Android 图形显示系统 · 第 二 篇

Choreographer:谁在召唤 Vsync

2026-09-2917,454 字28 张配图

本文由@溯光 出品,转载请注明出处 站点链接:技术博客页 | 公众号:溯光的图形笔记

一段 Vsync 信号与撕裂、延迟之间的取舍关系示意
题图Vsync 要解决的问题,本质是一个取舍:等得越齐,撕裂越少,但延迟越高@ Wikimedia Commons · Mercury(CC0)

核心问题:上一篇我们讲完了整条显示链路的六个站点。这一篇只盯住最靠前的那一站——屏幕静止不动的时候,是谁还在按每秒六十次的节拍叫应用起来干活,而且为什么它不肯停?

这个问题在中文社区里被讨论得很多,但讨论到一半就分岔了:一类文章站在应用侧,结论是"你的动画没停",代表是 《Android 中的"后台无效动画"行为分析》——它用 Systrace 抓到"只有动画回调在每帧执行"就收尾了;另一类站在 SurfaceFlinger 侧,讲 EventThread 什么时候回 Idle,代表是掘金《理解 VSync》的上篇与下篇。这两类内容之间那条线,很少有人真正接上。 本篇要做的事就是把它接上:把"应用忘了停订阅"与"系统侧单帧唤醒后重新休眠"连成一条可观测的链,并且给出三条你自己就能跑的命令来验证它。

承接上一篇。ep01 讲的是相位——HW_VSYNC、VSYNC、SF_VSYNC 三个时刻分别在哪儿,偏移量由什么算出来。那是在讲"节拍表怎么定"。本篇讲的是唤醒——这条节拍打在谁身上、以什么优先级分发、以及最关键的:什么时候它应该停下来。两篇共用同一条链路,但量的东西完全不同,读完 ep01 再读本篇,才是一条完整的链。

本篇也不涉及:渲染算法与画面技术(《实时渲染管线探析》《实时光线追踪与混合渲染》已写)、移动端功耗与带宽取舍(《移动端图形与功耗》已写)、渲染线程内部怎么执行绘制命令(ep03 展开)、BufferQueue 怎么传缓冲(ep04 展开)、抓帧工具自身的原理(《图形性能剖析工具链》已写)、厂商私有的 EventThread 改造与调度器替换。这么切是因为它们的约束条件与"谁被叫醒"完全不同:算法关心画面,功耗关心总量,本专题关心的是时刻与状态。完整边界见 §0.3。

系列文章写作风格说明(沿用上一篇,不再重复解释)

  • 术语双语:首次出现的核心术语写成「中文(English)」,后文可只用英文;
  • 三层分开说:官方文档怎么写、AOSP 源码怎么做、本机跑出来是什么——这三层分开陈述,不混成一句;
  • 读数标口径:每一个数字都标来源与条件(机型、Android 版本、命令、采样窗口);
  • 实测优先:能用命令量出来的就不引用别人的结论;本专题的图以本机抓帧与自绘链路图为主;
  • 模拟器与真机分开写:模拟器拿不到的东西(帧级时间线切片、自适应刷新率)如实标"需真机",绝不写成 Android 通例。

系列主要内容总览

  1. 【Android 图形显示系统】(一) 总览:从 App 到屏幕的图形管线
  2. 【Android 图形显示系统】(二) Choreographer:谁在召唤 Vsync(本篇)
  3. 【Android 图形显示系统】(三) RenderThread 与帧提交
  4. 【Android 图形显示系统】(四) BufferQueue:图形缓冲区的邮局
  5. 【Android 图形显示系统】(五) Vulkan on Android:低开销渲染入口
  6. 【Android 图形显示系统】(六) SurfaceFlinger:在 Vsync 之后做什么
  7. 【Android 图形显示系统】(七) ASurfaceControl:Android 10 的透明事务
  8. 【Android 图形显示系统】(八) HWC:GPU 合成之外的硬件叠加
  9. 【Android 图形显示系统】(九) HWC2:Vsync 回调和虚拟显示路径
  10. 【Android 图形显示系统】(十) Vsync 偏移:应用、SF、屏幕的三步舞
  11. 【Android 图形显示系统】(十一) 三缓冲、多刷新率与 ARR
  12. 【Android 图形显示系统】(十二) 虚拟显示与屏幕录制
  13. 【Android 图形显示系统】(十三) 剖帧实战:Perfetto FrameTimeline 抓 jank

相关资源链接


这一篇回答什么,不回答什么

OK,开篇先把坐标系挪正。上一篇给的是一张地图,这一篇要给的是一根链条——一条从显示控制器一路甩到应用回调的"叫醒链",以及这条链在无人使用时该有的收尾动作。

0.0 内容总览:本篇知识地图

先把十章各自回答的问题钉在一张表上。它同时也是本篇的目录总览,后面每一章都在展开其中的某一行。如果时间紧,第五章(VSyncRequest 的状态机)与第六章(空闲时应该发生什么)是这篇最不可替代的两章——中文社区对它们的覆盖最薄,而它们恰恰是"静止了还在耗电"这个问题的正解。

章 回答的问题 关键结论
零 这条叫醒链长什么样,本篇边界在哪 链上有四个执行体,每一跳都可用命令观测
一 Choreographer 到底在做什么 它是接收方与调度方,不是 Vsync 的产生方
二 五档回调与它们的顺序 五档不是四档,旧文的枚举值整体错了一位
三 一次 doFrame 里发生的事 掉帧检测的阈值与判据都在这里,日志只是其中一层
四 DisplayEventReceiver 与 SF 怎么握手 订阅是"一次性"的:不重订,就不会有下一帧
五 VSyncRequest 有哪几档,本机出现哪几档 枚举有五个取值;本机静止时一个周期订阅都没有
六 空闲时应该发生什么 源码语义是"无回调则不订阅",但官方文档里没有这句话
七 静态与滑动两态,读数差在哪 同一台机器,帧数据行数 0 → 127,迟到周期数可量化
八 四个最常踩的误用 自驱动循环是唯一一个"看起来完全正常"的 bug
九 三项可以立刻做的事 每条都给出命令与判定阈值

0.1 那条叫醒链:从显示控制器到五档回调

先来看看这条链。这条链的一端是一块屏幕的物理刷新,另一端是应用里一个 Runnable 被调用——中间隔着内核、一个系统进程、一次跨进程传输,和至少两次线程切换。

第一跳,是显示控制器自己产生的。 屏幕不是"被推"出画面的,它是按自己的节拍"拉"画面的:显示控制器(Display Controller)在每个刷新周期的起点产生一次硬件垂直同步信号 HW_VSYNC。这是整条链上唯一一个完全不受软件控制的时刻——软件能做的只有提前量,不能改节拍。

这笔交易的两端,就是题图画的那件事:对齐得越齐,撕裂越少,延迟越高。 下面这条链的全部复杂度,都是在为它付执行成本。

第二跳,SurfaceFlinger 侧把硬件脉冲加工成两路软件脉冲。 官方文档 实现 VSYNC 写得很直白:VSYNC 与 SF_VSYNC 由 DispSync(一个软件 PLL)生成,分别供给 Choreographer 与 SurfaceFlinger。为什么要"加工"?因为硬件脉冲到达的时刻对软件来说太晚了——等收到 HW_VSYNC 再开始画,这一帧必然赶不上这一次刷新。DispSync 用历史统计把唤醒点往前推,推多少由工作时长与就绪时长决定(这一段 ep01 的 §4.2 已展开,本篇不复述)。

第三跳,EventThread 把脉冲发给订阅者。 SurfaceFlinger 里的 EventThread 维护一张连接表,每个连接记录着"这个订阅者现在要什么"——要一次、要周期、还是不要。它按表分发,不需要的人不叫。

第四跳,应用侧收到 onVsync 并进入自己的消息队列。 DisplayEventReceiver 是应用侧那一端,它把脉冲变成一条异步消息投进目标线程的 Looper。注意"异步"这个词:它意味着这个消息可以越过队列里积压的普通消息,插到队首去执行。没有这个特权,一次繁忙的 UI 线程会把 Vsync 拖到下一个周期,帧率直接腰斩。

叫醒链的四跳与每跳的归属
图 2-1叫醒链的四跳与每跳的归属:显示控制器产生 HW_VSYNC → SF 侧 DispSync 分出两路 → EventThread 按连接表分发 → 应用 Looper 的异步消息@ 本系列绘制

到这里,"谁在召唤 Vsync"这个问题可以给出一个阶段性的答案了:没有任何一个应用在"召唤"它。 Vsync 是显示硬件产生的,SurfaceFlinger 只是转发,应用只是订阅。应用真正能决定的,是要不要继续订阅——这一个决定,就是本篇剩下九章要讲的全部内容。

0.2 本篇与上一篇的分工

OK,言归正传。上一篇和这一篇共用同一条链路,但如果量的是同一批数字,那就不是两篇而是重复。所以这里把分工说清。

维度 ep01《总览》 ep02(本篇)
关心的对象 相位:三个时刻分别在哪儿 唤醒:节拍打在谁身上
量什么 时长——偏移多少纳秒、工作时长多长 状态与条数——枚举取了哪个值、有几条连接
主要命令 dumpsys SurfaceFlinger 的 DispSync 段 dumpsys SurfaceFlinger 的连接段 + gfxinfo
典型结论 唤醒点是被历史统计推出来的 静止时没有一条连接处于周期订阅
不碰的东西 谁被唤醒、以什么优先级分发 偏移量怎么算、三个相位的相互位置

