4、Vsync信号与调度:硬件Vsync与软件Vsync、Choreographer的调度周期、FrameDisplayEventReceiver
Vsync 这个词,做 Android 性能优化的人几乎天天挂嘴边。但说实话,很多人对它的理解就停留在「屏幕每 16ms 刷新一次」这个层面。实际调优的时候,你会发现事情没那么简单。
我记得早年排查一个游戏掉帧问题,明明逻辑帧跑满了 60fps,画面却还是一抖一抖的。后来追到 Vsyntrace 里一看——Vsync 信号根本没对齐。从那天起,我就把 Vsync 的整个链路彻底啃了一遍。
这一章,我们就从硬件到软件,把 Vsync 的来龙去脉拆清楚。
4.1 硬件Vsync:屏幕的原始心跳
硬件 Vsync(HW Vsync)由显示控制器直接产生。屏幕从上到下逐行扫描,扫完一帧后回到顶部,这个瞬间就会触发一个硬件中断——这就是 Vsync 脉冲。
- 频率固定:60Hz、90Hz、120Hz,取决于屏幕面板
- 精度极高:纳秒级抖动,几乎不受软件负载影响
- 功耗敏感:每次中断都会唤醒 CPU,频繁唤醒会耗电
在 Android 早期版本(4.1 之前),应用层直接依赖 HW Vsync 中断。但这么做有个问题——一旦系统负载高了,中断响应延迟就会拖垮帧率。
关键认知:硬件 Vsync 是的「时钟源」,但它太硬了,软件层需要做一层缓冲和分发。这就是「软件 Vsync」出现的理由。
4.2 软件Vsync:DispSync 与双路信号
从 Android 4.1(Jelly Bean)开始,系统引入了 DispSync 模块。它不再直接使用 HW Vsync 中断,而是通过锁相环算法,根据 HW Vsync 的节奏「合成」出一路软件 Vsync 信号。
4.2.1 为什么需要软件Vsync?
- 去抖动:硬件中断偶尔会有毛刺,DispSync 可以平滑掉
- 相位调整:可以给 App 和 SurfaceFlinger 分配不同的相位偏移,让两者错开执行
- 省电:减少不必要的 CPU 唤醒,只在真正需要时派发信号
4.2.2 VSYNC-app 与 VSYNC-sf
DispSync 会生成两路信号:
| 信号名称 | 目标 | 作用 | 相位偏移 |
|---|---|---|---|
| VSYNC-app | App 主线程 | 触发 Choreographer 开始处理输入、动画、绘制 | 通常为 0 |
| VSYNC-sf | SurfaceFlinger | 触发合成与刷新显示 | 通常偏移 2~3ms |
这两路信号错开,是为了保证 App 正在绘制的时候,SurfaceFlinger 不会抢屏幕缓冲区。我早年调一个短视频 App 的卡顿问题,就是发现 VSYNC-sf 的相位偏移设得太小,导致 App 还没画完 SF 就开始合成了——结果每一帧都撕裂加掉帧。
4.3 Choreographer的调度周期
Choreographer 的中文意思是「编舞者」。它的工作就是:等 Vsync 信号来了,按顺序执行三件事——
- 输入处理(Input)
- 动画更新(Animation)
- 视图绘制(Traversal)
这三步合起来就是一次 doFrame 调用。你想想看,如果 doFrame 耗时超过 16ms,下一次 Vsync 来的时候上一帧还没处理完,那就会跳过一帧——这就是掉帧。
个人习惯:我一般会在 Choreographer 的 FrameCallback 里打时间戳,用来计算真实的帧间隔。代码很简单,但非常实用。
// 注册 FrameCallback 监控帧率
Choreographer.getInstance().postFrameCallback(new Choreographer.FrameCallback() {
private long lastFrameTime = System.nanoTime();
@Override
public void doFrame(long frameTimeNanos) {
long now = System.nanoTime();
long interval = now - lastFrameTime;
lastFrameTime = now;
// 16.67ms = 16666666ns
if (interval > 16666666L) {
int skipped = (int) (interval / 16666666L) - 1;
Log.w("FrameMonitor", "掉帧 " + skipped + " 帧, 间隔 " +
String.format("%.2f", interval / 1e6f) + " ms");
}
// 持续注册,形成循环
Choreographer.getInstance().postFrameCallback(this);
}
});
这段代码我直接用在了好几个线上项目的监控 SDK 里。你可能会问:frameTimeNanos 和 System.nanoTime() 有啥区别?frameTimeNanos 是 Choreographer 从 Vsync 事件里拿到的硬件时间戳,比 nanoTime 更精确地反映了屏幕的节奏。我建议你用前者来做掉帧计算。
4.4 FrameDisplayEventReceiver:Vsync 的快递员
Vsync 信号从 DispSync 出来,怎么传到 Choreographer 的?中间隔了一个关键的内部类——FrameDisplayEventReceiver。
它继承自 DisplayEventReceiver,本质是一个 Native 层的 Socket 监听器。简单说:
- Native 层收到 Vsync 事件后,通过 JNI 回调到 Java 层的
onVsync()方法 onVsync()内部会向主线程 Handler 发送一个 Message- Handler 处理 Message 时,调用 Choreographer 的
doFrame()
听起来链路挺长?其实很快。但有一个坑我要特别提醒:
我曾经在线上项目里遇到过一个诡异问题:某些机型上,onVsync 回调里居然有人插入了耗时操作(比如直接写文件)。结果 Vsync 事件被阻塞,Choreographer 迟迟收不到信号,帧率直接腰斩。排查了整整两天才发现。
所以我的建议是:永远不要在 onVsync 或 doFrame 里做任何 IO、锁竞争、大对象分配。 这个方法的使命只有一个——快速分发,然后走人。
4.4.1 核心流程源码示意
以下是我简化后的调用链路伪代码,帮你建立直观印象:
// Native 层收到 Vsync 中断
DisplayEventReceiver::onVsync(timestamp, displayId, frameNumber)
// JNI 调用到 Java
→ FrameDisplayEventReceiver.onVsync(timestamp, ...)
// 向主线程 Handler 发送消息
→ Message msg = Message.obtain(handler, MSG_DO_FRAME, ...)
handler.sendMessageAtTime(msg, timestamp / TimeUnit.NANOSECONDS.toMillis(...))
// Handler 处理消息
→ Choreographer.doFrame(frameTimeNanos, ...)
→ doCallbacks(INPUT, frameTimeNanos)
→ doCallbacks(ANIMATION, frameTimeNanos)
→ doCallbacks(TRAVERSAL, frameTimeNanos)
4.5 避坑指南与实战要点
结合我自己的排坑经验,给你几个干活用得上的原则:
- 区分 HW Vsync 和 SW Vsync 的抖动:如果掉帧是随机、不可复现的,多半是软件层抖动;如果是规律性掉帧,检查 VSYNC-sf 相位偏移
- 不要直接用 HW Vsync 做帧率统计:我从 SysTrace 里拿过数据,HW Vsync 偶尔会丢脉冲,用软件 Vsync 的时间戳更可靠
- Choreographer 的 doFrame 超时 = 掉帧:超过 16ms 还没返回,下一帧就被跳过了,这是最直接的判断标准
- FrameDisplayEventReceiver 的回调优先级很高:但它只在主线程执行,所以不要做任何阻塞操作
嗯,说到这我想起一个经典场景。有一次我负责优化一款资讯类 App 的列表滑动帧率。从 Trace 上看,doFrame 每次都在 12~15ms 之间,没超 16ms,但用户就是觉得「不跟手」。后来我用 FrameCallback 算了一下实际的帧间隔——发现每 3~4 帧就会有一个 33ms 的间隔,等于掉了一帧。原因就是 VSYNC-app 的相位偏移和页面 measure 耗时产生了共振。最后通过调整布局层级和减少 measure 次数才解决。
所以你看,光看平均帧率是不够的。你要能看见「每一帧」的间隔,才能找到真正的瓶颈。
我建议:在你项目里试着跑一下上面那段 FrameCallback 代码,把掉帧日志打印出来。哪怕只看 5 分钟,你都会对 App 的帧率表现有全新的认识。