最近在排查一个线上启动卡顿的问题,抓了 Perfetto 回来,组里新同学盯着 trace 问我:"UI 线程明明已经退出来了,为什么这一帧还是没上屏?"我说别急着猜,你先把 Android 显示完整链路从头到尾画出来——从 App 内部的 UI 线程一路画到屏幕面板上的那排像素,画完你自然知道该查谁。
说实话,干了这么多年 Android,真正能把这条链路讲清楚的人不多。多数人知道"卡顿、掉帧、SurfaceFlinger",但说不出每帧从 App 到屏幕到底经过了哪些节点、每个节点在等什么、谁在给整条链路打节拍。这篇是第一篇,我不钻某个模块的牛角尖,先带你把整张图拼完整。适合这几类人看:被掉帧问题折磨的应用开发者、刚开始接触 Framework 的系统工程师、以及所有想搞懂"一帧画面是怎么亮起来的"的 Android 从业者。
1. 全链路地图:一个像素从App到屏幕要走四段路
1.1 四段路程,四个关键角色
我在白板上画这条链路时,习惯把它分成四段:
| 阶段 | 进程 | 关键对象 | 产出物 |
|---|---|---|---|
| App 绘制 | 应用进程 | Choreographer、ViewRootImpl、View | DisplayList(绘制指令) |
| App 渲染 | 应用进程 RenderThread | HWUI、Skia、OpenGL/Vulkan | GPU 帧缓冲 |
| 系统合成 | 系统进程 | BufferQueue、SurfaceFlinger | 合成后的最终帧 |
| 硬件显示 | 硬件层 | HWComposer、Display Controller | 屏幕上的可见像素 |
App 绘制阶段干的活,是执行 measure、layout、draw 这三板斧,把 View 树转换成一套绘制指令。这一步很多人以为是把像素画出来了,其实在硬件加速时代并不是——UI 线程只是把"怎么画"记下来,真正动手是后面的事。
App 渲染阶段,RenderThread 拿到这套绘制指令,通过 Skia 等绘制引擎转成 GPU 能懂的 OpenGL 或 Vulkan 命令,最终由 GPU 栅格化出一整块图像数据,写进一个叫 Buffer 的内存区域。这是 App 产出的"一帧"。
系统合成阶段,各个 App 各自画好自己的那一块,但屏幕上要同时显示多个 App 的界面(比如微信在前台,状态栏是系统画的,分屏时还有另一个应用),总得有人把所有图层拼成一张完整的画面。这个人就是 SurfaceFlinger,运行在 system_server 进程里。
硬件显示阶段,SurfaceFlinger 拼好的完整帧交给 Hardware Composer(缩写 HWC),由显示控制器把这块图像数据在正确的刷新节拍里推到屏幕面板上。像素在这里真正发光。
1.2 Vsync:整条链路的节拍器
四段路不是谁想走就走的。Android 的显示链路里,从上到下横着一个节拍器,叫 Vsync(垂直同步信号)。
屏幕是逐行刷新的,刷到底部后再回到顶部重新开始,这个"一上一下"之间发送的同步信号就是 Vsync。屏幕每刷新一帧,就发出一个脉冲。60Hz 的屏幕一秒发 60 个脉冲,每两个脉冲之间 16.6ms;120Hz 的屏幕一秒 120 个脉冲,间隔 8.3ms。这个数字直接决定了 App 的帧预算:60Hz 屏幕上一帧最多花 16.6ms,超过这个数屏幕就要等下一拍,反映在用户眼里就是掉帧、卡顿。
你可能会问:为什么非要跟屏幕对齐?如果不对齐,App 任意时刻画完就把数据推给屏幕,屏幕很可能在刷新到一半时切换到新帧,上半屏是新的、下半屏还是旧的,画面被水平撕开,这就是撕裂(tearing)。有了 Vsync 这个全局节拍器,大家统一在脉冲到来时开始干活,画面切换永远发生在屏幕不显示的那一瞬间,撕裂就消失了。
所以请记住一句话:Vsync 不只是刷新率,它是整条链路所有节奏的源头。后面讲到的 Choreographer、SurfaceFlinger 的合成时机,全都是在等这个信号。
2. App侧那16ms:UI线程和RenderThread到底谁在干活
2.1 触发点:ViewRootImpl 的 performTraversals
应用侧这场接力赛的起点,是 Vsync 信号到来时 Choreographer 唤醒 UI 线程,走到 ViewRootImpl 的 performTraversals() 方法。这个方法里依次执行我们熟悉的 View 遍历:
- measure:测量每个 View 想占多大尺寸,并向上汇报;
- layout:父容器根据测量结果给子 View 摆位置;
- draw:执行绘制逻辑,生成绘制指令。
注意一个关键细节:这套流程跑在 UI 线程上,它本质上是反应式的——你今天写 XML、写自定义 View 的大部分运行成本,都集中在这三个动作里。比如一个嵌套了三层 LinearLayout 的界面,measure 阶段可能被多次调用,一层一层把约束往下传;layout 阶段同理。UI 线程在这一帧里拖得越久,留给后面渲染、合成的时间就越少。
我遇到过一个很典型的错误:在自定义 View 的 onDraw 里做 Bitmap 裁剪、旋转操作,每次帧都会重新创建 Paint 和 Path 对象。从原理上看,onDraw 在硬件加速模式下只负责"记录绘制指令",高频创建对象既拖慢指令生成,又制造大量 GC。后来改成预创建对象、draw 阶段只引用,帧耗时直接降了一半。
2.2 DisplayList:UI线程凭什么可以提前收工
很多人不理解:硬件加速渲染下,draw 到底画了什么?
从 Android 5.0 开始,系统引入了 RenderThread,UI 线程的 draw 被彻底"架空"了——它不再直接把像素画到画布上,而是把每次 Canvas 调用的参数(画什么形状、用什么颜色、贴哪张纹理)记录进一个叫 DisplayList 的结构里,然后把这些记录打包给 RenderThread。
举个例子,你写了一个自定义 View,onDraw 里调用 canvas.drawCircle(cx, cy, radius, paint)。软件渲染年代,这句话会立刻把圆画进画布对应的像素;硬件渲染年代,这句话只是往 DisplayList 里塞了一条"在 (cx, cy) 画半径 radius 的圆,用某个 Paint"的指令记录。所以 UI 线程结束得很快,它更像建筑师在画施工图,真正的施工队是 RenderThread。
DisplayList 还带来了一个附带福利:RenderNode 会缓存这套绘制指令,View 的树形结构里每个节点都对应一个 RenderNode。一帧里如果只有一小块区域脏了,就不必重放全部指令,只更新对应 RenderNode 就好——这就是为什么很多人经常听到"脏区域重绘"的概念。就这个原则:不要把大面积的绘制逻辑放在被频繁 invalidation 的地方,不然缓存优势会被浪费掉。
2.3 自己动手测掉帧:一个 FrameCallback 就够了
搞清 UI 线程和 RenderThread 的分工之后,第一件事就是学会测自己的 App 到底掉没掉帧。最简单粗暴的办法是挂一个 Choreographer.FrameCallback:
Choreographer.getInstance().postFrameCallback(new Choreographer.FrameCallback() { private long lastTimeNanos = 0; @Override public void doFrame(long frameTimeNanos) { if (lastTimeNanos != 0) { long spendNanos = frameTimeNanos - lastTimeNanos; long interval = (long) (1000000000 / (float) Choreographer.getInstance().getFrameIntervalNanos()); // 实际用的时间超过一个帧间隔,说明中间至少漏了一帧 if (spendNanos > interval) { Log.i("FrameMonitor", "掉帧: " + spendNanos / 1000000 + " ms"); } } lastTimeNanos = frameTimeNanos; Choreographer.getInstance().postFrameCallback(this); } });注意一个容易踩的坑:doFrame 里拿到的 frameTimeNanos 是 Vsync 的开始时间,不是回调实际执行时间。拿两次回调之间的真实时间差判断掉帧,更直观。线上环境不建议这么高频打日志,调试阶段用它足够。
如果你想拿更细的数据,Android 也提供了 FrameMetrics(API 24+),在 OnFrameMetricsAvailableListener 里可以直接拿到 UI 耗时、GPU 耗时、提交耗时等分布,后面讲排障时会展开说。
3. Surface与BufferQueue:App和系统之间的帧交接仪式
3.1 一块画板,一个传菜窗口
App 把帧画完之后,怎么交给系统?这里就得请出 Android 显示的经典组合:Surface 和 BufferQueue。
Surface 是 App 手里的"画板"。应用拿到 Window 对应的 Surface 之后,所有绘制结果最终都写到这个画板上。画板本身不直接对应屏幕内存,它的内部实现由 BufferQueue 支撑——一个典型的生产者-消费者队列:App 的渲染线程是生产者,SurfaceFlinger 是消费者。
生产者的动作是这套流程:
- dequeueBuffer:向 BufferQueue 申请一块空闲的 Buffer;
- CPU/GPU 把内容画进这块 Buffer;
- queueBuffer:画完的 Buffer 进入队列排队;
- 消费者(SurfaceFlinger)acquireBuffer 取走做合成;
- 合成完成后 releaseBuffer 把 Buffer 归还给队列。
说个题外话:很多人面试时说"Surface 是画布",这句话不算错。但你要知道真正干活的是 BufferQueue 里那一块块身体板正的 Buffer,Surface 只是它们的门面。
3.2 双缓冲换三缓冲,到底换了什么
这里我一直觉得用"餐厅出餐口"比喻最贴切。客人(屏幕)正在吃 A 桌的菜,厨房(App)要开始做下一道菜,总得有个空盘子才行。
双缓冲就是两个盘子轮换:一个盘子正在被客人吃,一个盘子厨房正在用。理论上速度匹配的话够用,但如果消费者偶尔慢一拍,厨房去 dequeueBuffer 时发现两个盘子都被占用,就只能干等——这一等,这帧的 Vsync 就错过了,屏幕只能重复显示上一帧,肉眼感知为掉帧。
三缓冲相当于出餐口多备了一个盘子。一个正在屏幕显示、一个正在被合成器取走、一个正被厨房使用,三个槽位轮转,厨房 dequeue 被阻塞的概率大幅降低。代价是显示多了一帧延迟——你会发现某些游戏里三缓冲的跟手性略差,但流畅度明显提升。这就是为什么系统在 BufferQueue 压力大时会自动把缓冲数量从 2 加到 3。
你实际排查时可以留意一个信号:如果 App 侧在 dequeueBuffer 上频繁耗时,说明队列里的 Buffer 都被消费端占着,这是消费太慢的信号;如果 dequeue 正常、但 queueBuffer 后等了很久,那多半是 GPU 还没画完,属于渲染侧的问题。
3.3 Fence:让 CPU、GPU、合成器不用互相干等
Buffer 交接过程中还有个容易忽略的角色:Fence。
如果没有 Fence,GPU 在画当前帧时,CPU 想深入下一帧的绘制命令,就必须等 GPU 干完活才能走,成本极高。Fence 的思路是:GPU 声称"我画完了这块 Buffer"时,并不保证真画完,它只是给一个信号量;消费方拿到 Buffer 后先不读,等 GPU 发出"我真画完了"的 Fence 信号再真正取用。这一等是异步的,CPU 不用停下手里的活。
这就很像点外卖时 App 上显示"订单已完成",外卖员其实还在路上,只有到达后才给你发短信。有了短信(Fence)机制,你就不会傻站门口干等,可以先干别的事。
做系统级优化时,Fence 时间是判断瓶颈的重要依据——如果一帧总耗时里 GPU fence 等待特别长,说明绘制指令太重或 GPU 频率不足;如果 fence 等待很短但整帧晚了,问题很可能在 CPU 侧或合成侧。这块需要有 trace 辅助分析,后面会说到。
4. SurfaceFlinger:画面最终由谁拼到一起
4.1 每帧它都在忙什么
现在视角从 App 进程切到系统进程。SurfaceFlinger(下面简称 SF)是 Android 的画面总编辑,它手里管着所有已注册的 Layer——每个 Window 的 Surface 本质上都对应一个 Layer,包含 Buffer、变换矩阵、透明度、位置这些信息。
每次 Vsync 到来,SF 会按这个顺序走一遍主循环:
- 处理事务(Transaction):应用通过 SurfaceControl 提交的窗口移动、大小变化、图层增删、透明度修改都在这里汇总;
- 计算可见区域:判断哪些 Layer 需要参与合成,被完全遮挡的 Layer 直接跳过;
- 逐个取走需要合成的 Buffer(acquireBuffer);
- 调用合成引擎把多个 Layer 混合成一张完整的帧图像;
- 把成果交给 HWC 或者其他显示路径输出。
事务处理是很多人没概念但实际容易出问题的地方。比如一个页面在做位移动画,如果每帧都更新大量 Layer 属性,Transaction 的解析就会拖慢 SF 主循环。遇到"App 侧一点都不卡,但整体就是掉帧"的情况,记得去 trace 里看 SF 的 Transaction 阶段。
4.2 两种合成方式:Client 合成与 Device 合成
SF 合成图层时,有两条技术路线:
- Client 合成:SF 调用 OpenGL ES,在 GPU 上把多个 Layer 画到一块输出 Buffer 上,也叫 GPU 合成;
- Device 合成:SF 把图层信息直接交给 HWC,由显示控制器的 Overlay 硬件模块在扫描输出的最后一刻混合,也叫硬件合成。
你可以理解成:Client 合成是后厨把几道菜先盛到一个大盘子里再端出去;Device 合成是把几道菜直接放在一个多格托盘上交给传菜员,出菜口现场摆盘。后者省掉了一次内存搬运,功耗、性能都更好。
但 Overlay 硬件通道数量有限,一般只有 2-4 层。当屏幕上的 Layer 数量超过这个数,或者单个 Layer 对合成引擎有特殊要求(比如特殊色彩格式、旋转角度),SF 就得退回到 Client 合成。这也是为什么系统优化常鼓励减少不必要的悬浮层的底层原因——每多一个悬浮窗 Layer,合成方案就可能从 Device 降级成 Client,GPU 功耗立刻上去。
4.3 用 dumpsys 快速看合成状态
想知道当前合成走的是哪条路,一条命令就能看到大概:
adb shell dumpsys SurfaceFlinger输出里包含了每个 Layer 的名字、大小、标志位以及 Buffer 状态。重点关注标记为 client composition 或 device composition 的条目,能帮你判断是不是 Layer 过多导致合成压力。
想看掉帧记录,用:
adb shell dumpsys SurfaceFlinger --latency这条命令会打印出每个 Layer 的历史帧时间戳,写的是纳秒级别的 desired present time、actual present time 等信息。看实际呈现时间和期望呈现时间差多少,就能知道那一帧到底有没有按节奏上屏。如果实际呈现间隔出现明显跳变,说明合成侧丢帧了。
这类命令输出的字段在不同 Android 版本上略有差异,我一般只看两个关键时间点的差值,不在字段细节上较劲。工具是辅助,把链路原理吃透才是根本。
5. 最后一公里:HWC如何把帧送到物理屏幕
5.1 HWC的合成决策逻辑
HWC 是 Hardware Composer 的缩写,属于 vendor 实现的一块 HAL 硬件抽象。它最大的价值是给上层提供一条"零拷贝"的合成路径。
SF 每次合成前都会询问 HWC:我手头这些 Layer,你能用 Overlay 帮我直接混出来吗?HWC 根据显示控制器上可用的 Overlay Plane 数量和每块 Buffer 的格式给出方案。能走 Overlay 的就走,不能走的 SF 自己用 GPU 合成完再给 HWC 做最终输出。因此 HWC 表面上看是个硬件接口,实质上承担了"合成策略决策"的角色。
这里暗示了一个排障点:同一个界面,在不同厂商手机上合成路径可能完全不同。某台机器上卡,另一台不卡,常见原因就是 HWC 的 Overlay 能力和策略不一样。遇到这类问题,别一头扎进 App 代码里反复优化,先用 dumpsys SurfaceFlinger 确认现在到底是 GPU 合成还是硬件合成。
5.2 像素真正上屏的最后一里路
当最终帧被 HWC 拿走之后,像素路径是这样的:显示控制器从内存里读出帧数据,经过颜色校正、亮度调节等图像处理后,通过物理接口——手机屏幕一般走 MIPI DSI,外接显示器走 HDMI/DP——把数据送到屏幕面板的驱动芯片。随后面板开始一行一行地刷新像素,也就是我们常说的扫描输出。
这里有个冷知识:屏幕刷新和 App 画帧是并行发生的。屏幕不断在刷新,App 不断在 Queue 新帧,只是 Vsync 机制保证新帧只会在屏幕处于 Vblank(两次扫描之间的切帧时机)时才被替换上去,避免出现撕裂。LCD 有背光层,OLED 自发光,但无论如何,帧数据最后都要通过 Display Controller 这条物理链路,很多"硬件卡死但 App 正常"的问题就出在这个环节。
5.3 高刷新率下,开发者的帧预算变紧了
最后强调一个实际影响:刷新率决定了所有环节的帧预算上限。这是很多人忽视的。
| 屏幕刷新率 | Vsync 间隔 | 一帧总预算 |
|---|---|---|
| 60Hz | 16.67ms | 16.67ms |
| 90Hz | 11.11ms | 11.11ms |
| 120Hz | 8.33ms | 8.33ms |
如果你的 App 目标设备里有 120Hz 的手机,那刚才说的 UI 绘制、RenderThread、GPU 渲染、SF 合成、HWC 输出,全都要在这 8.3ms 内完成。不少应用在 60Hz 上流畅,一到高刷机上反而暴露出 UI 线程的短板——不是高刷机变快了,而是你原本 15ms 的 UI 耗时已经装不进 8.3ms 的盒子里,掉帧反而更明显。
顺带提醒:屏幕刷新率和触控采样率是两个概念,触控采样率决定的是触摸事件的采集频率,它天然可以高于刷新率。两个数字对不上时,用户体验会出现"手已经动了、画面没跟上"的错位,排查问题时要区分开。
6. 顺着链路排查卡顿:一帧掉帧到底死在哪一站
6.1 把一帧的总耗时切成四段
链路都清楚了,排查卡顿就变成一场"分段测速"。我一般把一帧的总耗时切分成四个窗口:
| 阶段 | 怎么看 | 典型症状 |
|---|---|---|
| UI 线程耗时 | FrameMetrics 里的 UI Duration | 布局重、measure 慢、主线程被 IO/锁阻塞 |
| 渲染耗时 | FrameMetrics 的 Render Duration、GPU 耗时 | 绘制指令多、Bitmap 过大、着色器太重 |
| 合成耗时 | Systrace 里 SF 的合成块 | Layer 过多、Surface 频繁变化、Transaction 压力 |
| 上屏节奏 | dumpsys SurfaceFlinger --latency | 显示输出延迟、掉 Vsync |
应用侧最常用的实测命令是 gfxinfo:
adb shell dumpsys gfxinfo <packageName> framestats它会以帧为单位输出一长串以 PROFILE 开头的耗时字段——PROFILE_TOTAL 是整帧在 App 侧的总耗时,PROFILE_DRAW 是 UI 线程的 draw 耗时,PROFILE_PROCESS 是渲染线程向 GPU 提交的耗时,PROFILE_EXECUTE 是 GPU 执行耗时。看到这些字段,App 侧的问题基本能定位到段级。
6.2 一个真实例子:看起来像渲染问题,最后卡在 measure
阶段间如何协作?我讲一个真实案例。
当时一个列表页滑动掉帧,我先看 gfxinfo,发现 PROFILE_TOTAL 很高,应用侧总耗时接近 30ms,但 PROFILE_DRAW 和 PROFILE_PROCESS 都不算高。按经验判断,问题应该不在 draw 阶段。再用 Perfetto 看 UI 线程的 trace,卡点明确落在 View.measure 上。点开详细栈,发现罪魁祸首是列表 Item 里一个自定义 View,它在 onMeasure 里用了极其复杂的 MeasureSpec 拆分逻辑,每个 Item 都要重复算两轮。修复方案是重写测量逻辑、把一段可以预计算的尺寸缓存下来,改完 PROFILE_TOTAL 直接掉到 12ms。
这个案例的启发是:如果只凭"列表卡=渲染卡"的直觉,你很可能去优化 onDraw,而真正的瓶颈在 measure。链路图的价值就在于逼你把"UI 线程耗时"和"RenderThread/GPU 耗时"分开看,不要用局部猜测代替分段测量。
6.3 现场排查的常见顺序
最后整理一下我每次排查的实际操作顺序,供你直接抄:
- 先抓全局节奏:开 Systrace/Perfetto,全程录一次滑动或打开页面的过程;
- 判断哪一段超预算:对照前面表格,看卡顿发生时的帧时间段里,App UI 线程、RenderThread、SF、HWC 各自占了多少;
- 顺藤摸瓜找根因:如果 UI 线程长,进 trace 看是不是测量、布局、绘制指令生成的问题;如果 SF 长,看 Layer 数量和合成方式;如果两边都不长但总帧延迟高,检查 BufferQueue 和 Fence;
- 用 dumpsys gfxinfo 和 dumpsys SurfaceFlinger 交叉验证;
- 修复后再抓一次 trace,确认时间归位到预算内。
我个人最大的体会是:显示链路的坑从来不缺,缺的是一张把问题分段的天空视角。很多线上疑难掉帧,最后都能归结到链路某个节点的预算超标,只要你愿意把每帧拆开,而不是笼统说一句"画面有点卡"。
这一篇就把整条链路走了一遍。后续再写第二篇时,我打算专门挑一两个节点往深挖,比如 BufferQueue 在不同版本里的缓冲策略差异,或者 RenderThread 与 GPU 指令提交的细节。先把这张总图记牢,后面每一篇你都会有一个清晰的坐标系去落位。