一句话概括:ep01 量的是"几点几分",本篇量的是"谁在等这个点钟"。

可观测量分工:时长量纲与状态量纲
图 2-2可观测量分工:相位与偏移量是时长量纲,唤醒对象与订阅状态是状态量纲@ 本系列绘制

这张对照表还有一层用处:它划定了后面几章的范围。ep01 里已经讲透的东西——三个相位的相对位置、偏移量怎么由历史统计推出来——本篇一句都不重复。 本篇要补的是那条链上"谁在被唤醒"的部分。所以读本篇时如果觉得少了什么,先回头翻 ep01 的 §4.2;反过来,读 ep01 时如果觉得"知道偏移量了,但还是不明白为什么界面静止了系统还在跑",那正是本篇要接上的一段。两篇配合起来,才构成"节拍表 + 唤醒对象"的完整答案。

0.3 本文不涉及什么,为什么

好了,先把边界钉住。本文不涉及:渲染算法与画面技术(延迟着色、光追混合等,已在《实时渲染管线探析》与《实时光线追踪与混合渲染》写透);移动端功耗与带宽的取舍(已在《移动端图形与功耗》写透);RenderThread 内部怎么执行绘制命令(ep03 展开);BufferQueue 的缓冲传递与队列深度(ep04 展开);HWC 的叠加层决策(ep08 展开);抓帧与剖帧工具自身的设计原理(《图形性能剖析工具链》写透,本篇只把它当验证手段);厂商对 EventThread、VSyncDispatch 的私有改造(未公开,如实标注)。

为什么不涉及:这七类问题各有专门的约束条件,混进来会让"谁被叫醒"这个很窄的问题重新糊成一团。特别是工具原理那一条——本篇会用 Perfetto 和 dumpsys,但只把它们当作读出数字的仪器,不解释仪器内部怎么实现。中文侧已有几篇把"屏幕刷新机制"讲得很系统的入门全景,比如 腾讯云《屏幕刷新机制 — VSync、Choreographer 全面理解》 与掘金同题版本,本篇不重复它们的覆盖面,只在它们容易滑过去的那几处(枚举取值、订阅语义、两态读数)往下挖。

还有一处边界要提前说明,因为它关系到后面好几章的写法:本篇讲的是 AOSP 通用链路。 厂商可以在 EventThread 之上加自己的调度策略,也可以替换 VSyncDispatch 的实现。这类改动没有公开文档,本篇一律不推测,只在必要处标注"这一段属厂商私有,未公开"。

0.4 本文的读数从哪来

下面我们进入读数这一层。本篇所有真图都是本机实测,环境如下表。它同时也是这条链在软件侧能被观测到的全部入口——五项读数,四条命令,没有一项需要 root,也没有一项需要专用工具。

项 取值 获取方式
Android 版本 / API Android 16 / API 36 getprop ro.build.version.release / ro.build.version.sdk
构建类型 user / release-keys getprop ro.build.type / ro.build.tags
显示模式 单一 1080×2400 @ 60.00 Hz,vrrConfig=N/A adb shell dumpsys SurfaceFlinger --display-id
帧数据(逐帧) 24 列 CSV,静止 0 行 / 滑动 127 行 adb shell dumpsys gfxinfo <pkg> framestats
帧数据(汇总) 122 帧 / janky 53(43.44%)/ Missed Vsync 32 adb shell dumpsys gfxinfo <pkg>
订阅状态 静止 17 条全 None;滑动 16 None + 1 SingleSuppressCallback adb shell dumpsys SurfaceFlinger
线程轨 3,798,303 字节 trace,Choreographer#doFrame ×201 perfetto --txt -c <cfg>(类别 gfx / view / input / sched)
被测应用 com.google.android.apps.nexuslauncher 系统桌面,主线程天然有帧回调
本机观测环境与五项读数的来源面板
图 2-3本机观测环境与五项读数的来源面板:Android 16 / API 36 / user 构建 / 单一 60 Hz / vrrConfig 不可得@ 本机实测(Pixel_7 AVD,Android 16)

两张必须提前说明的表外条件。第一,本机是模拟器(Pixel_7 AVD),渲染走 SwiftShader 软件光栅化,所以帧耗时远高于真机——本篇所有耗时类数字都标"本机 = 模拟器 + 软件渲染",不得当作 Android 通例。第二,本机只有一个显示模式,自适应刷新率与多刷新率一律不可得,涉及这些的结论本篇只给指针,留给 ep11。题图与两张外部对照图来自 Wikimedia Commons,类目枚举与许可核查的检索留痕见 Commons 类目 API。


一、Choreographer 到底在做什么

1.1 官方那句话,逐词拆

先来看看官方那句话的原文。官方 Choreographer 类参考 的第一段是这样的(原文照录):

The choreographer receives timing pulses (such as vertical synchronization) from the display subsystem then schedules work to occur as part of rendering the next display frame.

这句话是"谁在召唤 Vsync"最权威的答案,但它太快了,四个关键词里藏了四个容易被读错的判断。我们一个一个拆。

官方定义的四个关键词到实现的映射
图 2-4官方定义的四个关键词到实现的映射:receives 是接收、schedules 是调度——它一个信号都发不出@ 本系列绘制

receives——它是接收方,不是产生方。 主语是 choreographer,动词是 receives。整条链上产生脉冲的是显示硬件,转发的是 SurfaceFlinger。这一点听起来是废话,但它直接推翻了中文技术圈一个流传很广的说法:"Choreographer 负责发出 Vsync 信号"。它一个信号都发不出。 它能发出的只有"订阅请求"——告诉系统"下一次请叫我"。

timing pulses——是"定时脉冲",不是"信号"或"事件"。 用"脉冲"这个词,强调的是周期性:它不是一个需要被处理完的事件,而是一个节拍点,过了就过了。这个区别在实操里很关键:如果你某一帧没赶上这个节拍点,系统不会补发一次,你只会等到下一个节拍点——这就是掉帧的全部物理含义。

schedules——它是调度方,不是执行方。 它不画任何东西,它按优先级决定"这一拍里,哪些活该按什么顺序干"。

next display frame——它的输出属于下一帧。 这一句是"延迟"的数学来源:这次收到的脉冲,对应的是下一次上屏的画面。所以从触摸到看见,中间至少隔着一次完整的刷新周期,60 Hz 下就是 16.67 毫秒——而这还是理想情况。

1.2 三个反直觉,和一个正确的提问方式

说白了,把上面四个词连起来,会得到三个跟直觉相反的事实。

反直觉之一:它不是"帧的驱动器",它是"帧的接收器"。 应用里没有任何代码在"控制帧率"。你写一个 invalidate(),你写一个动画,都只是表达了"我想画"这个意愿;真正决定什么时候画的是显示硬件。所以"怎么提高帧率"这个提问方式本身就是错的——正确的提问是"我该在什么条件下不再要求新帧"。

反直觉之二:五档回调不是五个队列,而是五个优先级层。 每一层是一个独立的 CallbackQueue,doFrame 按固定顺序逐层取到期回调。一个回调挂在哪一层,决定了它在同一拍里排前面还是后面。

反直觉之三:同一拍里 post 三个回调,doFrame 只会跑一次。 Choreographer 内部用一个 mFrameScheduled 标志位保证这一点:只要已经排了帧,后来的 postFrameCallback 只入队、不重复请求 Vsync。这个标志位在下一篇(ep03)会与渲染线程的提交动作对上——它是整条链"一拍只推一次"的闸门。

还有一个属性值得单独记一笔,因为它同时是"省电的机制"和"泄漏的源头":postFrameCallback 注册的回调只运行一次,运行完就被自动移除。 想让回调每帧都跑,你得在回调里再 post 一次自己。这句话是后半篇讲"自驱动循环"的起点,请先记住它。

1.3 一个 Looper 一个 Choreographer

好了,下面说一条最容易被忽略的属性。这条属性最容易被忽略,但在排查"为什么我明明没做动画,主线程还在按帧跑"时,它是第一把钥匙。

Choreographer 是线程局部(thread-local)的:每个带 Looper 的线程各有自己的一份,Choreographer.getInstance() 拿到的是当前线程的那一份。这意味着:

  • UI 线程的 Choreographer 与某个后台线程的 Choreographer 是两份完全独立的对象,各自订阅各自的 Vsync,各自维护各自的 mFrameScheduled;
  • 在后台线程 post 帧回调,不会让主线程多做一次遍历,但它同样会让系统为那个线程保持一次订阅;
  • 所以"我的界面明明没有动画,为什么还有帧在跑"的答案,可能不在 UI 线程上——你还有一个后台线程在按帧要时间。

这一点对本篇很重要:后面第五章量到的"连接条数",统计的是整机所有 EventThread 连接,不只是你关心的那个进程。本机静止时读到 17 条连接、全部为 None,这 17 条就是这台机器上当前注册着的全部订阅者——它们的数量本身就是一个可观测量。

1.4 一个较新的 API,与一处必须标注的边界

