大家好,作为一名深耕Android领域多年的开发。在日常开发中,我们每天都在和View打交道------写布局、自定义控件、优化UI卡顿,但很多同学对View绘制的底层逻辑只停留在"Measure、Layout、Draw"这三个关键词上,对其背后的调度机制、源码细节以及架构设计层面的考量知之甚少。
今天,我不想单纯复述官方文档的流程,而是结合自己多年的架构设计与性能优化经验,从"是什么、为什么、怎么用、怎么优化"四个维度,带大家吃透View绘制流程,理解每个环节的设计初衷,以及在实际项目中如何规避陷阱、提升UI性能。
先抛出一个核心观点:View绘制流程本质是"自上而下的树形遍历 + 分层渲染",由ViewRootImpl统一驱动,受Choreographer的VSync信号调度,全程围绕"效率"与"复用"两大核心设计,而我们所有的优化手段,本质上都是在贴合这个设计初衷。
一、绘制流程总览:先建立全局认知(避免陷入细节迷宫)
很多初学者一上来就扎进Measure的源码里,越看越乱,核心原因是没有先建立全局视角。我们先跳出细节,用一句话说清View绘制的完整链路:
当Activity启动或View发生重绘时,由ViewRootImpl发起,通过performTraversals()方法协调,自上而下递归执行Measure(测量)→ Layout(布局)→ Draw(绘制)三大阶段,最终通过RenderThread将绘制内容提交至GPU,由SurfaceFlinger合成后显示在屏幕上,全程严格遵循VSync信号的60Hz节拍(每帧16.6ms)。
这里有几个架构层面的关键认知,必须先明确(也是我面试时必问的点):
- View绘制是主线程独占操作:Measure、Layout、Draw的核心逻辑均运行在主线程,但Draw阶段的光栅化操作会交由RenderThread异步执行(硬件加速开启时),这是Android 5.0后性能优化的关键设计,避免主线程阻塞。
- ViewRootImpl是"唯一调度者":很多同学误以为Activity/Fragment会触发绘制,其实不然------Activity仅负责加载布局、管理DecorView,真正驱动绘制流程的是ViewRootImpl,它是连接WindowManager与DecorView的桥梁。
- 绘制是"递归遍历":View树的绘制采用自上而下的递归方式,父View负责驱动子View完成测量、布局和绘制,这意味着View树的层级越深,绘制耗时越长,这也是我们要减少布局层级的核心原因。
- VSync信号的核心作用:由Choreographer协调,确保每帧绘制都在16.6ms内完成,避免掉帧卡顿;requestLayout()和invalidate()均为异步触发,最终都会汇入performTraversals()完成整帧遍历。
用一张简单的流程图概括(贴合源码逻辑):
VSync信号 → Choreographer.postCallback() → ViewRootImpl.scheduleTraversals() → performTraversals() → performMeasure() → performLayout() → performDraw() → RenderThread光栅化 → SurfaceFlinger合成 → 屏幕显示
二、三大阶段深度拆解:源码锚点 + 架构思考
下面我们逐一拆解三大阶段,每个阶段不仅讲"流程",更讲"源码细节"和"实际开发中的坑",这也是架构师视角与初级开发者的核心区别------我们不仅要知其然,更要知其所以然,还要知道如何落地优化。
1. Measure阶段:确定"View要多大"------ 最容易踩坑的阶段
Measure的核心目的:测量View的宽高(measuredWidth/measuredHeight),由父View根据自身约束和子View的LayoutParams,生成MeasureSpec,再由子View根据MeasureSpec确定自身尺寸。
1.1 核心源码与关键概念
Measure的入口是ViewRootImpl.performMeasure(),最终调用View.measure()方法(该方法为final,无法重写,保证了绘制流程的稳定性),核心逻辑在View.onMeasure()中------这是我们自定义View时唯一需要重写的方法。
先搞懂MeasureSpec:这是一个32位整数,高2位是测量模式(Mode),低30位是测量尺寸(Size),设计初衷是"用一个int值打包两个信息,减少对象创建,提升效率",这是Android底层常用的内存优化技巧。
三种测量模式(结合实际场景理解,而非死记硬背):
- EXACTLY(精确模式):父View已确定子View的精确尺寸,对应LayoutParams中的match_parent或具体数值(如100dp)。此时子View的尺寸必须等于MeasureSpec中的Size,比如我们设置TextView的width为200dp,父View会给子View传递EXACTLY模式的MeasureSpec,子View直接使用该Size即可。
- AT_MOST(最大模式):父View给子View设定一个最大尺寸,子View的尺寸不能超过这个值,对应LayoutParams中的wrap_content。这里是最容易踩坑的地方------默认情况下,View的onMeasure()不会处理AT_MOST模式,会直接使用父View的Size,导致wrap_content和match_parent效果一样,这也是很多初学者自定义View时的常见错误。
- UNSPECIFIED(未指定模式):父View不对子View做任何约束,子View可以任意大小,常见于系统内部(如ScrollView的子View),日常开发中几乎用不到,但需要知道其存在。
关键源码片段(View.measure()简化版,API 33):
arduino
public final void measure(int widthMeasureSpec, int heightMeasureSpec) {
// 1. 检查缓存,避免重复测量(性能优化点)
if ((mPrivateFlags & PFLAG_FORCE_LAYOUT) != PFLAG_FORCE_LAYOUT
&& widthMeasureSpec == mOldWidthMeasureSpec
&& heightMeasureSpec == mOldHeightMeasureSpec) {
return; // 复用之前的测量结果
}
// 2. 核心:调用onMeasure(),由子类实现自身测量逻辑
onMeasure(widthMeasureSpec, heightMeasureSpec);
// 3. 校验:确保onMeasure()中调用了setMeasuredDimension()
if ((mPrivateFlags & PFLAG_MEASURED_DIMENSION_SET) == 0) {
throw new IllegalStateException("onMeasure() did not set the measured dimension");
}
}
1.2 架构思考与实际坑点
思考1:为什么View.measure()是final?------ 从架构设计角度,这是为了"规范流程"。如果允许重写measure(),可能会破坏父View对子View的约束逻辑,导致整个View树的测量混乱,比如子View擅自修改MeasureSpec,引发布局错乱。Google通过final修饰,强制开发者只能在onMeasure()中定制测量逻辑,保证了流程的稳定性和一致性。
思考2:为什么wrap_content需要手动处理?------ 这不是设计缺陷,而是"职责划分"。父View无法知道子View的内容大小(比如TextView的文本长度、ImageView的图片尺寸),因此将"确定自身实际大小"的职责交给子View,开发者需要在onMeasure()中计算内容尺寸,再结合AT_MOST模式的最大Size,确定最终尺寸。
实际坑点(亲身踩过的坑):
早年做一个自定义标签控件时,直接继承View,重写onMeasure()时没有处理AT_MOST模式,导致设置wrap_content后,控件宽度占满父容器,排查了半天才发现是没有手动计算内容宽度。正确的做法是:在onMeasure()中,先计算内容的实际宽高,再根据MeasureSpec的模式,取"内容宽高"和"MeasureSpec Size"的最小值(AT_MOST模式)或直接使用MeasureSpec Size(EXACTLY模式),最后调用setMeasuredDimension()保存结果。
优化建议:对于自定义View,优先重写onMeasure()并妥善处理三种模式;对于ViewGroup,尽量减少子View的重复测量(比如RelativeLayout会因为子View的依赖关系,可能触发两次测量,尽量用ConstraintLayout替代)。
2. Layout阶段:确定"View在哪个位置"------ 定位的核心是坐标计算
Layout的核心目的:确定View在父View中的位置,即确定View的四个顶点坐标(left、top、right、bottom),最终保存到mLeft、mTop、mRight、mBottom四个变量中,后续Draw阶段会根据这些坐标绘制View。
2.1 核心流程与源码
Layout的入口是ViewRootImpl.performLayout(),最终调用View.layout()方法,该方法同样是final,核心逻辑在View.onLayout()中------对于ViewGroup,必须重写onLayout(),因为需要遍历所有子View,计算每个子View的坐标并调用其layout()方法;对于普通View(如TextView),onLayout()为空实现,因为其位置由父View直接确定。
关键流程(以LinearLayout为例):
- 父View(LinearLayout)在onLayout()中,根据自身的方向(水平/垂直),遍历所有子View;
- 根据子View的measuredWidth/measuredHeight,结合自身的padding、子View的margin,计算子View的left、top、right、bottom坐标;
- 调用子View的layout()方法,将坐标传递给子View,子View保存坐标并触发自身的onLayout()(如果是ViewGroup,继续遍历子View)。
核心源码片段(View.layout()简化版):
ini
public void layout(int l, int t, int r, int b) {
// 1. 检查位置是否变化,若未变化则直接返回(性能优化)
if ((mLeft == l) && (mTop == t) && (mRight == r) && (mBottom == b)) {
return;
}
// 2. 保存新的坐标
mLeft = l;
mTop = t;
mRight = r;
mBottom = b;
// 3. 调用onLayout(),由子类处理自身布局(ViewGroup需重写)
onLayout(false, l, t, r, b);
// 4. 触发后续绘制相关逻辑
mPrivateFlags |= PFLAG_DRAWN;
}
2.2 架构思考与实际坑点
思考1:Layout阶段为什么也是自上而下递归?------ 因为子View的位置依赖于父View的位置和约束,只有先确定父View的位置和尺寸,才能计算出子View的坐标。这种设计符合"树形结构"的特性,逻辑清晰,但也意味着布局层级越深,坐标计算的耗时越长,这也是我们要扁平化布局的核心原因。
思考2:为什么View的位置坐标是相对于父View的?------ 这是"分层布局"的设计初衷。每个ViewGroup负责管理自己的子View,子View的坐标只需要相对于父View计算,无需关心整个屏幕的坐标,降低了耦合度,也便于布局的复用和维护。比如一个Button在LinearLayout中,它的left坐标是相对于LinearLayout的左边,而不是屏幕的左边。
实际坑点:
在做自定义ViewGroup时,曾遇到过"子View位置偏移"的问题,排查后发现是计算坐标时忽略了父View的padding和子View的margin。比如父View有10dp的paddingLeft,子View的left坐标应该从10dp开始计算,而不是从0开始;同时,子View的margin也需要纳入坐标计算,否则会导致子View之间重叠或间距异常。
优化建议:自定义ViewGroup时,务必妥善处理padding、margin和子View的坐标计算;尽量减少布局层级(如用ConstraintLayout替代LinearLayout嵌套),减少递归计算的耗时;避免在onLayout()中做耗时操作(如创建对象、网络请求),否则会阻塞主线程。
3. Draw阶段:将"View画到屏幕上"------ 渲染的核心是分层绘制
Draw的核心目的:将View的内容(背景、文本、图片、子View等)绘制到Canvas上,最终生成RenderNode显示列表,交由RenderThread异步光栅化,再提交给GPU渲染。
这里要注意:Draw阶段的流程是"自下而上"的------父View先绘制自己的背景,再绘制子View,最后绘制自己的前景和装饰(如滚动条),这样才能保证子View显示在父View的上方,符合视觉逻辑。
3.1 核心流程(View.draw()方法的内部逻辑)
View.draw()方法是Draw阶段的总入口,其内部严格遵循以下顺序(源码固定,不可修改):
- 绘制背景(drawBackground()):先绘制View的背景,背景的绘制位置由padding决定,这也是为什么padding会影响背景显示的原因。
- 绘制自身内容(onDraw(Canvas)):这是我们自定义View时最常用的方法,用于绘制文本、图形、图片等内容,Canvas是绘制的"画布",提供了各种绘制API。
- 绘制子View(dispatchDraw(Canvas)):仅ViewGroup有此逻辑,遍历所有子View,调用子View的draw()方法,实现子View的绘制。
- 绘制装饰(onDrawForeground()):绘制前景、滚动条、边缘效果等,位于所有内容的最上层。
关键注意点:
-
onDraw()默认是空实现:对于普通View(如TextView),系统已经重写了onDraw()方法,用于绘制文本;对于自定义View,若需要绘制内容,必须重写onDraw()。
-
硬件加速的影响:Android 4.0+默认开启硬件加速,此时draw()方法不会直接绘制到屏幕,而是生成RenderNode显示列表,交由RenderThread异步光栅化,减少主线程阻塞;若关闭硬件加速,则直接在主线程完成绘制。
-
invalidate()与postInvalidate():invalidate()用于主线程触发重绘,仅触发Draw阶段(跳过Measure和Layout);postInvalidate()用于子线程触发重绘,内部通过Handler切换到主线程后调用invalidate()。
3.2 架构思考与实际坑点
思考1:为什么Draw阶段是"自下而上"?------ 从视觉逻辑出发,父View是"容器",子View是"内容",内容应该显示在容器上方,因此父View先绘制自己,再绘制子View,最后绘制自己的前景,确保层级正确。这种设计符合用户的视觉习惯,也便于管理绘制顺序。
思考2:硬件加速为什么能提升性能?------ 核心是"异步渲染"和"显示列表复用"。开启硬件加速后,Draw阶段仅生成显示列表(记录绘制指令),实际的光栅化(将绘制指令转换为像素)交由RenderThread异步执行,主线程可以继续处理其他任务(如用户交互);同时,显示列表可以复用,若View未发生变化,无需重新生成绘制指令,直接复用之前的显示列表,大幅提升效率。
实际坑点(高频踩坑):
-
onDraw()中创建对象:曾遇到过一个项目,自定义View在onDraw()中创建Paint、Rect等对象,导致每帧绘制都会创建大量对象,触发GC,进而导致卡顿。原因是onDraw()会被频繁调用(每帧一次),频繁创建对象会占用大量内存,触发GC,阻塞主线程。解决方案:将Paint、Rect等对象作为成员变量,在构造方法中初始化,避免在onDraw()中创建。
-
过度绘制:这是UI卡顿的常见原因之一,指的是同一个像素被多次绘制(如父View和子View都绘制了背景,且重叠)。比如给LinearLayout设置了背景,又给其内部的TextView设置了背景,且两者重叠,就会导致过度绘制。解决方案:移除不必要的背景;使用Canvas.clipRect()裁剪绘制区域,避免绘制不可见的部分;使用View.setWillNotDraw(true)(对于不绘制内容的ViewGroup),跳过Draw阶段。
优化建议:尽量避免在onDraw()中做耗时操作;开启硬件加速(默认开启),但注意部分绘制API不支持硬件加速(如Canvas.drawTextOnPath()),需针对性处理;减少过度绘制,降低GPU渲染压力;复用绘制资源(如Paint、Bitmap),避免频繁创建和销毁。
三、架构层面的总结与优化思路(核心价值)
作为架构师,我们看待View绘制流程,不能只停留在"会用",更要思考"如何通过理解绘制流程,优化整个项目的UI架构和性能"。结合多年经验,我总结了3个核心优化思路,适用于大多数项目:
- 扁平化布局,减少View树层级:这是最直接、最高效的优化手段。View树的层级越深,Measure、Layout、Draw的递归耗时越长,因此尽量使用ConstraintLayout替代LinearLayout、RelativeLayout的嵌套,将布局层级控制在3层以内;对于复杂布局,可以使用自定义ViewGroup,减少子View的数量。
- 复用View,减少绘制频率:避免频繁创建和销毁View(如RecyclerView的复用机制);对于不需要频繁变化的View,尽量缓存其绘制结果(如使用View.setDrawingCacheEnabled(true));合理使用invalidate(),避免不必要的重绘(如仅在View内容变化时调用invalidate(),而非每次刷新都调用)。
- 贴合系统设计,规避性能陷阱:理解系统的优化设计(如Measure的缓存、硬件加速、VSync调度),不要对抗系统机制。比如不要重写measure()、layout()方法;不要在主线程做耗时的绘制操作;妥善处理wrap_content、padding、margin等细节,避免布局错乱和性能浪费。
四、最后:我的一点感悟
View绘制流程是Android UI的核心,也是架构设计的基础。很多同学觉得"绘制流程不重要,会写布局就行",但实际上,很多高级问题(如UI卡顿、布局错乱、自定义View兼容),本质上都是对绘制流程理解不深入导致的。
作为开发,我们不仅要自己吃透这些底层逻辑,还要能指导团队规避陷阱、优化性能。比如在项目初期,就制定布局规范(如禁止多层嵌套、避免在onDraw()中创建对象);在代码评审时,重点关注自定义View的绘制逻辑;在性能优化时,从绘制流程入手,定位卡顿根源。
最后,建议大家多阅读源码(View.java、ViewRootImpl.java、Choreographer.java),结合实际项目场景去实践,把理论知识转化为解决问题的能力。只有真正理解了View绘制的底层逻辑,才能在Android架构设计和性能优化的道路上走得更远。
好了,今天的分享就到这里。如果大家有关于View绘制、自定义View、UI性能优化的问题,欢迎在评论区交流,我会一一解答。
专注Android架构与性能优化,关注我,带你解锁更多Android底层干货~