Android图形系统这条路,我写到了第三篇。前两篇我们聊了Window、Activity和View那一层的东西,今天把视角拉到系统级,专门聊渲染和合成的底层链路。这个系列里,这篇是我觉得最值得反复读的,因为网上讲Activity生命周期、讲View绘制的文章很多,但能把“App画完一帧之后发生了什么”这件事讲透的,确实不多。简单说,这篇解决三个问题:一帧画面从App进程到屏幕上,经过哪些进程、哪几道工序;为什么有时候明明布局不复杂却掉帧;以及遇到渲染性能问题时,怎么从原理反推出排查方向。适合已经写过自定义View、看得懂onDraw,但对“屏幕为什么卡一下”“SurfaceView为什么快”这类问题还想再往深挖一层的同学。
1. 整体设计与核心思路:为什么要把渲染和合成拆成两件事
1.1 这是一条跨进程的生产线
Android的图形链路,本质上是一条跨进程的生产线。你可以把它想象成一家餐厅的后厨和传菜口:应用进程是厨师,负责把食材(UI状态)加工成菜品(一帧画面);SystemServer里有一个叫SurfaceFlinger的进程,是传菜员,负责把所有厨师做好的菜(各App的画面)按顺序摆到一个盘子里,最后端到餐桌上(屏幕)。
为什么必须拆成两个阶段?因为屏幕只有一个,但同时在“做菜”的App有好几个。状态栏、导航栏、桌面、你正在聊天的窗口,每一层都是独立App(或独立线程)画的。如果每个App直接去写屏幕显存,那画面一定乱套——你盖住了我,我刷掉了你。所以Android把“画画”和“上桌”分开:App只负责往自己的缓冲区块(Buffer)里画,画完交给SurfaceFlinger统一决策,谁在上面、谁在下面、谁需要被合成、谁直接透传,然后输出到显示器。
这背后是一套典型的生产者-消费者模型,用类比的思路梳理一下就清楚了。
| 角色 | 对应组件 | 职责 |
|---|---|---|
| 生产者 | App进程内的View系统 + HWUI渲染引擎 | 把界面绘制成像素/指令,写入BufferQueue的空闲Buffer |
| 缓冲队列 | BufferQueue | 管理Buffer的生产与消费,解决生产和消费速度不一致的问题 |
| 消费者 | SurfaceFlinger进程 | 从BufferQueue取走已填好的Buffer,合成后送显 |
| 屏幕 | Display / Composer | 最终把像素亮出来 |
1.2 渲染和合成是两套完全不同的“计时器”
很多性能问题分析不准,就是因为没搞懂渲染和合成各走各的时钟。渲染阶段的节拍器叫Vsync应用信号(Choreographer),合成阶段的节拍器叫Vsync合成信号(SurfaceFlinger的调度器)。虽然都源自同一个硬件VBlank(屏幕扫描完一帧后返回起点发出的脉冲),但中间经过了不同的分发路径,所以App画一帧和系统合一层,并不是严格的“你画完我马上合”。
这也解释了为什么有时候你的App明明在60fps稳定输出,但用户感知还是“有点不跟手”——因为合成阶段如果某一帧耗时太长,SurfaceFlinger来不及在下一轮Vsync之前完成合成,就会跳过一帧。所以做性能优化,不能只盯着App侧的渲染时间,合成侧的耗时同样是变量。
1.3 先把原理补齐再谈调优
我见过不少同学一上来就开“开发者选项-显示Surface更新”“调试GPU过度绘制”,一顿操作猛如虎,最后也没定位到根因。原因很简单:工具只能告诉你“这里有掉帧”“这里过度绘制了”,但不会告诉你“为什么”。要回答为什么,必须知道这条链路上每一站的工作方式。这篇博文就是想把这条链路从源头捋到尾,把每站的关键机制和对应工具串成一个整体,后面你无论是做应用层优化,还是做系统层定制,都有底子可以依靠。
2. 应用侧渲染链路:从setContentView到屏幕像素
2.1 setContentView只是“布置场地”,不是“画画”
先纠正一个常见的误解:很多人以为调用setContentView之后布局就开始绘制了,其实不是。setContentView做的事,本质上只是把XML布局解析成View树,挂到Window上,这时候屏幕上什么都没有。真正的第一次绘制,要等到Vsync回调把消息发到主线程,触发Choreographer的doFrame,才会走“measure(量尺寸) -> layout(摆位置) -> draw(画出来)”这套流程。
关于Choreographer,你可以把它理解成一个“节拍器服务员”:它跟硬件Vsync对齐,然后一杯一杯地把咖啡(回调)端给主线程。主线程接到回调就开始干活。如果主线程忙着处理别的事物(比如超长列表的item测量、一个巨大的Bitmap解码),没能在下一杯咖啡送来之前干完,那一帧就丢掉了——这就是掉帧的本质。
我第一次做性能排查时,总以为是draw方法里的path计算太慢,后来在Perfetto里才看到,真正吃掉时间的其实是上一帧遗留的measure/layout任务,draw本身反而是轻活。这个认知直接影响了我后来的写法:能不动的布局就不动,能动局部就不invalidate整棵View树。
2.2 CPU画指令,GPU画像素:DisplayList的由来
从Android 5.0开始,View的draw方法执行时,CPU不再直接往Bitmap上画像素,而是把绘制操作封装成一个指令列表,这个列表叫DisplayList。说人话就是,你把“画一个红色圆”“贴一张位图”“在这段文字上加粗”这些操作按顺序记录下来,但并不立刻执行。
这套设计的聪明之处在于缓存和复用。如果某一帧只是View的一个属性变化(比如平移),DisplayList本身没变,那下一帧直接重放指令就行,不需要重新measure、layout、遍历整棵View树。这也是属性动画比invalidate整View高效的原因之一。当然DisplayList也不是永远有效的,一旦View调用了invalidate或者它的绘制状态被标记为dirty,对应节点的DisplayList就得重建。
2.3 RenderThread与GPU执行:主线程不画像素的真正原因
DisplayList准备好之后,主线程的工作就基本结束了。接下来,一个叫RenderThread的渲染线程会接管DisplayList,把它转成OpenGL ES(现在也有Vulkan路径)的绘制命令,提交给GPU执行。这就是“硬件加速渲染”的默认模型:CPU负责“编剧本”(生成DisplayList),GPU负责“拍电影”(渲染像素)。
为什么Android要专门搞一个RenderThread?因为如果所有绘制命令都由主线程提交给GPU,那么当GPU繁忙时,主线程就会卡在等待GPU返回结果这一步,导致点击事件、输入事件全部没空处理,用户感知就是“点不动”“卡死”。有了RenderThread之后,主线程只管生成指令,提交和等待GPU是后台线程的事,两者可以流水线式并行。这在低端机上表现尤其明显,你给主线程减负一点点,用户能明显感觉到界面“活”了很多。
关于渲染引擎,这两年还有一个很值得关注的发展方向:Flutter新默认引擎Impeller,它把Skia的运行时shader编译很大一部分挪到了离线阶段,目标是解决首帧白屏和shader编译卡顿问题。这个思路对Android原生也有借鉴意义——你的App如果大量使用自定义Shader,编译阶段是绕不开的隐性成本,可以像我后面讲的那样,用预热方式规避。
2.4 BufferQueue:生产者和消费者之间的“物流中心”
RenderThread渲染完成后,GPU把像素写入一块Buffer,然后通过BufferQueue把这块Buffer交出去。BufferQueue本质上是一个有状态的队列,经典的三板斧操作是:dequeueBuffer(取一块空闲Buffer用于绘制)、queueBuffer(画完放回队列)、acquireBuffer(消费者取走处理)。
帧率能不能稳,Buffer数量是关键。老Android版本只有双缓冲,App在画帧A时,屏幕正在显示帧B,如果App画得太快,想画帧C但手里没Buffer了,只能干等屏幕把帧B扫完,于是帧率被强制拉低。后来引入三缓冲,相当于给生产者多一块后端Buffer缓冲,让生产者在某些时刻可以提前画,即使屏幕还没扫完也能继续干活。代价是额外的一层内存和显示延迟,硬件条件允许时,Android会自动在三缓冲和双缓冲之间切换,不需要开发者手动干预。
3. 合成阶段:SurfaceFlinger是如何把画面拼出来的
3.1 所有App的出口,都汇聚到SurfaceFlinger一个入口
应用侧画完之后,所有窗口的Buffer都进了SurfaceFlinger。SurfaceFlinger是整个图形系统的“中央调度室”,它维护了一张Layer列表,每个Layer对应一个窗口(更准确地说,一个Surface)。它要做的事是:在每次Vsync合成信号到来时,检查哪些Layer有新帧到来(dirty状态),然后根据每个Layer的显示参数(位置、大小、透明度、裁剪区域、Z序),决定如何把它们输出到屏幕
这里的“Z序”就是窗口的盖压关系。状态栏在顶层、输入法在需要时浮起来、Dialog在最上面,这些都是由WindowManager通过Transaction(事务)告知SurfaceFlinger的,比如setLayer、setPosition、setAlpha。如果你看过相关代码,会发现WindowManager几乎每帧都可能发起事务,SurfaceFlinger要高效合并这些事务再应用,也是不小的负载。
3.2 GPU合成与硬件合成:谁适合合成,谁只做透传
合成这一步,得看抬谁来做。如果所有Layer都能交给硬件合成器,那SurfaceFlinger就做个“甩手掌柜”,直接把各Layer的Buffer地址、格式、裁剪信息打包发给显示控制器,让硬件去完成叠加。这就是HWC(Hardware Composer)模式,省电且高效——注意,这里“合成”其实是硬件在显示扫描过程中完成的,不走GPU甚至不额外占内存带宽。
但硬件合成器并非全能的。某些Layer如果是GPU绘制的结果,需要做旋转/缩放/混合等复杂变换、需要特效处理,HWC大概率不支持,这时候SurfaceFlinger只能自己上,通过OpenGL ES把所有Layer合到一块新Buffer里,再把这块Buffer送去显示,这就是GLES合成。
还有一类更进阶的东西:硬件2D合成器。有些SoC会提供独立的2D引擎来做图层叠加,像T113这类方案里提到的G2D就属于此。这类引擎的核心价值在于:用专门硬件处理2D合成,比通用GPU更轻量、更省带宽,特别适合做LVGL这类GUI的渲染加速。但要用好它,得清晰区分哪些操作是“纯2D搬移/叠加”,哪些操作必须走GPU,这个边界不把握好反而可能比全走GPU还慢。
3.3 两个被混淆的概念:Layer与Surface
很多人会把Layer和Surface当成同一个东西,其实Surface是生产端的Buffer容器,Layer是合成端的一个显示实体。一个Surface被queueBuffer之后,SurfaceFlinger会为它建立/更新对应的Layer(或者Layer更新Buffer内容)。Layer还承担了Buffer的缓存管理:如果SurfaceFlinger发现一个Layer的内容没有变化(即没有新Buffer入队),就可以在合成时直接复用上一次的Buffer,不需要重新合成一遍。这也是为什么静态界面的省电效果远好于动画界面。
在实际工作中,统计Surface和Layer的数量是一项基本功。Layer太多,意味着每一帧合成时要做更多决策、处理更多Buffer,再强的合成器也会忙不过来。常见病根是:多个浮窗、多个透明Activity、频繁创建的SurfaceView。能用单Surface承载的内容,就尽量不要拆成多个窗口。
3.4 为什么SurfaceView可以“局部更新”且开销更低
讲到这里,SurfaceView的优势就很好理解了。普通View的一切都要先经过ViewRootImpl,走DisplayList渲染到App的Surface上,再由SurfaceFlinger合成。SurfaceView则单独拥有一块独立的Surface(独立的BufferQueue),而且默认情况下它还有个重要特性:它的Layer在Z序上位于宿主窗口的“挖洞”层之下,也就是宿主窗口那块区域相当于被挖空了,SurfaceView的内容直接从前台的独立Layer透传显示。
这意味着两点:第一,SurfaceView的内容更新,不需要经过App整体RenderThread重绘,它能做到独立于View树的刷新;第二,因为它是独立Layer,在合成时可以直接交给HWC透传,不走GPU合成,所以视频播放场景特别偏爱它。这也是为什么我经常建议:如果你要高频刷新一块区域(视频、相机预览、游戏画面),优先考虑SurfaceView,而不是在普通View里疯狂invalidate。
4. 性能优化实战:从原理反推排查方向
4.1 掉帧了,先分清是谁的锅
经验丰富的性能优化工程师看到掉帧,第一反应不是改代码,而是先分类:这帧是App侧渲染慢,还是合成侧慢,还是主线程调度慢?
三个方向对应不同的证据来源。用adb shell dumpsys gfxinfo可以看App的绘制统计数据,比如Draw、Prepare、Process等各阶段耗时,其中如果“Total GPU time”特别高,说明GPU负担重;如果想看合成侧的情况,dumpsys SurfaceFlinger --latency可以输出各Layer的帧时间戳信息,配合分析是否有掉帧或Buffer不及时。
我自己的习惯是:先开Perfetto或Systrace抓一段场景数据,找到掉帧的那个时刻,看主线程上是不是有一个很长的任务(常见的是setContentView大布局、getView重复创建、SharedPreferences读取),如果没有,再去RenderThread上找GPU瓶颈,去看SurfaceFlinger那一段的时间线。只要这条链路的分析顺序固定下来,大多数性能问题都能定位到具体环节,而不是靠猜。
4.2 过度绘制与DisplayList缓存:两个容易被忽视的重灾区
过度绘制是GPU压力的主要来源。开发者选项里的“调试GPU过度绘制”用颜色区分绘制层级:每多画一层就多一层颜色,最严重时整个屏幕都是红色的。典型问题包括:多层嵌套的背景叠底、带阴影的CardView大面积堆叠、DrawerLayout/Scrim在桌面层上又画了一层全屏遮罩。优化的本质是减少不必要的绘制指令:能用一层背景解决的,就不要叠三层;能用clipRect裁剪掉不可见区域的,就不让那部分像素白白参与渲染。
另一个重灾区是DisplayList缓存失效。很多时候你只是改了一个View的属性,但因为它处于一个复杂的ViewGroup中,invalidate一触发,整个父容器的DisplayList都得重建。这个问题在看列表(RecyclerView)时尤其明显:如果你的item根布局写得极其复杂,或者item复用机制没做好,很容易一两帧就把GPU打满。我处理过一个实际案例,明明GPU型号不差,但列表滑动就是掉帧,最后定位到的问题是item的阴影效果在每次滑动时都触发整棵子树DisplayList重建。把阴影改为预先画好的纯色阴影图后,帧率立刻回归平稳。
4.3 setLayerType与硬件层的取舍
View.setLayerType是一个容易被人滥用、也容易被人忽视的API。LAYER_TYPE_HARDWARE表示这个View会离屏渲染到一个Texture上,之后系统可以直接复用这块Texture,而不用每次重新执行draw方法。它适合用在动画频繁的场景(比如旋转、缩放一个复杂View),但有两个坑:
一是离屏缓冲会额外占用GPU内存,滥用的话内存吃紧,反而触发更频繁的GC和掉帧;二是如果View在动画过程中内容频繁变化(比如每帧都在更新里面的文本或Bitmap),那离屏缓冲还不如不建,因为每次内容变化还是要重画整个Texture,等于白绕一圈。所以我的经验是:先用工具确认动画期间是不是真的Draw方法重绘开销很大,确认了再用LayerType,不要一上来就“优化”。
另外,如果你的App确实在动画期间遇到了Shader编译卡顿,可以采用一种“预热”思路:在启动阶段,用一个小区域、低开销的方式提前触发目标Shader的一次编译,把编译耗时的尖峰平移到用户还没明显感知的时刻。这个技巧在Impeller出现之前,是很多引擎做首帧优化的妥协方案。
4.4 开发者选项里的实用开关
最后补充几个开发者模式里对图形调试极其实用的开关和它们的原理依据:
- “显示Surface更新”:让已提交的Surface在更新时闪烁,能快速发现哪些Surface在刷帧、哪些在隐藏地偷跑资源。
- “显示布局边界”:帮你看清楚每个控件的真实边界,Locate布局问题很直观。
- “模拟辅助显示设备”:如果你在做多屏异显或副屏适配,用它模拟多个逻辑显示。
- “Force GPU渲染”:让不支持硬件加速的绘制也强制走GPU路径,可以用来定位某些Canvas操作在软件渲染下是否成为瓶颈。
这些开关本身不复杂,关键是结合前面讲的链路,知道它们改的是哪一环,才能用得有意义。
5. 常见问题与排查技巧实录
5.1 问题速查表
下面是我在实际开发和调优中遇到的一些典型现象,整理成一张速查表,可以直接对照排查。
| 现象 | 可能原因 | 排查手段 | 解决方向 |
|---|---|---|---|
| 打开页面黑屏一闪 | Activity主题是黑/白背景,首帧未完成 | 看看dumpsys gfxinfo首帧时间、Theme配置 | 设置启动背景,或减少首帧工作量 |
| 列表滑动掉帧 | item布局复杂、DisplayList频繁重建 | Perfetto看RenderThread和主线程耗时 | 简化布局、复用View、减少invalidate范围 |
| 视频播放卡顿/黑块 | SurfaceView独立时序与窗口切换冲突 | 看SurfaceFlinger的Layer状态,检查SurfaceView的surfaceDestroyed时序 | 处理好Surface生命周期,必要时用TextureView兜底 |
| 界面整体白屏/无内容 | 合成Buffer异常或OpenGL崩溃 | 看logcat里OpenGL error,dumpsys SurfaceFlinger查Layer状态 | 检查硬件加速开关,确认是否有关闭硬件加速的异常 |
| 动画过程中掉帧严重 | 动画导致DisplayList频繁重建 | 用过度绘制查看绘制层级 | 考虑LAYER_TYPE_HARDWARE或将动画移到RenderThread可独立处理的对象上 |
| 高刷屏上帧率锁在60 | 系统合成或BufferQueue未匹配高刷模式 | dumpsys SurfaceFlinger --latency查看帧间隔 | 调整刷新率切换策略,确认HWC状态 |
5.2 容易被忽略的三个坑
第一个坑:StrictMode和掉帧检测只是结果指标,不是定位工具。我看到很多人卡顿发生时,只会说“这一帧16.9ms”,但根本不知道是哪里的16.9ms。其实应该要抓Perfetto,而且抓的时候一定要包含SurfaceFlinger进程,不然只能看到App内部隔离的一半真相。
第二个坑:View.setBackground的颜色叠加。这个问题很隐蔽:给根布局设了背景色,给RecyclerView的item也设了背景色,再给item里某个控件设了背景色,结果三层的背景全部参与了绘制,把GPU带宽消耗在了看不见的地方。很多所谓的“复杂布局导致卡顿”其实根本原因是背景叠加,把不必要的背景改成透明即可大幅改善。
第三个坑:Bitmap过大,GPU纹理超限。OpenGL ES对纹理尺寸上限有要求,部分低端机超过上限会直接绘制失败或黑屏。排查时要检查Bitmap原始尺寸,避免用超大尺寸图片做撑满屏幕的背景,正确做法是先用BitmapRegionDecoder等工具加载尺寸合适的采样图,而不是让GPU去处理一张明显超限的纹理。
写在最后
这条链路走下来,我觉得最有价值的一点是:当你真正理解渲染和合成是两套独立机制后,你在做界面优化时就不会再被“卡顿”这个词带偏,而是会冷静地问:是绘制阶段慢了,还是合成阶段慢了?是主线程掉帧,还是GPU掉帧?针对不同环节对症下药,很多之前靠乱试碰运气的优化,就变成了有依据的工程决策。
我个人还有一个习惯想分享给你:遇到难定位的渲染问题,先别急着改代码,先用dumpsys命令把当前进程的Surface、Layer、缓冲情况全部打出来看一眼,很多时候问题会自己浮出水面。比如你怀疑某个弹窗一直在做动画,一查Layer状态,发现它确实每隔几帧就在queueBuffer,那问题的源头就清楚了。
最后再补一条实战经验:动手优化前,先在真机上把“GPU呈现模式分析”打开,跑一遍目标场景,观察条形图的高低和颜色分布。如果绿色条密集且高度一致,说明渲染管线稳定;如果出现很多红色长条,再去深挖具体阶段。磨刀不误砍柴工,原理和工具配合起来,比一上来就写优化方案靠谱得多。