OK,再补一个较新的 API。较新版本的 Choreographer 追加了 postVsyncCallback 与 removeVsyncCallback 一对 API(见 Choreographer 类参考)。它们与 postFrameCallback 的区别在于回调时机的粗细:postVsyncCallback 更贴近脉冲本身,而 postFrameCallback 拿到的是"这一拍里该干活了"的时刻。

这里必须标注边界,因为它是本篇第一个"待验证"项:本机的 Pixel_7 AVD(Android 16)上,这两个方法未经实测。 本篇后续所有读数都不依赖它们,涉及它们的地方只作 API 存在性的说明。理由很简单——一篇讲机制的文章,不该把"文档里写着"当成"我跑过了"。


二、五档回调与它们的顺序

2.1 五档,不是四档

下面我们进入本篇的第一处纠正。这一节要纠正一个在中文技术圈流传极广、错得极其整齐的问题:回调是五档,不是四档,而且旧文里的枚举值整体错了一位。

先看当前(API 29 及以上)的真实取值。它由两条相互独立的路径给出——一条是 AOSP 源码里的常量定义,另一条是对着较新源码写的两篇笔记:掘金《窗口显示(四)》 与 bokmark 的框架源码笔记,两者给的取值完全一致:

常量 当前取值 旧文常见写法 说明
CALLBACK_INPUT 0 0 处理输入事件
CALLBACK_ANIMATION 1 1 计算动画
CALLBACK_INSETS_ANIMATION 2 旧文无此项 处理系统栏插入动画
CALLBACK_TRAVERSAL 3 2 measure / layout / draw 三件套
CALLBACK_COMMIT 4 3 提交阶段(如 RenderNode 提交)

错在哪、为什么错。 成因不是谁抄错了谁,而是中间插入了一档:Android 10(API 29)把"系统栏插入动画"(CALLBACK_INSETS_ANIMATION)插进了动画与遍历之间,TRAVERSAL 与 COMMIT 因此各后移一位。凡是在 Android 10 之前成文、之后没回头核过的文章,都会停在四档加错位的状态——中文侧传播最广的一篇是 CSDN《Android 图形架构之七 — Choreographer 源码分析》,它写的仍是 TRAVERSAL=2 / COMMIT=3。

五档回调的执行顺序与枚举取值
图 2-5五档回调的执行顺序与枚举取值:③ INSETS_ANIMATION 插入后,④ 与 ⑤ 各后移一位@ 本系列绘制

这一问题对排查的影响很实在。假设你在日志里看到某个回调序号或钩子编号是 3,按旧文你会以为那是"提交阶段",实际它是"遍历阶段"——你会去错的那一段代码里找问题。

2.2 顺序为什么是这个顺序

说白了,这五档的排列不是随意的,它反映的是数据依赖:后面的活依赖前面的活改过的状态。中文侧对着源码写的两篇笔记——掘金《屏幕刷新机制 Choreographer 原理分析详解》 与 掘金《Choreographer 工作原理分析》——给出的顺序也与这里一致。

  • 输入在最前,因为输入事件会直接修改 View 的属性(滚动位置、按压状态)。输入没走完就做遍历,遍历出来的是上一拍的布局。
  • 动画紧随其后,因为动画也是在改属性(透明度、位移、缩放)。官方文档把这一档的地位说得很清楚:动画求值必须发生在遍历之前,否则你在这一帧算出来的动画值要等到下一帧才被用上。
  • 插入动画排在中间的第三档,它处理的是系统栏(状态栏、导航栏)的进出动画,同样会改动可用区域,因此也必须在遍历之前。
  • 遍历在第四档,也就是 measure、layout、draw 三件事。到这里为止的所有属性改动,都在这一拍被一次性消化成绘制指令。
  • 提交在最后,把这一拍的结果交出去(例如 RenderNode 的提交)。
五档之间的数据依赖
图 2-6五档之间的数据依赖:输入与动画改状态、遍历消费状态、提交交付结果@ 本系列绘制

这条依赖链有一个很实用的推论:如果你的问题出在"布局读到了旧值",它几乎必然不是布局的问题,而是上游某一档没在这一拍执行。 排查方向应该往回调注册那一侧找,而不是往 onMeasure 里找。

2.3 双缓冲、三缓冲与回调节拍的关系

好了,到这里会冒出一个很自然的问题:既然一拍就是一次刷新周期,那缓冲的数量跟这个节拍是什么关系?

先把两件事分开。节拍是"什么时候可以开始画";缓冲数量是"画好的那一帧有哪些地方可以放"。双缓冲意味着应用只有一块"正在画"和一块"正在显示";三缓冲多出一块,留给"已经画完、还在排队等上屏"的场景。所以缓冲多了不会让帧变快,它只是让迟到的帧有地方排队,而不是被丢掉。

维度 双缓冲 三缓冲
应用可用的绘制目标 2 块 3 块
一次迟到(错过一拍) 大概率丢帧 有机会排队补上
输入延迟 更低 更高(队列里积压更多)
内存占用 更低 更高
典型场景 帧率稳定时 帧时间波动大时

这张表的读法要小心:"三缓冲能减少丢帧"和"三缓冲让系统更快"是两件事。 前者是真的,后者是错的——它牺牲的是延迟。ep04 会展开队列深度与 slot 的机制,ep01 的 §2.4 已经给过本机的实测值(默认 2,不是 3)。

双缓冲与三缓冲的时序对照
图 2-7双缓冲与三缓冲在同一 Vsync 序列上的时序对照:多一块缓冲换更少丢帧,代价是延迟@ Wikimedia Commons · Cmglee(CC BY-SA 3.0)

关于回调节拍在这里还剩最后一点要提醒。我们常说"一帧一次 doFrame",但反过来说要更谨慎——一次 doFrame 不等于上屏一帧。一次 doFrame 只是完成了"这一拍的软件侧工作";这一帧能不能上屏,还要看它有没有在截止时间前交出去。§三与§七会用本机的两态读数把这件事量出来。

2.4 一张表看清五档

下面我们把五档收成一张表,作为后面章节的索引。这张表同时也是对五档的一次归类:前四档都在"改状态或消费状态",只有最后一档在"交付结果":

档 枚举值 干什么 改什么状态 什么时候最值得怀疑它
INPUT 0 分发输入事件 View 的滚动与按压状态 滑动跟手性异常
ANIMATION 1 求值动画、执行 FrameCallback 属性值 界面静止但帧还在产
INSETS_ANIMATION 2 系统栏进出动画 可用区域 全屏切换后布局跳动
TRAVERSAL 3 measure / layout / draw 绘制指令 布局读到旧值
COMMIT 4 提交结果 渲染节点状态 画面更新滞后一拍

这张表自上而下就是执行顺序,也是排查顺序。 遇到"这一帧不对",按这张表从上往下问一遍,比直接扎进 onDraw 效率高得多。


三、一次 doFrame 里发生的事

3.1 四个动作

前面说过 doFrame 是调度方。它到底调度了什么?先看看四件事,按顺序。

第一件,复位标志位。 一进 doFrame,先把 mFrameScheduled 复位为 false。这一步看起来琐碎,但它的位置很关键——它是"下一拍可以重新订阅"的开关。复位发生在一帧工作的开头而不是结尾,意味着在 doFrame 执行期间如果有新回调被 post 进来,系统会认为"这一拍还有活要干",于是重新排一拍。这就是为什么同一拍里 post 进来的回调不会被漏掉。

第二件,逐档执行回调。 按 INPUT → ANIMATION → INSETS_ANIMATION → TRAVERSAL → COMMIT 的顺序,逐层取出"到期"的回调并执行。之所以强调"到期",是因为回调可以带延时——postFrameCallbackDelayed 注册的回调会在时间没到时留在队列里,不消耗这一拍。

第三件,处理掉帧判定。 拿当前时刻与"本拍本该发生的时刻"相减,看差了多少个帧间隔。这一步的产物就是你偶尔在日志里看到的 Skipped N frames!。

第四件(隐含的),决定要不要排下一拍。 如果执行完回调后队列里还有活,或者执行期间又有新回调进来,就通过 scheduleVsyncLocked() 再去要一次脉冲。这一步是整篇的分水岭:它既是"持续动画能一帧接一帧"的原因,也是"忘了停订阅会一直跑下去"的原因。两者是同一段代码的两种使用方式。

一次 doFrame 的四个动作
图 2-8一次 doFrame 的四个动作:第 4 步“按需排下一拍”既是持续动画的机制,也是订阅泄漏的根源@ 本系列绘制

3.2 掉帧检测:三行公式,一个阈值

好了,这是本篇唯一给出完整公式的地方,因为它几乎每一篇讲帧率的文章都会提到,但讲到阈值这一步就散了。

jitterNanos   = 当前时刻 − 本拍本该发生的时刻
skippedFrames = jitterNanos / mFrameIntervalNanos
若 skippedFrames ≥ 30 → Log.w("Choreographer", "Skipped N frames! ...")

其中:

  • jitterNanos 是"抖动",即这一拍实际开始的时间比它本该开始的时间晚了多少纳秒。它衡量的是迟到量,不是耗时量——这一点极容易被混。
  • mFrameIntervalNanos 是一个刷新周期,本机 60 Hz 下是 16,666,667 纳秒。
  • skippedFrames 是把迟到量换算成"相当于丢了几拍"。注意它是除法取整,所以迟到不到一拍的抖动会被记成 0。
  • 阈值 30 帧 是 SKIPPED_FRAMES_WARNING 的取值。

