第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 可以理解成一个“帧调度工厂”。它内部维护了一个回调队列,每一帧会依次执行:

  1. CALLBACK_INPUT —— 处理触摸事件
  2. CALLBACK_ANIMATION —— 执行属性动画
  3. 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。

我整理了一张完整的渲染流水线示意图,帮助你建立整体视角:

Android 渲染管线与 Choreographer 协作流程 VSync 信号源 Choreographer 调度器 CALLBACK_INPUT CALLBACK_ANIMATION CALLBACK_TRAVERSAL Measure Layout Draw Buffer 交换 + 合成 SurfaceFlinger 帧准备好通知

从图上可以看到: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。