VSync信号与Choreographer的关系
说到Android的流畅度,绕不开一对黄金搭档——VSync信号和Choreographer。说白了,整个画面渲染的“心跳”就是由它们俩控制的。我刚开始接触性能优化时,以为帧率波动只是代码写得太烂,后来才明白,不懂VSync和Choreographer,你连问题出在哪都找不准。
一句话总结:VSync是硬件的“发令枪”,Choreographer是软件的“调度员”。发令枪一响,调度员就开始安排干活。
4.1 VSync信号基础
VSync,全称Vertical Synchronization(垂直同步)。硬件层面,屏幕刷新率是固定的——比如60Hz的屏幕,每16.6ms刷新一次。VSync信号就是屏幕在每次刷新前发出的一个脉冲,告诉系统:“我要开始画下一帧了,你准备好了吗?”
如果没有VSync,系统可能在屏幕刷新到一半时提交新数据,画面就会出现撕裂(tearing)——你想想看,一半是上一帧,一半是下一帧,那画面简直不能看。
帧率与帧时间的关系:
| 屏幕刷新率 | 帧时间 | VSync周期 |
|---|---|---|
| 60 Hz | 16.6 ms | 16.6 ms |
| 90 Hz | 11.1 ms | 11.1 ms |
| 120 Hz | 8.3 ms | 8.3 ms |
我做过一个项目,设备是90Hz屏,但掉帧频发。查了半天才发现——业务方在锁屏亮起的瞬间,往主线程丢了一个巨耗时的任务,单次帧时间飙到40ms。那是90Hz的设备啊,每一帧只有11.1ms的时间,40ms相当于掉了3帧多。嗯,这里要注意:帧时间超过VSync周期,就是掉帧的开始。
4.2 Choreographer调度机制
Choreographer,直译过来是“舞蹈编排师”。我个人觉得这个名字起得特别形象——它负责把输入事件、动画、布局、绘制这些动作,按VSync的节奏编排好,让它们有条不紊地执行。
核心工作流程:
- 监听VSync信号
- 回调
Choreographer.FrameCallback - 依次处理输入、动画、遍历、绘制
- 把结果交给SurfaceFlinger合成
每个FrameCallback里,Choreographer会给你一个frameTimeNanos参数——这是VSync信号发生的时间戳,单位是纳秒。我个人习惯拿它来做精确的帧率统计,比System.currentTimeMillis()准得多。
小技巧:用Choreographer.getInstance().postFrameCallback()来注册回调,可以精确跟踪每一帧的开始时间。
为什么会每次回调都注册?
因为Choreographer默认只回调一次。如果你需要持续监听,必须在回调里再次postFrameCallback。这一开始也坑过我——我写了个监控工具,结果只输出了一帧的数据,排查了半天才发现是注册方式不对。
4.3 VSync与Choreographer的配合流程
我来画一张图,帮你看清这整条链路:
关键点:如果一个周期的所有任务在下一个VSync到来前没完成,画面就会延迟一帧。这就是我们常说的“掉帧”。
4.4 掉帧的根本原因
掉帧,说白了就是帧时间 > VSync周期。具体场景有以下几种:
- 主线程阻塞:你在主线程做了太多事——比如解析复杂JSON、做磁盘IO、加载大图。Choreographer在等主线程空闲,但主线程一直忙,于是VSync过去了,下一帧才开始处理。
- 过度绘制:布局嵌套太深,draw方法执行时间远超预期。我曾经接手过一个项目,一个ListItem里有12层嵌套,每次绘制耗时20ms——在60Hz的设备上直接超了3ms。去掉两层后,帧率稳定了。
- GC抖动:频繁创建对象触发GC,导致主线程暂停。这个在列表快速滑动时特别明显。
注意:掉帧不等于帧率低。有时候帧率看似在60fps,但掉帧次数(jank count)很高。因为掉帧的定义是“连续两帧之间时间间隔超过16.6ms”。我曾经在项目里发现,有些帧跑在15ms,有些跑在18ms,虽然平均是16.5ms,但掉帧次数达到了20次/秒——用户感知是“微卡”。
4.5 通过Choreographer监控掉帧
监控掉帧,我推荐用Choreographer.FrameCallback做自定义统计。直接上代码:
public class JankMonitor {
private long lastFrameTimeNanos = 0;
private long vsyncPeriod;
public void start() {
vsyncPeriod = getVsyncPeriod();
Choreographer.getInstance().postFrameCallback(new Choreographer.FrameCallback() {
@Override
public void doFrame(long frameTimeNanos) {
if (lastFrameTimeNanos != 0) {
long frameInterval = frameTimeNanos - lastFrameTimeNanos;
long frameIntervalMs = frameInterval / 1_000_000;
if (frameIntervalMs > vsyncPeriod) {
int droppedFrames = (int) (frameIntervalMs / vsyncPeriod);
Log.e("JankMonitor", "掉帧 " + droppedFrames + " 帧,耗时 " + frameIntervalMs + "ms");
// 上报到APM平台
}
}
lastFrameTimeNanos = frameTimeNanos;
Choreographer.getInstance().postFrameCallback(this);
}
});
}
private long getVsyncPeriod() {
return (long) (1000f /
DisplayManager.getInstance().getDisplay(0).getRefreshRate());
}
}
代码做了什么?
- 获取两次
frameTimeNanos的差值 - 和VSync周期比较
- 一旦超时,计算掉帧数并上报
我的经验:这个frameIntervalMs > vsyncPeriod的判断有时候会误报——比如页面第一次打开时,因为没有上一帧数据,需要特殊处理。我一般会在第一次回调时把lastFrameTimeNanos赋值,不做掉帧检测。
4.6 VSync与Choreographer的调优策略
明白了它们的配合关系,调优就有方向了:
- 减少主线程工作量:把IO、解析、编解码都丢到子线程。Choreographer只管UI,别让它等太久。
- 避免在FrameCallback里做重操作:我个人见过有人直接在回调里做位图缩放,帧率直接掉到30fps。回调里只做轻量级统计,重活交给Handler或子线程。
- 利用VSync的节奏做预加载:比如列表滑动时,提前加载下一帧需要的数据。我用过
Choreographer.postFrameCallback来做隔帧加载,效果不错。
我曾经踩过一个坑:接了一个高刷新率屏项目(120Hz),但Choreographer回调里用了Thread.sleep(5)来做节流。结果就是——每帧多睡了5ms,120Hz的周期是8.3ms,直接掉到60fps以下。后来改成用帧时间戳做精度控制,才把问题解决。结论:绝不要在UI线程里加sleep。
4.7 用Systrace验证VSync与Choreographer
Systrace是最直观的验证工具。抓取trace时关注以下几点:
- VSync信号线:如果VSync之间间距明显大于16.6ms,说明有掉帧。
- Choreographer#doFrame:这个阶段耗时如果超过VSync周期,就是主线程瓶颈。
- SurfaceFlinger#composeSurfaces:这个阶段耗时过长,可能是合成层数太多或GPU过载。
怎么看?
# 抓取15秒的trace
python systrace.py -t 15 -o trace.html gfx view input sched
打开trace.html,在搜索框输入“jank”或“Choreographer”,就能看到掉帧标记。我个人习惯按帧时间排序,先挑出耗时最长的几帧,然后点进去看具体哪个方法占用了主线程。
小结:VSync是硬件的节拍,Choreographer是软件的指挥。理解了这个关系,你就能从系统的角度去分析掉帧,而不仅仅是在代码里瞎猜。