阈值 30 是个很关键的细节。 它意味着这条日志只有在一次迟到相当于丢了 30 拍时才会出现——60 Hz 下就是连续迟到约 500 毫秒。所以:

"日志里没看到 Skipped N frames"和"没有掉帧"是两件事。 前者只说明没有发生 500 毫秒级别的长停顿。

本机实测正好印证了这一点,而且是以一种很有说服力的方式:

读数 取值 说明
Skipped 日志出现次数 1 次(累计 40 帧) 十分钟级别的重负载抓取里只出现一次
同一批数据的 janky 帧 53 / 122 = 43.44% 近一半帧被判定为 janky
掉帧计数 Missed Vsync 32 官方汇总段给出的另一个口径
高输入延迟计数 136 输入侧同样在积压

同一个 122 帧的窗口里,日志说"掉了 1 次",汇总段说"43% 的帧是 janky"。 两个数字差得很远,但它们并不矛盾——它们量的是完全不同的事情:前者是"有没有发生过超长停顿",后者是"有多少帧没能在预期时间内完成"。这个对比值得记下来,因为它解释了为什么很多团队"日志里干干净净,用户却一直在说卡"。

掉帧日志原文与触发阈值
图 2-9掉帧日志原文与它的触发阈值:Skipped 40 frames,阈值 30 帧(60 Hz 下约 500 毫秒)@ 本机实测(Pixel_7 AVD,Android 16,logcat)

3.3 在 trace 上看 doFrame 长什么样

先看看公式之外的第二种证据:把 doFrame 在时间轴上的实际形态抓下来。

本机用 Perfetto 抓了一段 6 秒的 trace,配置里打开 gfx、view、input、sched 四个类别,抓取期间对桌面做连续滑动。产物是 3,798,303 字节,各关键字符串的出现次数如下:

字符串 出现次数 含义
Choreographer#doFrame 201 这一段时间里实际跑了 201 次 doFrame
Choreographer 687 含类名在内的各种切片
onVsync 121 收到的 Vsync 回调次数
DrawFrames 120 渲染线程侧的绘制帧切片
queueBuffer 480 送缓冲的次数(含多进程)
BLAST 1200 BLAST 缓冲事务相关切片

这五个数字放在一起能读出三件事。第一,doFrame 的次数(201)明显多于 onVsync(121) ——说明确实存在"一拍里跑了不止一次"的情况,这与 §3.1 里"执行期间有新回调进来就再排一拍"的机制吻合。第二,DrawFrames 与 onVsync 几乎相等(120 对 121) ——软件侧每收到一次脉冲大致对应渲染线程的一次绘制,说明这段抓取里没有出现大面积"收了脉冲但没画"。第三,queueBuffer 是 DrawFrames 的四倍 ——这个比值不能读成"丢了四分之三的帧",因为 queueBuffer 是全机计数,包含了其他进程的缓冲提交;跨进程的计数不能与单进程的切片直接比。

最后这一条值得单独强调,因为它是判读 trace 时最常见的错误:图表上的数字只有在口径相同时才能相除。 ep01 §5.4 给过同类反例,本篇又遇到一次。

本机 trace 里的关键切片计数
图 2-10本机 trace 里的关键切片计数:doFrame ×201 对 onVsync ×121、DrawFrames ×120@ 本机实测(Pixel_7 AVD,Android 16,Perfetto,3,798,303 字节)

3.4 源码路径与调用序列

OK,机制篇不讲长代码,但路径和调用序列必须给全,否则读者无法自己复核——cs.android.com 与 AOSP 镜像 两处能查到同一个文件。这条链路从 MSG_DO_SCHEDULE_VSYNC 一路走到 FrameHandler 的逐步展开,可参看 CSDN《深度详解 Android S(12.0) 屏幕刷新机制之 Choreographer》 与 这篇源码分析。下面只留与"订阅—执行—再订阅"这条主轴相关的那几行。

//-----------------------------------【Choreographer.doFrame 关键序列】-----------------------------------
// 文件:frameworks/base/core/java/android/view/Choreographer.java
// 说明:删去了业务分支,只保留与"订阅—执行—再订阅"这条主轴相关的调用
//--------------------------------------------------------------------------------------------------------
public void doFrame(long frameTimeNanos) {          // 【1】由 FrameHandler 处理 MSG_DO_FRAME 时调用
    synchronized (mLock) {
        if (!mFrameScheduled) return;               // 【2】幂等保护:本拍已被处理过就直接返回
        mFrameScheduled = false;                    // 【3】复位——这是"下一拍可重新订阅"的开关
        ...
    }
    doCallbacks(Choreographer.CALLBACK_INPUT,  frameTimeNanos);              // 【4】五档,按序
    doCallbacks(Choreographer.CALLBACK_ANIMATION, frameTimeNanos);
    doCallbacks(Choreographer.CALLBACK_INSETS_ANIMATION, frameTimeNanos);
    doCallbacks(Choreographer.CALLBACK_TRAVERSAL, frameTimeNanos);
    doCallbacks(Choreographer.CALLBACK_COMMIT, frameTimeNanos);
    // 【5】掉帧判定:jitterNanos / mFrameIntervalNanos ≥ SKIPPED_FRAMES_WARNING(30) 则打日志
    // 【6】若队列仍有活 → scheduleVsyncLocked(),去要下一拍
}

下面对其中三处特别说明。

if (!mFrameScheduled) return; 这一行是幂等保护。 同一拍的脉冲如果在异步消息队列里排了两次,第二次会在这一行被挡掉。这解释了一个现象:有时你在 trace 上看到两个紧邻的 Choreographer#doFrame 切片,其中一个的宽度几乎为零——那是被挡掉的那次。

mFrameScheduled = false 的位置在回调执行之前,而不是之后。 这个顺序是有意的:它让"执行期间新 post 的回调"能够触发一次新的排帧,从而在同一次 doFrame 里被消化。如果复位放在最后,这些回调就会白等一整拍。

doCallbacks 五次调用是硬编码顺序,不是循环遍历。 五档的优先级因此是编译期确定的,运行时无法调整——这也从侧面说明"五档顺序"这件事是接口契约,不是实现细节。

3.5 同步屏障:异步消息为什么能插队

下面我们看看它靠什么支撑。上一节提到的"异步消息"需要一个机制支撑:同步屏障(Sync Barrier)。

Looper 的消息队列是按时序排列的,正常情况下先进先出。但帧脉冲不能排队——它必须插到队首,否则主线程一忙,脉冲就会被排在几十条普通消息后面,等它执行时这一拍早已过去。同步屏障就是为此设计的:Choreographer 通过 postSyncBarrierLocked() 在队列里插一个屏障,屏障之后的消息全部阻塞,而标记为异步的消息可以越过屏障先执行。

//-----------------------------------【同步屏障与异步消息】-----------------------------------
// 文件:frameworks/base/core/java/android/view/Choreographer.java(发送侧)
//       frameworks/base/core/java/android/os/MessageQueue.java(屏障实现)
//--------------------------------------------------------------------------------------------
// 【1】Choreographer 在需要等 Vsync 时插入屏障
//      mHandler.getLooper().getQueue().postSyncBarrier();
// 【2】帧脉冲消息被显式标记为异步
//      msg.setAsynchronous(true);
// 【3】MessageQueue.next() 遇到屏障时,只返回异步消息,普通消息继续挂起

这条机制的工程含义很直接:主线程再忙,帧脉冲也插得进来。 但它不保证你来得及处理——脉冲准时到,你的回调也可能来不及跑完,那就是这一拍丢了。"能收到"与"赶得上"是两个独立的问题,这也是为什么 §3.2 里的 jitterNanos 量的是迟到量而非耗时量。


四、DisplayEventReceiver 与 SF 的握手

4.1 端到端握手:谁注册、谁唤醒、回谁

OK,前面几章一直把"发脉冲"当成一个动作,现在把它拆成一次双向握手。这条链上有两个方向,各有各的传输通道。

应用 → SF 方向:订阅请求。 应用要帧时,通过 DisplayEventReceiver 发出"下一次叫我"的请求。这个请求是一次性的——发一次,得一次。要连续拿,就得每次收到后重发。

SF → 应用方向:脉冲投递。 EventThread 到点后,把脉冲通过 BitTube(一对基于 socketpair 的跨进程通道)送回应用。这条"应用 ↔ EventThread ↔ SurfaceFlinger"的三方往返,中文侧有一篇拆得比较细:CSDN《Android 14 显示系统 - VSYNC 机制》。

这两个方向合起来,才是"握手"的完整含义。而中间那个转发者 EventThread 手里记录的东西,就是我们第五、六章要量的那张连接表。

//-----------------------------------【DisplayEventReceiver 的注册与订阅】-----------------------------------
// 文件:frameworks/base/core/java/android/view/DisplayEventReceiver.java
//       frameworks/native/services/surfaceflinger/Scheduler/EventThread.cpp(SF 侧接收)
//--------------------------------------------------------------------------------------------------------
// 【1】注册:构造时建立与 SF 的连接
public DisplayEventReceiver(Looper looper) {
    mMessageQueue = looper.getQueue();
    mReceiverPtr = nativeInit(new WeakReference<>(this), mMessageQueue);
}                                        // → SF 侧 EventThread::createEventConnection()
                                         //   → 回传一个 EventConnection,内含 BitTube
