Android学习总结之自定义View绘制源码理解
1. View 重绘触发:requestLayout()
如何启动绘制流程?
当调用 View.requestLayout()
时,源码逻辑如下(基于 Android 13 源码):
- View.java:
public void requestLayout() {if (mMeasureCache != null) mMeasureCache.clear(); // 清除测量缓存mLayoutRequested = true; // 标记布局需要更新// 向上遍历父容器,直到找到 ViewRootImpl(窗口顶级容器)ViewParent parent = getParent();if (parent != null && !parent.isLayoutRequested(this)) {parent.requestLayout(); // 递归父容器的 requestLayout} }
最终会调用到 ViewRootImpl.requestLayout(),该方法会通过checkThread()
确保在主线程(否则抛CalledFromWrongThreadException
),然后调用scheduleTraversals()
:private void scheduleTraversals() {if (!mTraversalScheduled) {mTraversalScheduled = true;// 通过 Choreographer 安排下一帧的绘制任务(重点!)mChoreographer.postCallback(Choreographer.CALLBACK_TRAVERSAL, mTraversalRunnable, null);// 触发 vsync 信号等待(60Hz 时约 16ms 一次)if (!mUnbufferedInputDispatch) {scheduleConsumeBatchedInput(); // 处理输入事件}} }
关键点:requestLayout()
只会标记布局需要更新,真正的绘制流程由 Choreographer 驱动,结合 vsync 信号(60Hz 对应 16ms 间隔)在下一帧执行。- 若在子线程调用
requestLayout()
,会因ViewRootImpl.checkThread()
校验失败而崩溃(除非通过Looper.getMainLooper()
切换到主线程)。
2. 布局周期为什么是 16ms?深入 Choreographer 与 vsync
Android 的屏幕刷新率通常为 60Hz(即每秒 60 帧),每帧理想耗时 16ms(1000ms/60 ≈ 16.6ms
)。绘制流程由 Choreographer 协调,其核心逻辑在 Choreographer.java:
- Choreographer 通过 FrameDisplayEventReceiver 监听底层屏幕的 vsync 信号(由硬件定时发送)。
- 当 vsync 到来时,Choreographer 会触发回调(如
CALLBACK_TRAVERSAL
),执行ViewRootImpl
的performTraversals()
方法,该方法依次调用:performMeasure() → performLayout() → performDraw()
若这三个步骤总耗时超过 16ms,就会导致 掉帧(界面卡顿),严重时触发 ANR(主线程阻塞超 5s)。
面试官常问:“如果自定义 View 的 onMeasure
或 onLayout
耗时过长,会发生什么?”
→ 答:超过 16ms 会导致掉帧,若主线程阻塞超过 5s(如后台耗时操作未切线程),则触发 ANR。
3. MotionEvent 坐标数量与屏幕采样率的关系
触摸事件由 MotionEvent 封装,其坐标数据与 屏幕采样率(getDisplay().getRefreshRate()
)和 触控传感器采样率有关:
- 单点触控:每次事件(如
ACTION_MOVE
)包含 当前坐标(getX()
/getY()
),但多次ACTION_MOVE
的间隔由采样率决定(如 120Hz 屏幕可能每 8ms 产生一次移动事件)。 - 多点触控:通过
getPointerCount()
获取触点数量,每个触点有独立坐标(getX(int pointerIndex)
)。
源码证据:
在 MotionEvent.java 中,坐标存储在数组 mX
和 mY
中,支持最多 MAX_POINTERS
(通常为 10)个触点。每次触摸事件的坐标数量等于当前活动的触点数,而非 “移动前和移动后” 两个坐标(用户之前回答错误)。
面试官追问:“如何优化滑动卡顿?”
→ 答:减少 onTouchEvent
耗时,避免在事件处理中执行复杂计算;使用 VelocityTracker
计算滑动速度;确保 View
层级简洁,减少 measure/layout/draw
耗时。
4. 源码级分析:View 绘制的三大核心步骤
- measure():确定 View 的宽高(
MeasureSpec
控制),调用onMeasure()
。- 自定义 View 需重写
onMeasure()
,并通过setMeasuredDimension()
设置最终尺寸,否则抛IllegalStateException
。
- 自定义 View 需重写
- layout():确定 View 的位置(
left/top/right/bottom
),调用onLayout()
(ViewGroup 实现,View 默认为 0,0 固定大小)。 - draw():绘制内容,流程为
background → content → children → decoration
,调用onDraw()
(View 实现,ViewGroup 通常不重绘,除非设置willNotDraw=false
)。
关键类:
- ViewRootImpl.performTraversals():驱动三大步骤的核心方法,源码超 2000 行,通过标志位(
mLayoutRequested
,mDirty
)判断是否需要执行某一步骤。 - ViewGroup.layoutChildren():遍历子 View 调用
layout()
,是布局嵌套的核心逻辑。
5. 实战:如何通过源码定位绘制卡顿?
- 开启 GPU 过度绘制调试(开发者选项),红色 / 绿色区域表示过度绘制。
- Systrace 追踪:使用
adb shell systrace -t 10 -o trace.html gfx input view
,分析performTraversals
耗时是否超过 16ms。 - 自定义 View 重写方法耗时:在
onMeasure/onLayout/onDraw
中添加耗时统计,如:long start = System.currentTimeMillis(); super.onMeasure(widthMeasureSpec, heightMeasureSpec); Log.d("ViewDebug", "onMeasure耗时:" + (System.currentTimeMillis() - start) + "ms");
- Choreographer 帧间隔日志:通过
Choreographer.FrameCallback
监听每帧耗时,超过 16ms 则记录警告。
总结(面试官想听到的源码深度)
- 绘制触发:
requestLayout()
→ViewRootImpl.scheduleTraversals()
→ Choreographer 绑定 vsync 信号,16ms 一周期。 - 核心流程:
performTraversals()
串联measure/layout/draw
,自定义 View 需正确实现三大方法,避免耗时操作。 - 触摸事件:
MotionEvent
支持多点触控,坐标数量由触点数决定,采样率影响事件频率(非 “两个坐标”)。
面试扩展:
一、View 绘制流程(必考题:从源码角度说明三大流程)
面试官问题:
“请详细说明 Android View 的绘制流程,涉及哪些关键类和方法?自定义 View 时需要重写哪些方法?”
深度回答:
Android View 绘制分为 measure(测量)、layout(布局)、draw(绘制) 三大流程,由 ViewRootImpl
统一协调
-
measure 阶段(确定宽高)
- 核心方法:
View.measure(int, int)
→View.onMeasure(int, int)
MeasureSpec
控制测量规则(EXACTLY
/AT_MOST
/UNSPECIFIED
),自定义 View 需重写onMeasure
并调用setMeasuredDimension(width, height)
,否则抛IllegalStateException
。- 源码示例(LinearLayout 测量子 View):
// ViewGroup.java protected void measureChildren(int widthMeasureSpec, int heightMeasureSpec) { final int size = mChildrenCount; final View[] children = mChildren; for (int i = 0; i < size; ++i) { final View child = children[i]; if (child.getVisibility() != GONE) { measureChild(child, widthMeasureSpec, heightMeasureSpec); // 递归测量子View } } }
- 核心方法:
-
layout 阶段(确定位置)
- 核心方法:
View.layout(int, int, int, int)
→View.onLayout(boolean, int, int, int, int)
(仅 ViewGroup 需重写,View 默认位置为 (0,0))。 - 父 View 通过
setChildFrame(child, left, top, right, bottom)
确定子 View 坐标,如RecyclerView
的LayoutManager
在此阶段计算子 View 布局。
- 核心方法:
-
draw 阶段(像素渲染)
- 核心方法:
View.draw(Canvas)
,分 6 步(背景→内容→子 View→修饰),自定义 View 需重写onDraw(Canvas)
绘制内容(如绘制文本、路径)。 - 源码关键逻辑:
// View.java public void draw(Canvas canvas) { int saveCount; // 1. 绘制背景 drawBackground(canvas); // 2. 绘制主体内容(自定义View重写此步) onDraw(canvas); // 3. 绘制子View(仅ViewGroup执行) dispatchDraw(canvas); // 4. 绘制前景(边框、滚动条等) onDrawForeground(canvas); }
- 核心方法:
加分项:
- 提及
ViewRootImpl.performTraversals()
是三大流程的总入口,通过mLayoutRequested
/mDirty
标志位判断是否执行对应流程。 - 强调自定义 View 时:
- 仅绘制内容 → 重写
onDraw
; - 自定义尺寸 → 重写
onMeasure
并正确处理MeasureSpec
; - 自定义容器 → 重写
onLayout
并遍历子 View 调用layout()
。
- 仅绘制内容 → 重写
二、requestLayout () 与重绘触发(高频陷阱题)
面试官问题:
“requestLayout()
、invalidate()
、postInvalidate()
有什么区别?为什么不能在子线程调用前两者?”
深度回答:
方法 | 触发流程 | 线程限制 | 使用场景 |
---|---|---|---|
requestLayout() | 触发 measure + layout | 必须主线程 | View 尺寸 / 布局参数变化(如setLayoutParams ) |
invalidate() | 触发 draw (可能先触发requestLayout ) | 必须主线程 | View 内容变化(如文字 / 颜色更新) |
postInvalidate() | 内部通过Handler 切主线程 | 子线程可用 | 子线程中触发重绘(等价invalidate() 的线程安全版) |
源码级差异:
-
requestLayout()
线程校验:// ViewRootImpl.java public void requestLayout() { checkThread(); // 校验是否为主线程,否则抛异常 mLayoutRequested = true; scheduleTraversals(); } void checkThread() { if (mThread != Thread.currentThread()) { throw new CalledFromWrongThreadException( "Only the original thread that created a view hierarchy can touch its views."); } }
子线程调用会因
checkThread()
失败崩溃,需通过view.post(() -> requestLayout())
间接调用。 -
invalidate()
触发条件:- 若 View 尺寸变化(如
LayoutParams
修改),invalidate()
会先调用requestLayout()
触发测量布局,再执行绘制。 - 若仅内容变化(如
setText()
),直接标记mDirty
区域,跳过测量布局,仅重绘。
- 若 View 尺寸变化(如
三、布局周期 16ms 与性能优化(核心原理题)
面试官问题:
“为什么说 Android 的理想布局周期是 16ms?超过会怎样?如何通过源码定位掉帧?”
深度回答:
-
16ms 的由来(VSYNC 机制):
- 主流屏幕刷新率 60Hz(每秒 60 帧),每帧理想耗时
1000ms/60 ≈ 16.6ms
,由Choreographer
(源码Choreographer.java
)监听硬件 VSYNC 信号(垂直同步),确保绘制与屏幕刷新同步。 Choreographer
通过FrameDisplayEventReceiver
接收 VSYNC 事件,触发ViewRootImpl
的performTraversals()
执行绘制(约 16ms 一次)。
- 主流屏幕刷新率 60Hz(每秒 60 帧),每帧理想耗时
-
掉帧与卡顿:
- 若
measure+layout+draw
总耗时超过 16ms,会导致 掉帧(该帧被丢弃),连续掉帧即感知为卡顿; - 主线程阻塞超 5s 触发 ANR,日志中可见
Input dispatching timed out
。
- 若
-
源码级性能分析:
- 使用 Systrace 追踪:
adb shell systrace -t 10 -o trace.html gfx input view
,查看performTraversals
各阶段耗时(绿色为 measure,蓝色为 layout,红色为 draw)。 - Choreographer 帧回调:
Choreographer.getInstance().postFrameCallback(new Choreographer.FrameCallback() { @Override public void doFrame(long frameTimeNanos) { long frameDuration = frameTimeNanos - mLastFrameTimeNanos; if (frameDuration > 16_666_667) { // 超过16.6ms即掉帧 Log.w("ViewRender", "Frame dropped: " + frameDuration); } mLastFrameTimeNanos = frameTimeNanos; Choreographer.getInstance().postFrameCallback(this); } });
- 使用 Systrace 追踪:
四、触摸事件与坐标(细节辨析题)
面试官问题:
“MotionEvent
中的坐标如何获取?多点触控时如何区分不同触点?移动事件的坐标数量由什么决定?”
深度回答:
-
坐标类型:
- 绝对坐标:
getX()
/getY()
(相对于屏幕左上角); - 相对坐标:
getRawX()
/getRawY()
(相对于设备左上角,含系统栏高度)。
- 绝对坐标:
-
多点触控支持:
- 通过
getPointerCount()
获取触点数量,每个触点有唯一pointerId
(通过getPointerId(int index)
获取); - 单个触点坐标:
getX(int pointerIndex)
/getY(int pointerIndex)
,如双指缩放时需同时获取两个触点的坐标。
- 通过
-
采样率影响:
- 坐标数量与 屏幕采样率(
Display.getRefreshRate()
)和 触控传感器采样率 相关,高刷新率屏幕(如 120Hz)会更频繁触发ACTION_MOVE
(约 8ms 一次),每次事件携带当前触点坐标(非 “移动前 / 后两个坐标”)。
- 坐标数量与 屏幕采样率(
源码证据:
MotionEvent
内部通过数组存储多触点坐标(mX[]
, mY[]
),支持最多 MAX_POINTERS
(通常 10 个),示例:
// 处理多点触控
for (int i = 0; i < event.getPointerCount(); i++) { float x = event.getX(i); float y = event.getY(i); int pointerId = event.getPointerId(i); // 根据pointerId区分不同手指
}
五、实战:自定义 View 常见坑与优化(经验题)
面试官问题:
“自定义 View 时容易出现哪些性能问题?如何避免过度绘制和布局嵌套?”
深度回答:
-
测量尺寸未正确处理:
- 错误:未在
onMeasure
中调用setMeasuredDimension()
,导致运行时崩溃; - 正确:
@Override protected void onMeasure(int widthSpec, int heightSpec) { int width = measureDimension(DEFAULT_WIDTH, widthSpec); int height = measureDimension(DEFAULT_HEIGHT, heightSpec); setMeasuredDimension(width, height); } private int measureDimension(int defaultSize, int measureSpec) { int specMode = MeasureSpec.getMode(measureSpec); int specSize = MeasureSpec.getSize(measureSpec); return specMode == MeasureSpec.EXACTLY ? specSize : defaultSize; }
- 错误:未在
-
过度绘制优化:
- 开启 GPU 过度绘制调试(开发者选项→调试 GPU 过度绘制),红色表示过度绘制 4 次以上;
- 避免多层透明背景,使用
android:background="@null"
或setWillNotDraw(true)
(ViewGroup 默认不绘制,减少不必要的draw
调用)。
-
布局嵌套优化:
- 用
ConstraintLayout
替代多层LinearLayout/RelativeLayout
,减少measure/layout
次数; - 使用
ViewStub
延迟加载非必要视图,避免启动时全量渲染。
- 用