3. Choreographer 工作流程:输入事件、动画、绘制与 FrameCallback

说实话,刚开始接触 Android 渲染的时候,我以为 Choreographer 就是个“定时器”,负责每隔 16ms 喊一声“该画了”。后来我翻源码才发现,这家伙的工作量远比我想象的复杂——它要协调输入事件、动画、绘制,还得给外部提供一个回调钩子。今天我就带你走一遍它的核心工作流。

3.1 整个流程长什么样?

Choreographer 的“心跳”由 VSync 信号驱动。每次 VSync 到来,它会依次做三件事:处理输入事件 → 执行动画回调 → 发起绘制。另外还提供一个 FrameCallback 机制,让你能在下一帧开始时拿到时间戳。下面这张图可以帮你快速建立全局印象。

Choreographer 一次 VSync 处理流程 VSync 信号到达 1. 处理输入事件 (InputConsumer) 2. 执行动画回调 (Choreographer.doFrame → Animation) 3. 遍历 & 绘制(ViewRootImpl) (performTraversals) FrameCallback (通过 postFrameCallback 注册) 回调时机:VSync 到来后 输入事件处理 完成之后 掉帧检测关键点 (expectedVSyncTime - actualTime) / 16.67ms 实际耗时过长→丢帧
提示:图中蓝色箭头表示串行执行顺序,虚线表示 FrameCallback 的插入位置。虽然 API 上 postFrameCallback 在输入事件处理阶段之后、动画之前被回调,但不同的 Choreographer 版本略有差异。我个人建议你在做帧率监控时,把回调时机放在输入事件之后、动画执行之前来做时间戳记录。

3.2 第一步:输入事件处理(Input Event)

VSync 到达后,Choreographer 会首先调用 doFrame()。在这个方法里,它先处理的是 输入事件队列。为什么输入事件要放在最前面?你想想看,用户的触摸、按键等操作如果不先分发,后面的动画和绘制就是过时的数据。

我记得有一次在排查一个列表滑动掉帧问题,发现掉帧竟然不是因为绘制耗时,而是 输入事件处理阶段耗时太长。后来定位到是某个自定义 ViewGroup 在 onInterceptTouchEvent 里做了大量计算。这个阶段处理的,是 InputConsumer 从 InputTransport 读出来的事件列表。

阶段核心方法典型耗时原因
输入事件处理InputConsumer.consume()事件队列堆积、Touch 事件分发中的同步锁
动画回调AnimationUtils.lockClock() → Choreographer.doFrame属性动画计算、动画监听器回调
绘制ViewRootImpl.performTraversals()measure/layout/draw 耗时、硬件加速缓冲提交
避坑指南:我曾经遇到一个诡异掉帧:输入事件处理阶段耗时增加了 8ms,但实际输入队列里只有一个事件。后来发现是因为 InputDispatcher 给多个窗口分发时,Choreographer 所在窗口被排到后面。这种情况只能通过减少前后台忙碌窗口数量或优化输入事件的主线程回调来解决。

3.3 第二步:动画回调(Animation Callback)

输入事件处理完后,Choreographer 会取出 mAnimationCallbacks 队列,逐个执行。注意,这里的动画回调特指 通过 Choreographer 注册的帧动画,比如 ValueAnimator、ViewPropertyAnimator 等。它们依赖 VSync 来驱动帧更新。

// Choreographer 内部大致逻辑(简化)
void doFrame(long frameTimeNanos, int frame) {
    // 1. 处理输入事件
    doCallbacks(InputEventCallback, frameTimeNanos);
    // 2. 处理动画回调
    doCallbacks(AnimationCallback, frameTimeNanos);
    // 3. 处理绘制回调
    doCallbacks(TraversalCallback, frameTimeNanos);
    // 4. 处理 FrameCallback
    doCallbacks(FrameCallback, frameTimeNanos);
}

这里有个很容易被忽略的点:如果你在动画回调里 直接调用了 postInvalidate(),Choreographer 会把绘制请求排到下一帧,而不是立即执行。所以别指望动画回调里能触发同步绘制。

3.4 第三步:绘制与显示(Traversal & Draw)

动画回调执行完,Choreographer 处理绘制回调。这个阶段会触发 ViewRootImpl.performTraversals()。说白了这个方法就是老生常谈的 measure → layout → draw 三部曲。但要注意,一次 doFrame 只触发一次遍历,如果你在同一个 VSync 周期里多次调用 invalidate(),Choreographer 会合并请求,只执行一次。

绘制阶段是掉帧的重灾区。我自己的经验是:在 draw() 方法里避免创建对象、避免频繁的 Bitmap 解码、避免过度绘制(overdraw)。另外硬件加速虽然快,但 HardwareRenderer 提交到 GPU 的 buffer 如果太大,也会被卡住。

注意:在某些帧中,performTraversals() 可能会因为 布局大小没变、没有脏区域 而跳过绘制(直接返回)。但 Choreographer 仍然认为该帧被“处理”了。所以监控掉帧时,不能只看 doFrame 是否被调用,还要结合 FrameMetrics 中的 drawDuration 字段判断真正的绘制耗时。

3.5 FrameCallback 机制:你的帧时间戳“窃听器”

最后一个要聊的是 FrameCallback。这是一个非常实用的 API,你可以通过 Choreographer.getInstance().postFrameCallback(FrameCallback callback) 注册一个回调。回调的 doFrame(long frameTimeNanos) 方法会在 下一帧 VSync 到来后、绘制开始前 被调用。典型用法:

Choreographer.getInstance().postFrameCallback(new Choreographer.FrameCallback() {
    @Override
    public void doFrame(long frameTimeNanos) {
        long nowNanos = System.nanoTime();
        long frameIntervalMs = (nowNanos - lastFrameNanos) / 1_000_000;
        if (frameIntervalMs > 16.67f) {
            int droppedFrames = (int)(frameIntervalMs / 16.67f);
            Log.w("FrameMonitor", "丢帧数: " + droppedFrames);
        }
        // 继续监听下一帧
        if (isMonitoring) {
            Choreographer.getInstance().postFrameCallback(this);
        }
    }
});

这个小工具是我做帧率监控的必备利器。我在项目中经常用它来统计连续 100 帧的平均耗时,发现掉帧问题就弹 Toast 提醒自己。不过要注意,frameTimeNanos 是 Choreographer 预计的 VSync 到达时间,而 System.nanoTime() 才是实际回调时间——两者之差就是这一帧的延迟。

实践建议:如果你要统计精确的掉帧数,我建议结合 FrameMetrics API(Android 7.0+)。它会按阶段拆解耗时,包括 Input、Animation、Layout、Draw、Submit 等。这样你就能精准定位究竟是哪个阶段拖慢了帧率。

3.6 小结:一个“会干活”的三段式流程

Choreographer 的工作流程,说白了就是:收到 VSync 信号 → 先处理用户输入(避免过时)→ 再驱动动画(让动画平滑)→ 最后执行绘制(生成新帧)。FrameCallback 就像是挂在流程上的一个钩子,让你在每一帧开始时获得控制权。

只要理解了这个流程,后面做掉帧分析时,你就知道该去检查哪个环节。比如滑动掉帧,优先查输入事件处理;动画卡顿,优先查动画回调里的计算;画面撕裂或绘制延迟,就从 performTraversals 入手。