// 【2】订阅:请求下一次脉冲(native,一次性)
public void scheduleVsync() {
    nativeScheduleVsync(mReceiverPtr);   // → EventConnection::requestNextVsync()
                                         //   → 本连接的 VSyncRequest 变为 Single
}
// 【3】投递:脉冲回来时被回调
public void onVsync(long timestampNanos, long physicalDisplayId, int frame) {
    // timestampNanos 是脉冲携带的时间戳,不是"现在"
}

下面对三处特别说明。

onVsync 的参数里,timestampNanos 是"本拍本该发生的时刻",不是"函数被调用的时刻"。 这两个时间差,就是 §3.2 里的 jitterNanos。混用这两个时间是最容易犯的错——如果你拿系统当前时间去和 timestampNanos 做比较来算耗时,得到的是"迟到量 + 你的处理耗时",两个量被搅在一起。

physicalDisplayId 说明订阅是"面向某块屏"的。 多屏设备上,不同的屏有各自的 EventThread 与各自的节拍。本机只有一块屏,所以这个字段在本篇的读数里不产生分歧。

frame 是帧号。 它对判读"这一拍属于哪一帧"有用,但对定位性能问题帮助有限——因为它是系统给的编号,不是时间。

DisplayEventReceiver 与 SF 的握手四步
图 2-11DisplayEventReceiver 与 SF 的端到端握手四步:建连 → 订阅 → 分发 → 回投@ 本系列绘制

4.2 两条通道,两个常量

先看看两条通道。DisplayEventReceiver 里有两条 Vsync 通道,分别对应两类不同性质的消费者:

常量 取值 消费者 性质
VSYNC_SOURCE_APP 0 应用(Choreographer、Swappy 等) 软件脉冲,由 DispSync 推出来的提前量
VSYNC_SOURCE_SURFACE_FLINGER 1 SurfaceFlinger 自己 用于合成决策,与上屏时刻关系更紧

为什么要分两条?因为两者要的东西不同:应用要的是"给我留出足够时间画完"的时间点,SurfaceFlinger 要的是"离上屏最近"的时间点。同一条脉冲无法同时满足这两种诉求,所以有了 VSYNC 与 SF_VSYNC 的分工——这也正好回到 ep01 §4.1 讲过的三个相位。ep01 讲的是"这三个时刻在哪儿",本篇讲的是"这两条通道各自叫醒谁"。

两条 Vsync 通道的诉求差异
图 2-12两条 Vsync 通道:VSYNC_SOURCE_APP = 0 与 VSYNC_SOURCE_SURFACE_FLINGER = 1 的诉求差异@ 本系列绘制

4.3 一次性:这一节不讲"应该",只讲"是什么"

下面这一节只有一句话,但它是半篇的后半段全部论证的起点:

scheduleVsync() 是"一次性"的。收到一次脉冲之后,订阅就用完了。想要下一拍,必须重新订阅。

这句话不是"建议",是接口语义。scheduleVsync() 在 native 侧对应 EventConnection::requestNextVsync(),它把连接的请求置为"只要下一次",而不是"一直要"。换句话说:

  • 连续动画能一帧接一帧,是因为每一帧的回调末尾又调了一次 scheduleVsync(),而不是因为系统一直在发;
  • 界面静止时没有帧,是因为没有任何回调在末尾重新订阅,而不是因为系统"知道你没在动";
  • 所以"静止了还在耗电"的成因,在应用侧只剩一种可能:某个回调一直在重新订阅自己。
scheduleVsync 的一次性语义
图 2-13scheduleVsync 的一次性语义:一次订阅只换回一次脉冲;“持续”是误以为的语义@ 本系列绘制

把这一节的结论与 §一 的那句"postFrameCallback 的回调只运行一次后自动移除"接起来,本篇的核心命题就完整了:

调用只跑一次,但订阅可以无限延续。 决定界面静止时系统停不停下来的,不是回调本身,而是"回调里有没有再订一次"。


五、VSyncRequest 的状态机

5.1 有哪几档:先核枚举名,再谈"有几种"

OK,言归正传。第四章说了"订阅是一次性的"。订阅这个动作在系统侧留下的是什么?是一个枚举值——每条连接都有一个 VSyncRequest,记录它现在要什么。

关于这个枚举,中文写法里流传最广的是"三种:None、Single、Persistent"。这个说法有两处问题。

取值 数值 含义 中文旧文常见写法
VSyncRequest::None −1 这一帧不要,别叫我 None
VSyncRequest::Single 0 只要下一次 Single
VSyncRequest::Periodic 1 按周期持续订阅 漏了,被 Persistent 顶替
Persistent 较新版本追加 持续订阅的一种形态 被当成三档之一
SingleSuppressCallback 较新版本追加 单次订阅且抑制回调投递 未见中文讨论

问题一:把 Periodic 漏掉了。 以 AOSP 的 EventThread.h 为准,三档的核心是 None、Single、Periodic,取值分别是 −1、0、1。问题二:Persistent 与 SingleSuppressCallback 是较新版本追加的,不是"三档"里的固定成员。

这里要立一条纪律,它是本篇最想传达的方法论:

"枚举有哪几档"与"这台机器上出现了哪几档"是两件事,不能并成一句写。

前者查源码,后者跑命令。混起来写,就会出现"文档说有五种,我机器上只见到两种,所以文档错了"或反过来的误判。更麻烦的是,这种混写会把"我没见到"悄悄变成"不存在"。

这条纪律不是凭空来的。 上一篇(ep01)上线时,§4.3 写的正是"VSyncRequest 有 None / Single / Persistent 三种"——它踩的就是上面第二个问题。本篇在做阶段 1 研究时对照 EventThread.h 一核,发现它对不上,于是先回头把 ep01 就地改正并重新上线(批次 20260929-091451),然后才继续写这一篇。

把这件事写在这里不是自曝其短,而是因为它比任何一段原理都更能说明"先核枚举名"这个动作的价值:我踩过一次,所以这一篇的每个枚举值都给了出处。 完整来源见 EventThread.h。

5.2 谁把它推到哪一档

枚举本身只是取值,真正有意思的是迁移。先来看看三个动作,它们覆盖绝大多数情况。

动作一:应用要帧 → Single。 DisplayEventReceiver.scheduleVsync() 被调用时,连接的请求被置为 Single。注意:应用侧没有任何 API 能让它直接变成 Periodic。 持续订阅不是应用"申请"来的,而是应用"每次只要一次"累积出来的效果。这一点极易被误解,很多开发者以为"动画在跑所以系统开了周期模式"——实际上从头到尾都是 Single 一次又一次。

动作二:脉冲送达 → 回到 None。 需求被满足,请求清零。这就是 §4.3 说的"用完了"。所以在任意时刻抓一次连接表,看到的大多应该是 None ——因为 Single 只在"我提了要求、还没被满足"的那个极短窗口里存在。

动作三:压制回调 → SingleSuppressCallback。 较新版本里的一种形态:仍然只订一次,但不把回调投递给上层。它出现在什么场景、由谁触发,属实现细节,本篇只记录"本机滑动时确实读到了它"(见 §5.4)。

VSyncRequest 的取值与三条迁移
图 2-14VSyncRequest 的取值与三条迁移:None(−1) 是稳态、Single(0) 是瞬态、Periodic(1) 本机两态恒为 0@ 本系列绘制

5.3 静止时实测:17 条连接,全部是 None

好了,现在把命令跑起来。dumpsys SurfaceFlinger 的输出里有一段连接列表,每条形如 Connection{0x…, VSyncRequest::None}。把静止状态的桌面放两秒,然后抓一次,统计各取值出现的次数:

状态 None Single Periodic SingleSuppressCallback 合计
静止(回到桌面后静置) 17 0 0 0 17
滑动(连续滑动期间) 16 0 0 1 17

静止时 17 条连接无一例外全是 None。 这是本篇最想拿到的一个读数,因为它是"空闲即无周期订阅"这个论断的直接证据——不是推论,不是源码里读出来的意图,而是这台机器在这一刻的真实状态。连接总数 17 不变,说明没有任何连接被销毁重建:这 17 个订阅者一直都在,只是它们此刻都不要帧。

静止状态的连接列表
图 2-15静止状态的连接列表:17 条 Connection 的 VSyncRequest 全部为 None@ 本机实测(Pixel_7 AVD,Android 16,dumpsys SurfaceFlinger)

5.4 滑动时实测:出现了 SingleSuppressCallback

下面我们换到滑动状态。同一条命令,在连续滑动期间再抓一次,结果变了一格:16 条 None 加 1 条 SingleSuppressCallback。

这一格的三个细节值得逐条读。

第一,变化的是 SingleSuppressCallback 而不是 Periodic。 也就是说,即使界面正在高速滚动、每一拍都在产帧,也没有任何连接进入周期订阅模式。这坐实了 §5.2 的结论:连续帧是"一次一次地要"堆出来的,不是"开了周期模式"。

第二,为什么只有 1 条,而不是满屏。 因为滑动期间真正需要在主线程做遍历的订阅者,只有持有那个滚动容器的进程(本机是桌面)。图标层、壁纸层、系统栏层的订阅者并不因为"手指在动"而改变需求。这恰好说明这张连接表是"按需"的,不是"按状态"的。

