第4章 渲染管线与 Choreographer: VSync、垂直同步、绘制流程
这一章咱们聊聊 Android 渲染的核心——Choreographer。说白了,它就是 UI 流畅度的调度中枢。我在做 SystemUI 卡顿治理时,发现很多掉帧问题最终都会追到 Choreographer 的调度环节。不理解它,优化无从下手。
4.1 VSync 与垂直同步
先问一个问题:为什么手机屏幕刷新率是 60Hz、90Hz、120Hz?硬件层面,屏幕以固定频率刷新内容——这叫垂直同步(VSync)。每次刷新前,GPU 必须准备好一帧数据。如果 GPU 还没画完,上一帧就会被重复显示,这就是所谓的“掉帧”(Jank)。
| 术语 | 含义 | 你需要注意 |
|---|---|---|
| VSync | 硬件发出的脉冲信号 | 触发每帧开始渲染 |
| 垂直同步 | 匹配 GPU 与屏幕刷新 | 不开启可能画面撕裂 |
| 掉帧 | 某一帧耗时超过 16.67ms(60Hz) | 用户直接感知卡顿 |
我早期优化锁屏界面时,遇到过一个问题:明明 CPU 和 GPU 都不忙,但帧率就是上不去。后来发现是 Choreographer 没有准时收到 VSync 信号——驱动层有延迟。嗯,这里要注意:VSync 的稳定性直接影响 Choreographer 的调度。
4.2 Choreographer 的工作原理
Choreographer 可以理解成一个“帧调度工厂”。它内部维护了一个回调队列,每一帧会依次执行:
- CALLBACK_INPUT —— 处理触摸事件
- CALLBACK_ANIMATION —— 执行属性动画
- CALLBACK_TRAVERSAL —— 执行 View 的 measure/layout/draw
我在阅读源码时,看到这样一个关键方法:
// Choreographer.java (简写)
void doFrame(long frameTimeNanos, int frame) {
// 1. 先检查是否错过 VSync
final long intendedFrameTimeNanos = vsyncData[frame].frameTimeNanos;
if (frameTimeNanos - intendedFrameTimeNanos > SKIPPED_FRAME_WARNING_LIMIT) {
// 日志输出:慢帧
}
// 2. 依次执行三类回调
doCallbacks(Choreographer.CALLBACK_INPUT, frameTimeNanos);
doCallbacks(Choreographer.CALLBACK_ANIMATION, frameTimeNanos);
doCallbacks(Choreographer.CALLBACK_TRAVERSAL, frameTimeNanos);
}
你想想看,如果输入处理耗时太久,动画和绘制就得排队。SystemUI 的导航栏动画经常会跟其他事件竞争,这就是卡顿的源头之一。
核心指标:单个 doFrame 执行时间应控制在 12ms 以内(留 4ms 给硬件合成)。超过 16ms 必然掉帧。
4.3 绘制流程三兄弟:Measure、Layout、Draw
View 的绘制流程是逐层递归的。每个 DecorView 开始,依次调用:
- measure(int widthMeasureSpec, int heightMeasureSpec) —— 测量尺寸
- layout(int l, int t, int r, int b) —— 确定位置
- draw(Canvas canvas) —— 绘制内容
我曾经在 SystemUI 的状态栏布局里发现一个坑:某个自定义 View 的 measure 方法里执行了文件 IO 操作——每次测量都要读一次 SharedPreferences。结果单次 measure 耗时 30ms,整个帧直接崩了。避坑指南:measure/layout/draw 里不能做 IO、网络、锁等待等操作。
我的调试习惯:在关键 View 的 onMeasure 开头和结尾加 Trace.beginSection/endSection,然后用 systrace 观察单个 View 的耗时。如果发现某一阶段超过 2ms,就要重点审查布局嵌套或冗余计算。
4.4 Choreographer 与 SurfaceFlinger 的协作
当 Choreographer 完成了所有回调,它会通过 Binder 通知 SurfaceFlinger 当前帧已经准备好。SurfaceFlinger 收到后,会进行图层合成,然后交给硬件显示。整个链路的时序必须精确匹配 VSync。
我整理了一张完整的渲染流水线示意图,帮助你建立整体视角:
从图上可以看到:VSync 信号触发 Choreographer 开始一次 doFrame,然后依次处理输入、动画、遍历;遍历阶段又包含 Measure、Layout、Draw 三个子阶段。最终绘制完成的 Buffer 被交换到 SurfaceFlinger,由它进行图层合成并送显。
曾经踩过的坑:我在 SystemUI 的亮屏动画中,发现 Choreographer 的 CALLBACK_ANIMATION 阶段回调了一个重量级动画,单次动画帧计算耗时 8ms。紧接着 CALLBACK_TRAVERSAL 又因为布局重新测量耗时 12ms,加起来超过 16ms。解决方案:将动画放到单独线程计算,主线程只做取值。
4.5 如何定位 Choreographer 的卡顿点
你知道最直接的定位方式是什么吗?看 Choreographer.doFrame 的 Trace 信息。在 Systrace 中搜索 Choreographer#doFrame,可以看到每一帧的耗时分布。
我习惯在自定义 Application 中开关一个监控:
Choreographer.getInstance().postFrameCallback(new Choreographer.FrameCallback() {
long lastFrameTime = 0;
@Override
public void doFrame(long frameTimeNanos) {
if (lastFrameTime != 0) {
long diff = (frameTimeNanos - lastFrameTime) / 1000000; // ms
if (diff > 20) {
// 记录卡顿堆栈
Log.w("Jank", "Frame took " + diff + "ms");
// 可以 dump 主线程栈
new Handler(Looper.getMainLooper()).post(() -> {
// 打印当前调用栈
});
}
}
lastFrameTime = frameTimeNanos;
Choreographer.getInstance().postFrameCallback(this);
}
});
注意:这个 callback 会持续运行,记得在不需要时移除。我在生产环境中通过定时采样来降低性能开销。
4.6 关键优化方向总结
| 优化点 | 具体手段 | 预期收益 |
|---|---|---|
| 减少输入处理耗时 | 触摸事件优先处理,避免在主线程做复杂计算 | 降低 CALLBACK_INPUT 延时 |
| 轻量化动画 | 使用硬件加速、属性动画代替 View 动画 | CALLBACK_ANIMATION 从 8ms 降至 1ms |
| 避免布局嵌套过深 | 使用 ConstraintLayout、减少层级 | Measure/Layout 耗时减少 50% |
| 避免过度绘制 | 使用 GPU 分析工具去除冗余绘制 | Draw 阶段耗时降低 |
| 错开图层合成 | 减少透明视图、避免频繁刷新 | SurfaceFlinger 合成压力降低 |
最后我想说,渲染管线的每个环节都可能成为瓶颈。我的经验是:先通过 Systrace 定位到底是哪一段超过了 16ms 的红线,然后对症下药。不要盲目优化——比如看到卡顿就去减少布局层级,结果发现其实是动画线程导致的,那就白费功夫了。
小技巧:在 adb shell 中执行 setprop debug.choreographer.skipwarning 1 可以关闭 Choreographer 的慢帧警告日志,方便你只关注自己打的 Trace。