第三,Single 一次都没读到,反而是 SingleSuppressCallback 被读到了。 这不是矛盾,而是采样窗口的必然结果:回到 §5.2 的动作二,Single 只在"提了要求还没被满足"的那个极短窗口存在,而一次 dumpsys 从发起到打印完要几十毫秒——抓到的概率天然极低。这提醒我们一件事:用 dumpsys 抓连接表,抓的是"稳态",不是"瞬态"。 想知道 Single 的存在,靠的是源码语义与 trace,不是这条命令。

滑动状态的连接列表
图 2-16滑动状态的连接列表:16 条 None 加 1 条 SingleSuppressCallback,Periodic 仍未出现@ 本机实测(Pixel_7 AVD,Android 16,dumpsys SurfaceFlinger)

5.5 这个枚举是怎么走到今天的

下面补一条时间线。为什么要补?因为 §5.1 那个"整体后移一位"的错位,本身就是版本演进的产物——不理解它怎么变的,就还会再错一次。

时间 版本 与本篇论证相关的变化
2012 [Google] Android 4.1(API 16) Choreographer 与 Vsync 驱动机制引入,取代此前的直接绘制方式——本篇主角登场的版本
2014 [Google] Android 5.0(API 21) 渲染线程从 UI 线程分离,UI 线程的工作被压缩成"记录"(ep03 展开)
2017 [Google] Android 8.0(API 26) HWC2 成为标准合成接口(ep09 展开)
2019 [Google] Android 10(API 29) CALLBACK_INSETS_ANIMATION 插入,五档回调枚举整体后移一位——旧文错位的根因
2021 [Google] Android 12(API 31) 官方把 FrameTimeline 的可用版本定在 Android 12 及以上;本篇本机取不到帧级切片(见 §7.1 的口径说明)
2025 [Google] Android 16(API 36) 本篇实测环境;framestats 已是 24 列

年份口径:以 AOSP 分支的正式发布年份为准(Android X 对应 API Y)。"与本篇论证相关的变化"一列只列与本篇有关的项,不是完整的版本史——完整的版本矩阵 ep01 §6 已经给过。

这条时间线的用法:看 2019 那一行。它说明"枚举值错位"不是笔误,而是版本演进留下的化石。凡是 2019 年之前成文、之后没回头核过的写法,都会带着那一次的位移。这也解释了为什么这类错误会成片出现,而不是零散分布。


六、空闲时应该发生什么

6.1 先把一句话的来源交代清楚

言归正传,到这里可以正面回答核心问题了:界面空闲时,系统应该发生什么?

答案听起来很简单:没有任何回调在等帧,于是没有任何连接重新订阅,于是系统不再为这个应用产生软件脉冲,EventThread 回到等待状态。

但这句话的来源必须交代清楚,否则又是一条以讹传讹。我在官方文档里没有找到"界面空闲时应停止请求 Vsync"这样的逐字表述——比较接近的口径出现在 AOSP 侧 Frame pacing 对"不必要的显示屏更新"的取舍讨论里,以及中文侧一篇基于 Android 10 源码的笔记(掘金《Android-Choreographer 工作原理》)明确写过的"界面不变则不请求 Vsync"这一步。查过的三处最接近的官方内容如下:

来源 官方原话(大意) 它能支持什么
Choreographer 类参考 接收脉冲并调度工作,作为下一帧的一部分 只说明"被脉冲驱动",没说空闲怎么办
Frame Pacing 库 该库可延长电池续航,因为它避免不必要的显示屏更新 间接支持"不必要的帧是可以不产"的
实现 VSYNC VSYNC 与 SF_VSYNC 由 DispSync 生成,分别供给 Choreographer 与 SurfaceFlinger 说明脉冲从哪来,没说何时停

所以本篇的写法是:把"空闲即停"明确标注为 AOSP 源码实现语义(implementation semantics),而不是官方规定。 依据是两条源码事实:Choreographer 只在"有待执行的回调"且"本拍尚未排"时才调用 scheduleVsyncLocked();EventThread 在连接请求全为 None 时会回到等待状态、不产生软件脉冲。

这两条都不是公开文档的承诺,而是当前实现的形态。厂商可以改,未来版本也可以改。把实现细节当成规范来引用,是技术写作里最隐蔽的一类错误。

6.2 两态并排:本篇最有力的一张证据

下面我们把两次读数并排摆在一起。把 §5.3 与 §5.4 的两次读数并排摆在一起,就得到了本篇最直接的一组对照。

观测项 静止 滑动 变化
连接总条数 17 17 不变(无连接被销毁重建)
其中的 None 17 16 少 1
其中的 Periodic 0 0 始终为 0
其中的 SingleSuppressCallback 0 1 出现 1 条
帧数据行数(gfxinfo) 0 127 从"读不到帧"到"读满一整页"
Choreographer#doFrame 极稀疏 密集 桌面静止时几乎不产帧

这张表的阅读重点不在"滑动时多了什么",而在两列里那两个恒为 0 的格子。 Periodic 在两种状态下都是 0,说明这台机器上没有任何订阅者使用周期订阅——包括正在高速滚动的那个。而 None 从 17 掉到 16,只掉了一格,说明滑动所需的帧只来自一个订阅者的"按需索取"。

静止与滑动两态的连接列表并排
图 2-17两态并排:同为 17 条连接,静止全部 None、滑动只多一格;两列的 Periodic 都是 0@ 本机实测(Pixel_7 AVD,Android 16,dumpsys SurfaceFlinger 两次采样)

6.3 帧数据的两态:从 0 行到 127 行

OK,连接表证明的是"订阅状态",还需要一个量证明"产出"。用 dumpsys gfxinfo <pkg> framestats 就能拿到。

方法是先 reset 清空统计,然后分别在两种状态下读取:

步骤 静止 滑动
dumpsys gfxinfo <pkg> reset 执行 执行
状态保持 回到桌面静置 2 秒 连续滑动 10 次
framestats 数据行数 0 行 127 行
表头是否可读 是(24 列) 是(24 列)

静止时是 0 行。 这个 0 是这一组实验里信息量最大的数字:它意味着在 reset 之后的那段时间里,这个应用一帧都没有完成过。不是"帧很慢",而是"没有帧"。把它和连接表的"17 条全 None"放在一起,两条独立的通道给出了同一个结论——这一次,静止是真的静止。

framestats 的两态帧数据
图 2-18gfxinfo framestats 的两态帧数据:reset 后静止 0 行、滑动 127 行@ 本机实测(Pixel_7 AVD,Android 16,dumpsys gfxinfo com.google.android.apps.nexuslauncher)

6.4 EventThread 回到等待:链路的收尾

下面把系统侧那一半补上。应用不再订阅之后,SurfaceFlinger 里的 EventThread 做了什么?

EventThread 的核心是一个循环:检查连接表里有没有人需要脉冲。当所有连接的请求都变成 None 时,它没有脉冲需要产生,于是回到等待状态,直到有新的请求进来。 这个"等待—被唤醒—分发—再等待"的形态,是它在稳态下的正常姿势。而如果脉冲始终有人要,硬件垂直同步就会被保持启用——这也是"后台有个动画没停"会实实在在体现在功耗上的原因。

这里要把两句容易混的话分开:

  • "应用停止订阅"是应用侧的行为:没有回调重新订阅,请求归零;
  • "EventThread 回到等待"是系统侧的响应:请求归零后,它不再产生软件脉冲。

这两件事之间存在因果,但不是一个动作。把它们合成一句"应用停止订阅后系统就休眠了",会掩盖掉中间那个真正需要观测的对象——连接表。

空闲收尾链路
图 2-19空闲收尾链路:回调清空 → 不再订阅 → 请求归 None → EventThread 回等待@ 本系列绘制

6.5 一条可复用的纪律

好了,把第六章的结论收成一条能带走的规矩,它是本篇对 ep01 那条纪律的延伸:

第一层问"文档怎么写",第二层问"AOSP 源码怎么做",第三层问"这台机器跑出来是什么"。三层可以不一致——不一致时,先别急着说谁错了,先问"这一层的口径是什么"。

具体到本篇:官方文档没写"空闲应停"(第一层缺位),源码语义说"无回调则不订阅"(第二层有答案),本机跑出来是"静止时 17 条全 None、0 行帧数据"(第三层可验证)。三层各有各的位置,合起来才是完整的一句话。


七、实测:静止不动 vs 持续滑动

前六章讲的都是"应该是什么"。这一章全部是读数。方法很朴素:同一台机器、同一个应用、同一条命令,只改一件事——界面动还是不动。

7.1 第一件意外:列名跟文档不一样

先看看第一件意外。按官方文档的说明,framestats 输出的是这样一组列(14 列):

FLAGS, INTENDED_VSYNC, VSYNC, OLDEST_INPUT_EVENT, NEWEST_INPUT_EVENT, HANDLE_INPUT_START,
ANIMATION_START, PERFORM_TRAVERSALS_START, DRAW_START, SYNC_QUEUED, SYNC_START,
ISSUE_DRAW_COMMANDS_START, SWAP_BUFFERS, FRAME_COMPLETED

本机跑出来是这样一组(24 列,驼峰式):

Flags, FrameTimelineVsyncId, IntendedVsync, Vsync, InputEventId, HandleInputStart,
AnimationStart, PerformTraversalsStart, DrawStart, FrameDeadline, FrameStartTime,
FrameInterval, WorkloadTarget, SyncQueued, SyncStart, IssueDrawCommandsStart,
SwapBuffers, FrameCompleted, DequeueBufferDuration, QueueBufferDuration, GpuCompleted,
SwapBuffersCompleted, DisplayPresentTime, CommandSubmissionCompleted

两列并排放在一起,差异有三类。第一类是命名风格:文档用下划线大写,机器用驼峰。第二类是数量:14 对 24,多出来的 10 列大多与缓冲、GPU 完成、上屏时刻有关。第三类是语义新增:机器版本里有 FrameTimelineVsyncId、WorkloadTarget、FrameInterval、DisplayPresentTime 这些文档没提的字段——它们与帧时间线、工作负载目标、上屏时刻直接相关。

遇到这种不一致,本篇的取舍是:以机器实测为准,两列并排给出,并说明差异。 理由不是"机器一定对",而是:解析脚本要面对的是机器输出,不是文档;而且把差异本身写出来,读者才能知道自己的设备上该按哪一版写解析。 如果只抄文档,解析脚本在这台机器上会直接对不上列。另外提醒一句:只有 Flags 为 0 的行才是有效帧,非 0 表示这一帧被跳过了,做统计时应当剔除。

还有一处前提要说清:官方并没有提供"把 framestats 转成分布图"的现成工具。 官方文档的建议是自行解析或贴成表格;社区可用的只有 mobile-doctor 与 ARM Workload Automation,而这两个都不是为此设计的。本篇下面两张分布图,都是自写脚本算出来的。

framestats 的列名对照
图 2-20framestats 的实际列名:本机 24 列驼峰式与官方文档 14 列下划线大写的并排对照@ 本机实测(Pixel_7 AVD,Android 16,dumpsys gfxinfo)

7.2 迟到周期数:谁没赶上 Vsync

好了,现在是本篇最核心的一组数字。

先讲为什么不画帧耗时直方图。原计划是要画一张 FRAME_COMPLETED − INTENDED_VSYNC 的耗时分桶图的,本机一跑就发现它退化成了一个柱子:127 帧里 126 帧的耗时都在 33.3 毫秒以上,中位 85.5 毫秒。 原因是本机是模拟器、走 SwiftShader 软件光栅化,CPU 根本画不过来——60 Hz 的节拍在那里,但软件渲染一帧要 85 毫秒,等于每拍只能画五分之一帧。

一张只有一个柱子的直方图没有任何信息量。所以本篇改了一个量:不问"一帧花了多久",改问"这一拍晚了几个周期"。

迟到周期数 = (VSYNC − INTENDED_VSYNC) / mFrameIntervalNanos

其中 INTENDED_VSYNC 是"这一帧本该对齐的那一拍",VSYNC 是"实际用上的那一拍"。两者相减,就是这个应用的 UI 线程没赶上几个节拍。本机 127 帧的完整分布:

迟到周期数 帧数 占比 折算迟到时长(60 Hz)
0(准时) 32 25.2% 0 毫秒
1(迟一拍) 83 65.4% 16.7 毫秒
2 10 7.9% 33.3 毫秒
3 1 0.8% 50.0 毫秒
5 1 0.8% 83.3 毫秒
合计 127 100% —

这一组数字比耗时直方图有信息量得多,理由有三条。

第一,它直接就是用户感受。 迟到一拍意味着这一帧赶不上最近的一次刷新,要多等 16.7 毫秒才亮;迟到五拍就是多等 83 毫秒。这才是"卡不卡"的原始量。

第二,"迟一拍"占了 65.4%,而这个数本身就是结论。 它说明这套系统的瓶颈不是"偶尔的长停顿",而是稳定的、每帧都差那么一点点。这类问题的修法跟"偶发卡顿"完全不同:偶发卡顿要去找那个尖峰,稳定迟到要找的是"每帧固定多出来的那几毫秒"。

第三,迟到超过两拍的只有 2 帧(1.6%)。 也就是说,出现"严重卡顿"的比例其实很低——这跟 §3.2 里"Skipped 日志只出现 1 次"是同一件事的两种度量,互相印证。

至于 32 帧准时的部分,也值得说一句:这说明 UI 线程并不是"永远慢",而是处在一个临界状态——有时候刚好赶上,大多数时候差一点点。临界状态是最难优化的,因为任何一点余量都会被立刻吃掉。

本机迟到周期数分布
图 2-21本机迟到周期数分布:0 周期 32 帧、1 周期 83 帧、2 周期 10 帧、3 周期 1 帧、5 周期 1 帧@ 本机实测(Pixel_7 AVD,Android 16,127 行帧明细自写解析)

7.3 两套口径的耗时对不上,这次不是错

OK,同一批滑动负载,换一条命令读耗时,会得到另一组数字。dumpsys gfxinfo <pkg>(不带 framestats)的汇总段给出的是官方统计:

指标 本机读数
渲染帧总数 122
janky 帧数 53(43.44%)
第 50 百分位 46 毫秒
第 90 百分位 65 毫秒
第 95 百分位 73 毫秒
第 99 百分位 117 毫秒
Missed Vsync 32
高输入延迟 136
绘制命令下发慢 46

注意 50 百分位是 46 毫秒,而上一节 framestats 逐行算出来的中位是 85.5 毫秒。 相差近一倍。第一次读到这里,很容易判成"某一条命令在撒谎"。

它们都没错,量的是两个不同的窗口。

口径 窗口 量法
汇总段百分位 自上一次 reset 起累积的全部帧(本次 122 帧) 官方直方图,由系统在帧完成时登记
framestats 逐行 最后 120 帧的环形缓冲(本次落 127 行) 逐帧明细,逐行自行计算

两个窗口的长度不同、覆盖的帧集不同、登记的时机也不同,所以中位数不同是必然的。更要紧的是:这两个数不能互相验证,只应互相参照。 想验证一致性,必须先让两个窗口重合(比如 reset 之后立刻连续抓,且负载保持稳定)。

还有一处必须写明的限定:这 85.5 毫秒的中位耗时,是模拟器加软件渲染的结果,不是 Android 的性能。 真机上有 GPU 承担光栅化,同样的界面在同样的 60 Hz 节拍下不可能每帧花 85 毫秒。本篇把它写出来,是为了说明"迟到周期数"这个口径怎么用,不是给出任何性能数字。这条限定在全篇反复出现,因为它是最容易被读者顺手摘走、当成结论的陷阱。

两套口径的帧耗时对照
图 2-22两套口径的帧耗时:汇总段第 50/90/95/99 百分位(46/65/73/117 毫秒)与 framestats 逐行中位 85.5 毫秒@ 本机实测(Pixel_7 AVD,Android 16 + SwiftShader 软件渲染)

7.4 撕裂:为什么"早一点上屏"也不总是好事

先看看一个容易冒出来的想法:既然迟到不好,那"越早上屏越好"是不是就行了?

不是。这里有一条硬约束:画面必须一次换完。

显示控制器是把缓冲区里的像素逐行扫到屏幕上的,这个扫描过程需要一整个刷新周期。如果在一帧还没被扫完的时候,另一帧被换上去,屏幕的上半部分就是旧帧、下半部分是新的——这就是撕裂(Tearing)。

所以拿掉了 Vsync,帧确实能"更早"上屏,代价是画面被撕开。这就是题图那句话的完整含义:等得越齐,撕裂越少,但延迟越高。 Vsync 的全部意义在于把"换缓冲"这个动作对齐到扫描的空隙里,而它换来的代价就是等待。

这也解释了为什么"静止时该停"是一条好性质:没有帧要产的时候,就不会有人因为在错误时刻上屏而把画面撕开,也不会有任何延迟收益的损失。 什么都不做,恰好是这时最优的选择。

撕裂伪影的成因
图 2-23撕裂的成因:显示控制器逐行扫描期间缓冲被换走,屏幕上下半屏分属两帧@ Wikimedia Commons · Vanessaezekowitz(CC BY-SA 3.0)

八、四个常见的误用

前七章把机制讲完了。这一章换个角度:把最常踩的四个坑摊开。它们的共同点是——都能让"静止即停"这条性质失效。 而其中第一个坑最麻烦,因为它看起来完全正常。

8.1 自驱动循环:唯一一个"看起来完全正常"的坑

OK,言归正传。回到 §一 的那句话:postFrameCallback 注册的回调只运行一次,运行完自动移除。 于是想让某件事每帧都做,标准写法是在回调末尾再 post 一次自己:

//-----------------------------------【自驱动帧回调(危险写法)】-----------------------------------
// 说明:回调只跑一次,所以"每帧"必须靠自我重订实现——这正是危险的来源
//--------------------------------------------------------------------------------------------
private final Choreographer.FrameCallback mTicker = frameTimeNanos -> {
    updateSomething();                                   // 【1】干本帧的活
    Choreographer.getInstance().postFrameCallback(this); // 【2】重订下一拍——危险就在这一行
};

危险就在【2】这一行。 它本身没有任何错误:连续动画本来就应该这么写。问题在于它没有停止条件。

  • 只要这个回调还挂在队列里,scheduleVsyncLocked() 就有活可排;
  • 每排一次,就消耗一次脉冲;
  • 每次脉冲送达,回调再订一次——闭环形成。

于是:界面完全静止、用户什么都没做,这个线程依然每秒被唤醒 60 次。 表现上一点看不出来——没有画面变化,没有帧率异常,没有日志。唯一的迹象是功耗与温度。这个"只跑一次的回调 + 自我重订"的特性,在 腾讯云《Choreographer 原理及应用》 里被明确点出过。

这个坑之所以值得排在第一个,是因为它与"正确写法"是同一段代码。区别只在于有没有人在正确的时机调 removeFrameCallback。所以判据不能是"代码像不像有问题",只能是观测:界面静止时,这个线程还在不在要帧。

四类误用与各自的观测判据
图 2-24四类误用各自如何让“静止即停”失效,以及各自的观测判据@ 本系列绘制

8.2 自递归的 Fling 链

下面看第二个坑。第二个坑是第一个的变体,但更隐蔽,因为它藏在滚动逻辑里。

惯性滑动(Fling)需要每帧计算一次当前位置,所以典型实现是:每帧在 doFrame 里算出新的滚动位置,然后 post 下一个 Fling 步骤。只要手指甩出去还没停,这条链就是对的。

问题出在"停"的判定上。这条链的终止条件是"速度衰减到阈值以下",而这个阈值往往写得比较宽。于是:

  • 视觉上早就停了(位移小于一个像素,人眼看不出来);
  • 逻辑上还没停(速度还没降到阈值);
  • 于是链还在继续,帧还在要,只是每帧算出来的位移是 0.02 像素。

这类问题的观测量很有意思:帧在产,但画面几乎不动。 它对应的现象就是我们常听说的"越滑越卡"——滑动过程中帧时间被这条链不断占据,越到后面越挤。"越滑越卡"这个说法来自 CSDN《微信越滑越卡》 的一线实测,它抓到的正是 Fling → doFrame → post → Fling 这条自循环。

8.3 生命周期未解绑

先看看第三个坑,它最简单,也最普遍:onResume 里 post,onPause 里忘了 removeFrameCallback。

后果有两层。表层是资源浪费(页面不可见了还在要帧);里层更麻烦——持有回调的对象被这个引用链拽住,无法回收。 这个坑的判据很好给:列出所有 postFrameCallback 的调用点,逐个问"哪一行把它移除了"。 找不出对应行的,就是嫌疑。

顺带说一个容易漏的位置:View 层级的可见性变化(onVisibilityChanged)与 Window 的焦点变化,都不是 onPause。只在 onPause 里解绑,漏掉了"页面还在栈里但不可见"的那一段时间。

8.4 postFrameCallbackDelayed 的语义

好了,第四个坑是一个纯粹的语义误解。

postFrameCallbackDelayed(callback, delayMillis) 听起来像"延迟这么多毫秒后再排帧"。实际语义是:回调挂的仍然是 CALLBACK_ANIMATION 这一档、token 仍是 FRAME_CALLBACK_TOKEN,延迟由 Handler 的延迟消息机制负责——它不会把回调挪到别的档位,也不会改变回调与脉冲的关系。

误解会带来什么后果?如果你用"延迟 16 毫秒"来模拟"隔一帧",得到的是"每帧都跑,只是晚 16 毫秒跑"。 帧在照常被要,只是每次多等一个延迟。想真正隔帧执行,得自己在回调里计数。自定义 FrameCallback 做帧率监控时这一点尤其要注意,掘金《从 FrameCallback 理解 Choreographer 原理及帧率监控》 与 这篇掘金笔记 都展示了这类自定义回调的写法。

8.5 把四个坑收成一张表

下面我们把四个坑收成一张表,一眼看清它们各自是怎么让"静止即停"失效的。

# 误用 典型触发 静止时会发生什么 判据
① 自驱动帧回调 回调内重订自己、无停止条件 线程每秒被唤醒 60 次 静止时该线程是否仍在要帧
② Fling 自递归 速度阈值过宽 帧在产但位移近似为 0 链条终止条件是否在人眼可见的停止点
③ 生命周期未解绑 只在 onPause 移除 页面不可见仍要帧,且对象泄漏 每个 post 是否有对应 remove
④ Delayed 语义混淆 用延迟模拟隔帧 每帧都跑,只是各晚 16 毫秒 回调档位与 token 是否被改动

说白了,四条的判据里,没有一条是"看代码像不像有问题"。 全部是"在静止状态下观测"。这不是巧合——"静止即停"这条性质的验证方式,本来就只有观测一条路。

自驱动帧回调的闭环
图 2-25自驱动帧回调的闭环:界面静止也会每秒被唤醒 60 次,且没有任何日志@ 本系列绘制

这张图是把表里的第①条放大来看。要注意它的性质:它不是一张"错误代码"的图,而是一张"正确代码跑出错误结果"的图。 回调本身没问题,自我重订本身也没问题,问题只在于没有人负责让它停。这也回到本篇反复强调的那句话——系统只执行"有人还在要"这个事实,它不判断"你是不是还需要"。 判断权在应用手里,交付方式就是那一行 removeFrameCallback。


九、三项可以立刻做的事

9.1 三条命令,三个判据

好了,前面八章的原理,落到操作上就是三条命令。每条都给出判据——不是"跑一下看看",而是"读到什么算有问题"。

第一件:看连接表里有没有人周期订阅。

# 静止状态下的连接与它们的 VsyncRequest 取值
adb shell dumpsys SurfaceFlinger | grep -o 'VSyncRequest::[A-Za-z]*' | sort | uniq -c

判据:静止时如果 None 之外还出现了持续性的订阅(尤其是周期订阅),就说明有订阅者在你不知情的时候一直要帧。 本机静止读数是 17 条全 None——这是一个"干净"的基线。同时注意这条命令的一个限制:它抓的是稳态,Single 这种瞬态值大概率读不到(原因见 §5.4)。

第二件:看静止时还在不在产帧。

# 先清空统计,再让界面静止,然后读帧明细的行数
adb shell dumpsys gfxinfo <包名> reset
adb shell dumpsys gfxinfo <包名> framestats | grep -c '^[0-9]*,'

判据:静止窗口内行数应当为 0(或与静止前基本持平)。 本机静止读数是 0 行。如果静止时还在稳定地产生新行,那就是有回调在重新订阅——回到第八章的四条去逐个排查。

第三件:看汇总段的 jank 与 Missed Vsync。

# 官方汇总段:janky 帧比例、掉帧计数、输入延迟计数
adb shell dumpsys gfxinfo <包名>

判据:看三个数——Janky frames 的比例、Number Missed Vsync、Number High input latency。 本机读数是 43.44% / 32 / 136。这里要留意 §7.3 讲的窗口问题:汇总段的数字是自 reset 起累积的,与 framestats 的环形缓冲不是同一个窗口,不要把两者的结果直接相除。命令的完整字段说明见 dumpsys 工具;需要更长窗口时用命令行抓帧或 systrace,测试侧的通用性能验收口径见测试性能。

三条命令与各自的判据阈值
图 2-26三条命令与各自的判据阈值:连接表、framestats 行数、汇总段三个计数@ 本系列绘制

9.2 从"CPU 不归零"到结论:四步判读

先来看看只有一个抱怨时该怎么走。如果只有一个抱怨——"我的页面在后台,CPU 就是不归零"——按下面四步走,每一步都把范围缩小一半。

第一步,确认它是不是真的在产帧。 跑第二件命令。若静止窗口内行数为 0,说明这个应用没有在产帧,问题不在本篇的范围(去查唤醒锁、定时器、传感器)。若有行数,进入第二步。

第二步,确认它是不是在要帧。 跑第一件命令,但要在只有这个应用处于可疑状态时抓。若连接表里出现了持续订阅,进入第三步;若全是 None,说明它在要帧但不通过 Choreographer——大概率是直接调用渲染 API 的自绘场景。

第三步,定位是哪一档在要帧。 用 trace 抓 view 类别,看 Choreographer#doFrame 里哪一档的回调在每拍执行。锚定到档位,范围就从"整个应用"缩到"某一类回调"。

第四步,在该档里找那个重订自己的回调。 到这一步,答案通常已经写在代码里了——一个末尾有 postFrameCallback(this) 却没有 removeFrameCallback 的回调。

这四步的顺序不能换。 先判"有没有帧",再判"是不是要的帧",再判"哪一档",最后才看代码。反过来做(一上来就读代码找可疑点)几乎必然找错,因为前三步的结论会直接排除掉大部分嫌疑。

从 CPU 不归零到定位结论的四步判读
图 2-27从“CPU 不归零”到定位结论的四步判读:顺序不能换@ 本系列绘制

9.3 本篇留下的一句话,与下一篇

下面把全篇收成一句话:

系统不会替你判断"不需要帧"。它只会忠实地执行"有人还在要"这个事实。所以"静止即停"不是一条自动性质,而是一条需要你亲手维护的约定。

这也顺带回答了一开始那个问题:为什么屏幕静止时 CPU 不归零。因为"静止"是视觉上的,而订阅是逻辑上的——这两件事之间没有自动同步。

下一篇讲的是这条链的下半段:脉冲收到了、doFrame 也跑完了,绘制命令是怎么交给另一个线程、又怎么变成一块可以送出去的缓冲的。也就是说,从「谁被叫醒」走到「醒来之后干了什么」。


相关链接

With Best Wishes.