本文由@溯光 出品,转载请注明出处 站点链接:技术博客页 | 公众号:溯光的图形笔记
核心问题:上一篇把 vsync 这条链打开了一半——节拍从哪来、谁在派、派给谁都回答了,但留下一个词没拆:那三条回调的相位到底错开了多少。
本篇从一个反直觉的对照切入。同一段
dumpsys,两台设备:app phase SF phase 本机(模拟器 · Android 16) 1000000 ns 1000000 ns 真机(Pixel 6 · Android 12) 6233333 ns 611111 ns本机的两个数一模一样,真机的两个数差了一个数量级。 而更要紧的是再往下看一行:本机三档(regular / early / GL early)全都是 1 ms,真机三档各不一样,甚至有一个是负的:
本机 early app phase: 1000000 ns early SF phase: 1000000 ns 真机 early app phase: 133333 ns early SF phase: -5488889 ns ← 负值这两个数不是"量"出来的,是"配"出来的。 它们印在 dump 里,但它们真正的身份是设备配置里写进去的纳秒值——
dumpsys只是把配置念了一遍。所以"偏移是多少"这个问题,答案不在屏幕里,在BoardConfig.mk里。
承接上一篇。ep09 讲的是"这条路怎么派班":setVsyncEnabled 按需开关、五条回调各管什么、DispSync 是软件锁相环、No Last HW vsync 在下游截断了供给侧。可那篇只给了偏移一条结论就收手了——"偏移是给最坏情况留的余量,不是最优值",然后明写"本篇只讲偏移是余量,怎么排留给 ep10"。这一篇来还这笔债:把两个属性、一段源码、两份设备读数摆在一起,讲清三路信号的相位到底怎么错开、谁改得动、什么时候会切。
这件事在前几篇里也挂过账。ep06 §2.1 写"等到 vsync"时没问"等的是哪一路";ep08 §1.4 把 vsync 列在链路图里却没展开;ep09 §2.3 给完"余量"结论就停住。到了这一篇,三路信号的时间关系才算真正落地。
三篇共用同一条链路,各篇量的东西仍然不同:
| 维度 | ep08《HWC》 | ep09《HWC2》 | ep10(本篇) |
|---|---|---|---|
| 关心的对象 | 归属:这一帧由谁去摞 | 节拍:这一帧什么时候开始 | 相位:两路节拍错开多少 |
| 量什么 | 每层合成类型与客户端合成代价 | vsync 的派发对象、速率与周期来源 | 三档相位与预算、切档条件、三步舞的实测间隔 |
| 主要命令 | HWC layers 段 + --timestats |
VsyncSchedule / VsyncDispatch 段 + 计数采样 |
Scheduler 相位段 + getprop + --latency 三元组 |
| 典型结论 | 7 层仍全 DEVICE(2) |
滑动 57.57 Hz、静置 0.65 Hz;No Last HW vsync |
本机三档全 1 ms、真机三档各不同(含 −5.49 ms);帧就绪→上屏中位 6.17 / 15.96 ms |
本篇也不涉及:多刷新率与 ARR 下相位怎么随档位走(ep11,需支持 ARR 的真机)、三缓冲与 buffer stuffing(ep11)、应用侧 Choreographer 的调度细节(ep02)、合成归属与 HWC 判定(ep08)、三路信号"从哪来、谁在派"(ep09,本篇只回指)、厂商私有的偏移调参工具与策略(未公开,本篇只写 AOSP 通用链路,并在第六章用一份公开真机 dump 佐证)。为什么不涉及:这一篇的价值集中在"相位差"这一个点上,把谁定义、谁写入、谁切档、怎么量这四问讲透,掺进别的题域就讲不透了。完整边界见 §0.3。
系列文章写作风格说明(沿用前九篇,不再重复解释)
- 术语双语:首次出现的核心术语写成「中文(English)」,后文可只用英文;
- 三层分开说:官方文档怎么写、AOSP 源码怎么做、设备上读出来是什么,这三层分开陈述,不混成一句;
- 读数标口径:每一个数字都标来源与条件(机型、Android 版本、命令、采样时刻);
- 登记值与实测值分开写:这是本篇最要紧的一条纪律——相位、duration 这些数是"登记值"(配置里写的),不是"实测值"(量出来的)。凡涉及"实际错开了多少"的结论,一律标明是回代官方算式算出来的,还是从设备上量出来的;
- 模拟器与真机分开写:本机三档被配成同值,真机三档各不相同,两者的读数分列两栏、不混算。
系列主要内容总览
- 【Android 图形显示系统】(一) 总览:从 App 到屏幕的图形管线
- 【Android 图形显示系统】(二) Choreographer:谁在召唤 Vsync
- 【Android 图形显示系统】(三) RenderThread 与帧提交:从显示模块到一块缓冲
- 【Android 图形显示系统】(四) BufferQueue:图形缓冲区的邮局
- 【Android 图形显示系统】(五) Vulkan on Android:低开销渲染入口
- 【Android 图形显示系统】(六) SurfaceFlinger:在 Vsync 之后做什么
- 【Android 图形显示系统】(七) ASurfaceControl:Android 10 的透明事务
- 【Android 图形显示系统】(八) HWC:GPU 合成之外的硬件叠加
- 【Android 图形显示系统】(九) HWC2:Vsync 回调和虚拟显示路径
- 【Android 图形显示系统】(十) Vsync 偏移:应用、SF、屏幕的三步舞(本篇)
- 【Android 图形显示系统】(十一) 三缓冲、多刷新率与 ARR
- 【Android 图形显示系统】(十二) 虚拟显示与屏幕录制
- 【Android 图形显示系统】(十三) 剖帧实战:Perfetto FrameTimeline 抓 jank
相关资源链接
- 官方文档:VSync(AOSP) | SurfaceFlinger 的系统属性 | SurfaceFlinger 和 Hardware Composer
- 源码:
frameworks/native/services/surfaceflinger/Scheduler/下的VsyncConfiguration.h、PhaseOffsets.h、Scheduler.h、VSyncDispatch.h
这一篇回答什么,不回答什么
OK,先把坐标系挪正。前九篇给了地图、叫醒链、交接、账本、入口、关口、改动、归属、节拍;这一篇要给的是"错开"——三路节拍之间那点时间差是多少、谁定的、什么时候会变。
先说清它跟前一篇的关系:ep09 已经把那三条回调印在设备上了(sf: / app: / appSf:),也给出了两条的 workDuration 差 1 毫秒这个读数。可"差 1 毫秒"说的是预算,不是唤起点——按官方那条"回调在 workDuration + readyDuration 之前派发"的规则回代一遍,同一个 vsync 上应用比 SF 早约 16.67 ms(正好一个周期)被唤醒。这个"回代"就是本篇的起点:把登记值翻译成行为,再拿设备读数去核。
0.0 内容总览:本篇知识地图
好了,先把十一章各自回答的问题钉在一张表上。它同时也是本篇的目录总览。如果时间紧,第三章(DispSync 怎么造偏移)、第五章(切档与短路)、第六章(真机 vs 本机)是这篇最不可替代的三章:第三章给来源,第五章给机制,第六章给现场。
| 章 | 本章问句 | 关键读数或原文 |
|---|---|---|
| 零 | 这一篇回答什么、不回答什么 | 本机三档全 1 ms / 真机三档各不同 |
| 一 | 偏移到底在解决什么问题 | "The latency is at least two frames";"providing a greater chance for error" |
| 二 | 三路信号与"相位"这两个词怎么定 | "three signals with the same period and offset phase" |
| 三 | DispSync 怎么把偏移造出来 | Reference / Output / Feedback;三档三元组 |
| 四 | 偏移从哪来 | VSYNC_EVENT_PHASE_OFFSET_NS → ro.surface_flinger.vsync_event_phase_offset_ns 等三个属性 |
| 五 | 什么时候会切档 | if (mEarlyPhaseOffset == mLatePhaseOffset) return; |
| 六 | 真机与本机差在哪 | 真机 early SF −5488889 ns / 本机 1000000 ns |
| 七 | 在设备上怎么读出来 | 四个读面 + 三种层名输入 |
| 八 | 一次"三步舞"的量化 | 帧就绪→上屏:静置 6.172 ms / 滑动 15.963 ms |
| 九 | 偏移调坏了会怎样 | 过小、过大、负值各自的代价 |
| 十 | 四处说法与四项动作 | 两条口径警示 + 四组命令判据 |
0.1 本篇那个"反直觉的对照"
好了,先把这个现象摆出来,因为它撑住了整篇的骨架。
第一件事:本机的三档相位完全相同,而且是一个"整数"。
app phase: 1000000 ns SF phase: 1000000 ns
early app phase: 1000000 ns early SF phase: 1000000 ns
GL early app phase: 1000000 ns GL early SF phase: 1000000 ns
1,000,000 ns 就是 1 毫秒,三档六个数,六个都一样。这种"整整齐齐"不是巧合——它不是量出来的,是默认值被原样念了一遍(第三章会给出源码侧的解释与社区转录的默认值对照)。
第二件事:真机不是这样的。 一份公开的 Pixel 6(Android 12)bugreport 里,同一个段长这样:
app phase: 6233333 ns SF phase: 611111 ns
early app phase: 133333 ns early SF phase: -5488889 ns
HWC min duration: 0 ns
present offset: 0 ns VSYNC period: 11111111 ns
三档互不相同:常规档 app 6.233 ms、SF 0.611 ms;early 档 app 0.133 ms、SF 是负的 −5.488889 ms。而它跑在 90 Hz 上(周期 11,111,111 ns),本机跑在 60 Hz 上。
把两张表放在一起看,结论就出来了:相位偏移是"配置产物"。同一份 AOSP 代码,在两台设备上给出了完全不同的六个数字——差别不在代码里,在设备的偏移配置里。所以本篇的第一个判据是:
看到相位数字,先问它是"配的"还是"量的"。 本机三档全 1 ms 只说明"这台设备没配",不说明 Android 默认就是 1 ms;真机三档各不同也不说明"真机才好",只说明"这家厂商在这里做了取舍"。
0.2 与相邻篇目的分工
这里有一条必须说清的接口:相位发生在 Scheduler 内部,但它的值来自设备配置;而它生效的对象是 EventThread。
- ep09 讲的是"谁被叫醒"——它给出了
VsyncDispatch里的三条登记;本篇接着问"这三次叫醒分别发生在周期的哪一刻"; - ep02 讲的是应用侧的 Choreographer——它接收显示子系统的脉冲,那个脉冲的相位就是本篇的对象(ep02 只取"一个 Looper 一个 Choreographer"这句接口关系);
- ep11 要接的是"多刷新率下相位怎么跟着走"——本篇只把"相位随刷新率分档"这件事的存在证明出来(
setRefreshRateFps/early/earlyGL三档),不展开档位切换策略。
一句话:ep09 回答"这条路派不派班",本篇回答"班次之间错开多久",ep11 回答"换班表时错开怎么跟着变"。
0.3 边界声明:本篇不涉及什么,以及关键参数
先把边界画出来,省得后面对不上账。
本篇展开:偏移要解决的原始问题(延迟与误差的对立);三路信号 HW_VSYNC_0 / VSYNC / SF_VSYNC 与 offset phase 的定义;DispSync 作为软件锁相环如何"按相位偏移执行回调";偏移的三个来源(两个 ro.surface_flinger.* 相位属性 + 一个 present offset 属性)与它们的两层回退(属性 → ConfigStore HAL);三档配置与切档的两条触发条件;一份公开真机 dump 与本机读数的对照;--latency 三元组把"三步舞"量化。
本篇不涉及(越界内容只给指针,不配图):
| 不涉及的内容 | 已在何处 | 本篇的交代 |
|---|---|---|
| 多刷新率 / ARR 下的相位切换策略 | ep11(需支持 ARR 的真机) | 只用到"本机只有 60 Hz 一档"这一条 |
| 三缓冲与 buffer stuffing | ep11 | 只提"至少两帧延迟"这句官方原话 |
| 虚拟显示与录屏自身的实现 | ep12 | 只回指它的 vsync 从哪来(ep09 已答) |
| Perfetto FrameTimeline 抓 jank 的方法论 | ep13 / 《图形性能剖析工具链》 | 只在"需真机"清单里列名 |
| 合成归属与 HWC 判定逻辑 | ep08 | 只回指 |
| 三路信号的产生与派发本身 | ep09 | 只回指,不重复 |
| 应用侧 Choreographer 的调度细节 | ep02 | 只取"一个 Looper 一条连接"这句接口关系 |
| 厂商私有的偏移调参工具与策略 | 未公开 | 只写 AOSP 通用链路;真机读数只用一份公开 bugreport,逐字标来源 |
关键参数取值范围(后面所有读数都落在这个区间内,超出即视为异常):
| 参数 | 本机实测值 | 说明 |
|---|---|---|
| 刷新周期 | 16,666,666 ns |
与 ep09 的 --latency 首行同值 |
| 三档相位(app / SF) | 各 1,000,000 ns |
三档完全相同(走默认值) |
app duration / SF duration |
16,666,666 ns / 15,666,666 ns |
差 1 ms,与 ep09 三处交叉验证一致 |
HWC min duration |
0 ns |
这台 HWC 的最小耗时估计 |
| 三个偏移属性 | 全部缺席 | 两个相位属性 + 一个 present offset 属性都没设 |
sf PRESENT_TIME_OFFSET |
0 |
出现在 Build configuration 行 |
四类"本机读不到"先说在前:① 三个属性的实际生效值(属性缺席,且回退的 ConfigStore 也没有);② 相位是谁在哪一刻写进去的(dump 只有数值,没有写入者与时间戳);③ 档位切换的历史(本机三档相同 → 切档被短路 → 根本没有切换发生);④ 真机的三段读数在同一时刻的关系(只能逐字引用别人 dump 的静态快照)。逐条登记见 §7.4。
0.4 本篇怎么读:三条判据贯穿全篇
篇幅不短,先把跑完全篇要带走的三条判据放在这里。后面每一章都在为它们补证据,读者也可以拿它们随时检查自己有没有读偏:
| 判据 | 一句话 | 拿到什么证据 | 在哪一章 |
|---|---|---|---|
| 判据一 | 相位是"配"出来的,不是"量"出来的 | 本机三档全 1,000,000 ns vs 真机三档各不同(含 −5,488,889 ns) |
§0.1、§6.1 |
| 判据二 | "两档配成同值"会让切档被短路 | updatePhaseOffsets() 第一句的 if (mEarlyPhaseOffset == mLatePhaseOffset) return; |
§5.2 |
| 判据三 | 登记值 ≠ 行为值 | workDuration 差 1 ms,而唤起点差约 16.67 ms |
§7.2、§10.2 |
三条判据的性质不同,别混用:判据一管"读数能不能外推",判据二管"机制是否在跑",判据三管"两个数能不能互推"。本篇最常出现的两种误读,恰好就是把判据一与判据三违反掉——从模拟器外推 Android、把登记参数当行为参数。
读法上还有一条:本篇的章节顺序是"问题(一)→ 定义(二)→ 来源(三)→ 配置(四)→ 切档(五)→ 对照(六)→ 读面(七)→ 量化(八)→ 坏法(九)→ 收束(十)"。前两章不需要任何设备,从第三章起才出现本机读数;只关心'怎么读'的读者可以直接跳到第七章,那里的四个读面自成一套自检流程。
官方入口留在这里,便于对照:VSync(AOSP) | SurfaceFlinger 的系统属性(AOSP)
一、偏移要解决什么问题:延迟与误差的对立
好了,先看官方为什么要有偏移这件事。它的出发点是一个具体的延迟数字,而不是"三路信号看起来更整齐"。
1.1 两帧延迟从哪来
官方 VSync 页把"不同步时的流水线"讲得很清楚:
"The sync app and SurfaceFlinger render loops to the hardware VSync. On a VSync event, the display begins showing frame N while SurfaceFlinger begins compositing windows for frame N+1. The app handles pending input and generates frame N+2."
来源:VSync(AOSP)
这三句话是一条"错位流水线":同一个 vsync 事件到来时,屏幕上正在显示的是 N、SF 正在合成的是 N+1、应用正在生成的是 N+2。每一段都在干不同的帧。
于是延迟被顶到了两帧以上,官方紧接着给了这个结论:
"Synchronizing with VSync delivers consistent latency. It reduces errors in apps and SurfaceFlinger and minimizes displays drifting in and out of phase with each other. This assumes app and SurfaceFlinger per-frame times don't vary widely. The latency is at least two frames."
"at least two frames"就是问题的来源:60 Hz 下两帧 = 33.3 ms,从"手指按下"到"屏幕变化"至少要等这么长。
为什么要这样错位? 因为三段是串行的,而它们都被同一个硬件 vsync 触发——如果应用和 SF 同时开工,两者就会抢同一段时间:
"To remedy this, you can use VSync offsets to reduce the input-to-display latency by making app and composition signals relative to the hardware VSync. This is possible because app plus composition usually takes less than 33 ms."
注意最后半句的推理方式:官方的逻辑是"应用 + 合成通常不到 33 ms,所以可以在一帧里塞下两段活"。"通常"两个字很关键——它意味着这个安排是赌常态的,代价在 §1.2。
1.2 偏移的两难:它买的是延迟,付的是余量
官方在同一页给了两句话,一句说收益,一句说代价:
"Note: VSync offsets reduce the time available for app and composition, providing a greater chance for error."
"
VSYNC_EVENT_PHASE_OFFSET_NSandSF_VSYNC_EVENT_PHASE_OFFSET_NSare set conservatively based on high-load use cases, such as partial GPU composition during window transition or Chrome scrolling through a web page containing animations. These offsets allow for long app render time and long GPU composition time."
来源:VSync(AOSP)
把两句并起来读,就得到本篇的第一条原理:
偏移不是"把时间表排得更紧",而是"把时间表按最坏情况排松"。 它确实减少了端到端延迟(这是它存在的理由),但它同时压缩了应用与合成各自的可用时间,所以取值不能按平均负载拍,只能按高负载场景(窗口转场时的部分 GPU 合成、带动画的网页滚动)留余量。
官方还给了一条"多少才算有感"的量级:
"More than a millisecond or two of latency is noticeable. To minimize latency without significantly increasing error counts, integrate thorough automated error testing."
"一两个毫秒就有感"——这句话解释了为什么偏移的取值单位是纳秒级而不是毫秒级,也解释了为什么真机会把它调到十几万纳秒(0.133 ms)这种精度(§6.1)。
这张对照要说明的是一件事:排班表不是按"平均值"排的,而是按"最坏的那一天"排的。 按平均负载排,账面延迟更低,但一旦遇到窗口转场那种重负载,三段就会在同一段时间里互相挤,出错概率随之上升;按最坏情况排,常态下确实有闲置,但重载时余量在那里。官方选择后者,代价就是平时的延迟比理论最优值高一点。本节这一对数字——"一两个毫秒就有感"与"按最坏情况保守设定"——合起来界定了偏移的取值范围:它是纳秒级精调的量,却用毫秒级的最坏情况在做预算。
1.3 一位三体的读数:本机观测环境
好了,后面所有读数都来自同一套环境,先把它的身份交代清楚(沿用 ep09 的封条,本章再复述一次关键几条):
service list 第 31 项 android.hardware.graphics.composer3.IComposer/default
ro.hardware = ranchu ← 模拟器虚拟板
ro.hardware.egl = emulation ← 非硬件 EGL 实现
ro.boot.qemu.vsync = 60 ← 模拟器给的 vsync 频率
adb shell getprop | grep -iE "sf\.|surface_flinger|vsync|phase|present"
adb shell dumpsys SurfaceFlinger | grep -A8 "app phase:"
三条读数合起来把身份钉死在模拟器上:ranchu、emulation、qemu 三个词一出现,下面所有数字就都属于模拟器参考实现,不是显示硬件。这一条对本章尤其重要:既然相位是配置产物,那么"本机配置是什么"就必须跟着读数一起报——否则读者会把"模拟器的默认值"当成"Android 的默认值"。
还有两条与本章直接相关的环境读数,先在这里登记:
debug.sf.vsync_reactor_ignore_present_fences=true—— 与 ep09 的封条一致(present fence 那条采样退路被显式关掉);debug.sf.force_client_composition=1—— 存在于属性表却不生效:同一时刻物理屏仍是usesDeviceComposition=true / usesClientComposition=false、层级仍是composition=DEVICE (2),与 ep08 §7.5 的登记一致。后面所有读数都不受这个遗留属性影响,这一点在 §6.3 会再核一次。
本机的属性全貌(14 条 sf 相关属性)留到 §4.4 逐条摆开——那里才是"三个偏移属性全缺席"这句话该出现的位置,因为它要跟官方那张属性映射表对着看。
二、三路信号与"相位"这个词
概念清楚了,下面进入定义。这一章要做的只有一件事:把"相位"这个词钉死,因为后面三章全都在用它。
2.1 相位差:两路同频波形错开 θ
先看看"相位"这个词。它在日常语言里有歧义(有人会说"相位就是时间偏移",有人会说"相位是角度")。最不容易歧义的用法是:两路同频的波形,其中一路相对另一路平移了多少时间。
这个结构用一张通用图就能说清——拿它跟显示一点关系都没有:
边界要再说一遍:这张图画的是两路正弦波的相位差,跟显示没有关系。本篇借它,是因为"同频、只差一个平移量"这个结构,与 vsync 三路信号是同构的——而 Commons 上没有一张画 Android 三路 vsync 相位差的图(阶段 2 检索了 4 类类目、8 组关键词、候选池 382 条,带 "vsync" 的文件只有 3 条且领域都不符:两条讲的是模拟视频信号的场同步结构,一条讲的是 tearing 设置对照,详见 material-inventory.md §三)。
从这张图上要带走两个词:周期(相邻两次之间隔多久)与相位差(这两路之间平移了多少)。后面全篇只用这两个词。
2.2 三路信号:官方那句"same period and offset phase"
官方把偏移的结果写成一句话,这句话里每个词都值得读:
"The result of a VSync offset is three signals with the same period and offset phase: -
HW_VSYNC_0— Display begins showing next frame. -VSYNC— App reads input and generates next frame. -SF_VSYNC— SurfaceFlinger begins compositing for next frame."
来源:VSync(AOSP)
"same period" 说的是三者共用同一个周期——它们不是三种节拍,而是同一根节拍被切了三份。"offset phase" 说的是三者彼此之间有一个固定的平移。合起来:这三路信号的频率完全一样,差别只在"这一次比基准早或晚多久"。
三路各自服务谁,官方也逐条写明了:
| 信号 | 服务谁 | 官方原话 |
|---|---|---|
HW_VSYNC_0 |
显示控制器 | "Display begins showing next frame." |
VSYNC |
应用 | "App reads input and generates next frame." |
SF_VSYNC |
SurfaceFlinger | "SurfaceFlinger begins compositing for next frame." |
这张表要与 ep09 的读数接起来看:ep09 在设备上印出的三条回调条目是 sf: / app: / appSf:——前两条正对应这里的 SF_VSYNC 与 VSYNC,第三条 appSf 是 Android 13 起新增的一路(它的存在是为了消掉旧设计里"应用与 SF 共用某一路"带来的歧义)。官方文档说三路,设备上也印着三路,但两边的三路不是一一对应的同名关系——一条是 HW_VSYNC_0(硬件侧,不进 dump),另两条是软件侧、其中还有一条是后补的。
2.3 周期与相位:把两个词钉死
在往下走之前,把两个词各写一句定义(后面全篇只用这两句):
- 周期(period):相邻两次 vsync 之间隔多久。本机是
16,666,666 ns(60 Hz),真机那份 dump 是11,111,111 ns(90 Hz)。 - 相位(phase)/ 相位偏移(phase offset):某一路信号相对基准(
HW_VSYNC_0)平移了多少时间。官方明写它是"offset in nanoseconds afterHW_VSYNC_0, default to zero (if not set), and can be negative"。
三个性质值得单独记住:
- 单位是纳秒,因为"一两个毫秒就有感"(§1.2);
- 不设就是 0——所以"属性缺席"本身就是一个有意义的状态(§4.4 会看到本机正是这个状态);
- 可以是负的——负相位不是错误,是"比基准更早"。§6.1 那份真机 dump 里
early SF phase = −5488889 ns就是一个正常的负值。
第 1 条为什么值得展开一句:本机那一档的相位是 1,000,000 ns,也就是恰好 1 毫秒;真机 early 档的 app 相位是 133333 ns,约 0.133 毫秒。这两个数量级说明"相位"是一个需要纳秒精度的量——用毫秒描述它等于什么都没说。读数时记住这个量级:看到"相位 1"这种没有单位的写法,基本可以判定它没在讲这件事。
参数符号的约定也写在这里,后面不再重复:V 表示某一刻硬件 vsync 的绝对时刻;V − x 表示"比它早 x";V + x 表示"比它晚 x"。相位为正 = 排在基准之后,为负 = 排在基准之前。
三、DispSync 怎么把偏移造出来
上一章把"相位差"这个词钉死了。这一章回答一个更实的问题:这个相位差是谁造出来的? 答案在 ep09 §5.1 已经露过一次——DispSync。但那一篇只讲"它是软件锁相环",没讲"偏移是怎么加在它上面的"。
3.1 一个软件锁相环:偏移是它输出的一部分
OK,先把官方对 DispSync 的定义原样引一遍,重点看第三句:
"DispSync maintains a model of the periodic hardware-based VSync events of a display and uses that model to execute callbacks at specific phase offsets from the hardware VSync events."
"DispSync is a software phase-lock loop (PLL) that generates the
VSYNCandSF_VSYNCsignals used by Choreographer and SurfaceFlinger, even if not offset from hardware VSync."
来源:VSync(AOSP)
第一句是本章的题眼:DispSync 干的事不是"把硬件 vsync 原样转发",而是按模型执行回调,并且这个执行点带一个相位偏移。第二句补充了一个容易忽略的情况:even if not offset——即使偏移是 0,这两路信号也仍然由软件生成。
翻译成一句话就是:硬件 vsync 只提供一个"参考点",VSYNC 与 SF_VSYNC 这两路是软件按"参考 + 偏移"算出来的。这解释了本篇开头那个反直觉现象的另一半——为什么"改偏移"不需要动硬件:因为要改的那个量,本来就在软件手里。
锁相环本身是个很老的电路概念,它的结构用一句话说就是"拿输出跟参考比,用差值去调输出"。下面这张图是一个通用锁相环框图:
边界照例说清:这张图画的是模拟锁相环(鉴相器 / 环路滤波器 / 压控振荡器),它的方框在本篇里只当"结构"看,里面的电路行为一个字都不用。借它只为说明一件事:"DispSync 是一个锁相环"不是比喻,是字面意义上的同一类结构——都有一个参考、一个输出、一个比较器,以及把差值反馈回去的回路。
3.2 官方给它的三要素:Reference / Output / Feedback
官方把 DispSync 的三要素直接列了出来,这三行就是偏移的"输入、输出、校正"三条边:
"DispSync has the following qualities: - Reference —
HW_VSYNC_0. - Output —VSYNCandSF_VSYNC. - Feedback — Retire fence signal timestamps from the HWC."
来源:VSync(AOSP)
把这三条跟"偏移在哪"接起来看:
- Reference 是硬件基准——所有相位都以它为参照,"offset in nanoseconds after
HW_VSYNC_0"(§2.3)说的就是这件事; - Output 就是那两路带偏移的信号——偏移加在这一步;
- Feedback 是校正回路——它用的是 retire fence 的时间戳("这一帧什么时候真正退下来了")。注意这条回路与 ep09 那台模拟器的关系:本机
mExternalIgnoreFences=1、No Last HW vsync,反馈这一条边在本机是空的,所以模型只能按显示模式的vsyncRate直算周期——ep09 的封条,在这里又出现了一次。
3.3 三档三元组:一组 {相位, 预算} 而不是一个数
好了,到这里还没回答"偏移到底有哪些"这个问题。实际跑在设备上的不是"一个偏移值",而是"三档配置",每档是一组数。
按官方与社区可核的部分,一组配置里至少包含这些量:
| 量 | 含义 | 官方/源码依据 |
|---|---|---|
phase(相位偏移) |
这一路相对 HW_VSYNC_0 平移多少纳秒 |
官方:"offset in nanoseconds after HW_VSYNC_0, default to zero (if not set), and can be negative" |
duration(预算) |
这一路从被唤醒到"必须交付"的时间 | 官方对 schedule() 的注释:回调在 "workDuration + readyDuration" 之前派发 |
minFramePeriod |
这一档适用的最小帧周期 | 现代 VsyncConfiguration 一族(路径见下) |
而档位有三档,官方在 dump 里的字段名就是它们的名字:regular(常规)/ early(提前档)/ GL early(提前且走 GL 合成档)。三档各有一组 app 与 SF 的数——这正是 dumpsys 里那七行的来历:
app phase: 1000000 ns SF phase: 1000000 ns
app duration: 16666666 ns SF duration: 15666666 ns
early app phase: 1000000 ns early SF phase: 1000000 ns
early app duration: 16666666 ns early SF duration: 15666666 ns
GL early app phase: 1000000 ns GL early SF phase: 1000000 ns
GL early app duration: 16666666 ns GL early SF duration: 15666666 ns
HWC min duration: 0 ns
七行、两列、三档——这就是本章要解释的那张表。为什么会有"提前档"?因为有些场景(窗口转场、上一帧走过客户端合成)需要更早唤醒,把多出来的时间留给 SF。而什么时候切到提前档,是第五章的正题。
一处必须标注的"待核":社区源码分析文章里转录的 PhaseOffsets 默认构造值是 vsync_event_phase_offset_ns(1000000),与本机读到的 1,000,000 ns 完全一致。但本轮的检索环境取不到 AOSP 原文件的正文(android.googlesource.com 与 cs.android.com 的直连均失败),所以这一条在正文里只能写成"源码分析文章转录、待核",并把它与本机读数的一致性当作旁证,而不是原文证据。旁证不等于原文——这条纪律在 ep10 尤其要紧,因为整篇都在谈"配置值"。
这一步的意义:到这里,"偏移"这件事的形状才清楚了——它不是"一个可以调的数",而是一张三行两列的班表,每格还有一个预算值。下一章回答这张班表从哪读进来。
四、偏移从哪来:两个属性 + 一个 present offset
三档是三档,但这些值总得从某处读进来。官方把它们映射成了三个系统属性,而这条映射有两层回退——这一章就把这两层讲清,然后用本机读数验证"缺席"这件事。
4.1 官方属性映射:三个名字
好了,官方在 SurfaceFlinger 属性页给出的映射是逐行可查的:
"|
VSYNC_EVENT_PHASE_OFFSET_NS|ro.surface_flinger.vsync_event_phase_offset_ns|" "|SF_VSYNC_EVENT_PHASE_OFFSET_NS|ro.surface_flinger.vsync_sf_event_phase_offset_ns|" "|PRESENT_TIME_OFFSET_FROM_VSYNC_NS|ro.surface_flinger.present_time_offset_from_vsync_ns|"
三条链路各管一件事,把它们并排看就清楚了:
| 配置项 | 属性名 | 管什么 | 官方取值依据 |
|---|---|---|---|
VSYNC_EVENT_PHASE_OFFSET_NS |
ro.surface_flinger.vsync_event_phase_offset_ns |
应用那一路(VSYNC)的相位 |
"set conservatively based on high-load use cases" |
SF_VSYNC_EVENT_PHASE_OFFSET_NS |
ro.surface_flinger.vsync_sf_event_phase_offset_ns |
SF 那一路(SF_VSYNC)的相位 |
同上 |
PRESENT_TIME_OFFSET_FROM_VSYNC_NS |
ro.surface_flinger.present_time_offset_from_vsync_ns |
retire fence 时间戳与硬件 vsync 之间的差 | "based on the display controller and panel characteristics" |
两个相位属性是"排班表",第三个是"对表"——它们的性质不同,下一节讲。
三条链路里有两条是"排班"、一条是"对表",这个区分在读数上会露出来。 两个相位属性的作用对象是信号——它们决定 VSYNC 与 SF_VSYNC 各自排在周期的哪一刻,改动的效果是"三段活错开多少";present offset 的作用对象是时间戳——它只对齐记账用的那个时刻,改动的效果是"误差数字显得多大"。所以三者在 dump 里的形态也不一样:相位值出现在 Scheduler 段那七行里(§5.3),present offset 则出现在 Build configuration 行(§4.3 末尾)。读到哪一个,就知道自己在看哪一类配置。
4.2 两层回退:属性 → ConfigStore HAL
属性读不到的时候会怎样? 官方给了答案:
"The ConfigStore HAL remains in AOSP to support legacy vendor partitions. On devices running Android 10 and higher,
surfaceflingerfirst reads system properties. If no system property is defined for a config item inSurfaceFlingerProperties.sysprop,surfaceflingerfalls back to the ConfigStore HAL."
这一句是本篇最容易被忽略、也最容易读错的一句:
"某个属性读不到"不等于"这台设备没有偏移"。 它只说明第一层没配,值可能来自第二层(ConfigStore HAL),也可能来自编译期默认。
这条口径直接决定了 §4.4 该怎么写——本机属性全缺席,我们能说的只是"属性这一层是空的",不能因此断言"本机的偏移是 0"或"本机没有偏移配置"。要断言后者,得把 ConfigStore 也读出来,而那一步在本机读不到(§7.4 会登记它)。
4.3 第三个偏移:present offset 是"对表"而不是"排班"
第三个属性容易被当成"第三个相位",但官方对它的说明指向另一件事:
"The signal timestamp of retire fences must match HW VSync, even on devices that don't use the offset phase. Otherwise, errors appear to be more severe than they are. Smart panels often have a delta where the retire fence is the end of direct memory access (DMA) to display memory, but the actual display switch and HW VSync is some time later."
来源:VSync(AOSP)
这段话讲的是一个测量口径问题:retire fence 表示"DMA 写显存结束",而屏幕真正切换(硬件 vsync)在稍后某刻。如果不用 present offset 把两者对齐,误差会被算大——"errors appear to be more severe than they are"。
所以三个属性的分工是:
| 属性 | 作用 | 不用它会怎样 |
|---|---|---|
| 两个相位属性 | 把三路信号错开 | 三段挤在一起,延迟回到"至少两帧" |
| present offset | 把 retire fence 时间戳与硬件 vsync 对齐 | 误差数值偏大(不是真的更大,是口径错位) |
这条区分在本篇的读数里也有对应:本机的 Build configuration 行里写着 sf PRESENT_TIME_OFFSET=0——它是"设成 0",不是"没这个配置项"。这两者在 dump 里的形态不同,§6.3 会把两种形态摆在一起对比。
这条链上有两个时刻,而它们本来就不相等:一个是"DMA 写完显存"(retire fence 给出的那个时间戳),一个是"屏幕真正换了一张画面"(硬件 vsync 对应的那一刻)。面板厂商的屏幕通常有这么一个差值,而 present offset 的存在就是为了把这个差值写进配置——让记账时间戳与硬件 vsync 对齐。如果不对齐,后果不是"延迟变大了",而是"误差看起来比实际更严重":官方原话是 "errors appear to be more severe than they are"。这也是本篇反复强调"它是口径问题不是排班问题"的原因:把 present offset 当成"第三个相位"去调,方向就错了。
4.4 本机:三个属性全缺席
好了,现在把本机的属性摆开。这是官方那张映射表的反面验证:
adb shell getprop | grep -iE "sf\.|surface_flinger|vsync|phase|present"
adb shell dumpsys SurfaceFlinger | grep -m1 "Build configuration"
三条结论,逐条写清:
- 两个相位属性缺席(
ro.surface_flinger.vsync_event_phase_offset_ns与..._vsync_sf_event_phase_offset_ns都不在属性表里)。按 §4.2 那一层回退,这只说明"属性这一层没配",不说明"没有偏移"。 本机实际印出来的三档相位是1,000,000 ns(§5.3),它与社区转录的PhaseOffsets默认构造值一致——这条一致性是旁证,不是原文证据。 - present offset 在 dump 里以另一种形态出现:
Build configuration行里写着sf PRESENT_TIME_OFFSET=0。它是"设成 0",不是"缺席"——两种形态必须分开写,这正好是 §4.3 那条"排班 vs 对表"的区分在读数上的投影。 - 五条运行期覆盖属性也全缺席:
debug.sf.use_phase_offsets_as_durations、debug.sf.early_*四条、debug.sf.late.*两条——逐条查过,全是空串,脚本与输出都落盘在ep10_raw/props_phase_factory.txt。这第二条证据很关键:它说明本机三档连运行期覆盖都没有,三档值只能来自编译期默认。
这就把"本机三档相同"这件事的成因锁定了:不是"设备不支持切档",而是两档本来就没有被分开配过。而"没分开配"会导致什么后果,是第五章的正题。
五、切档:early / earlyGL / late
上一章说清了"值从哪来"。这一章回答"什么时候换值"——这是偏移这套机制里唯一带"能动性"的部分。
5.1 两条触发条件:事务标 EARLY,或上一帧用了 RenderEngine
言归正传,先看源码。VSyncModulator 这个类的首注释就把自己的职责写在脸上了:
/* Modulates the vsync-offsets depending on current SurfaceFlinger state. */
来源(可访问的 gitiles 镜像,frameworks/native):
VSyncModulator.h
"depending on current SurfaceFlinger state" 就是要切档的信号。 而切档的入口只有两个函数:
void setTransactionStart(TransactionStart transactionStart) {// An early transaction stays an early transaction.if (transactionStart == mTransactionStart || mTransactionStart == TransactionStart::EARLY) {return;}mTransactionStart = transactionStart;updatePhaseOffsets();}
void setLastFrameUsedRenderEngine(bool re) {if (re == mLastFrameUsedRenderEngine) return;mLastFrameUsedRenderEngine = re;updatePhaseOffsets();}
两条触发条件就此清楚了:
| 触发 | 什么时候发生 | 为什么值得提前唤醒 |
|---|---|---|
事务被标成 TransactionStart::EARLY |
窗口转场 / 动画开始时(提前提交一个事务) | 转场那几帧 SF 的活变重,需要更早拿到时间 |
| 上一帧用了 RenderEngine | 发生了客户端合成(CLIENT 层 → GPU 参与) |
GPU 合成比硬件叠加慢,同样需要更长的预算 |
两条都指向同一件事:当"下一帧的活预计更重"时,提前唤醒。——这与官方那句"偏移是按高负载场景保守设定"(§1.2)在机制上对上了:常态用 regular 档省时间,重载切到 early 档补余量。
下面这张表把两条触发条件与它们各自的"重负载来源"并排摆开,它要回答的是"为什么这两件事会被选作切换信号":
顺带一个本机读数:这两个触发条件在本机都观察不到切换,因为本机两档被配成同值(下一节讲)。但另一个更靠上游的开关可以读——工厂挑哪一支实现:
adb shell getprop debug.sf.use_phase_offsets_as_durations
adb shell getprop | grep -iE "phase_offsets|use_phase|early|late"
面板上的两条要点:① debug.sf.use_phase_offsets_as_durations 缺席 → 取默认 false,按社区转录的工厂逻辑走 PhaseOffsets 那一支(不是 WorkDuration 那一支);② 五条 debug.sf.early_* / debug.sf.late.* 也全缺席。第 ① 条有一个交叉验证:本机 dump 里印的是 app phase: / SF phase: 这种相位形态,而不是只印预算的形态——形态与分支对得上。(分支判定本身来自第三方转录,标待核;形态一致只是旁证。)
5.2 early == late 时,切档被短路
这一节是本章最硬的一处源码证据,只有一行,但它解释了本篇开头那个现象:
void updatePhaseOffsets() {// Do not change phase offsets if disabled.**if (mEarlyPhaseOffset == mLatePhaseOffset) return;**…}
逐字读这三行:
if disabled是注释,判据是"两个档位相等"——在这个实现里,"禁用"不是另设一个开关,而是"把两档配成同一个值";- 相等就
return,updatePhaseOffsets()后面那些setPhaseOffset(...)一行都不会执行; - 因此相位从启动到关机都不会切——哪怕窗口在转场、哪怕上一帧走了客户端合成。
把这条与 §4.4 的读数合起来,就得到本篇的第二个判据:
本机三档相位完全相同,不是"设备不支持切换",而是"两档被配成同值、切档被短路"。 前者是能力问题,后者是配置问题——读数长得一样,成因完全不同。
这条判据还有一个副产品:它解释了为什么"未配置的设备上,early 档的存在感是零"。档位在 dump 里印着,机制在源码里写着,但在这台设备上从来不会触发。
5.3 本机三档原样:六个 1,000,000 ns
好了,规则讲完,看设备。本机的 Scheduler 段原样如下(它与 §3.3 引的那张表是同一段,这里换一个读法:只看相位,不看预算):
三个读法:
- 相位:六个数字全同(三档 × app/SF = 6 个
1,000,000 ns)。按 §5.2,这意味着updatePhaseOffsets()在第一句就返回了——本机从启动到现在没有切过一次档。 - 预算:
SF duration比app duration少 1 毫秒(15,666,666 对 16,666,666)。这一条与 ep09 的三处读数一致(ep09 §1.2 的预算表、§2.2 的workDuration差、§7.3 的七组读数),是跨篇的第三方交叉验证。 HWC min duration: 0 ns:这台 HWC 的最小耗时估计是 0——它的意思是"设备侧没有给任何耗时下界",不是"送显不花时间"。
还有一处要指出来的空缺:本机这一段的末尾没有 present offset 与 VSYNC period 两行(§6.3 会把真机的同段摆出来对比)。同一段 dump,不同 Android 版本印的行数不同——读 dump 时不能假定行都在。
六、真机 vs 本机:三档的两种世界
前五章讲的是规则。这一章把两台设备的读数摆在一起——这是全篇最直观的一处对照,也是"偏移是配出来的"这句话的最终证据。
6.1 真机那份 dump:三档互不相同,还带负值
先交代来源,因为它决定了这一节的读法必须比别处更谨慎:
来源:一份公开的 bugreport 粘贴(
dumpsys SurfaceFlinger的Scheduler段),文件头dumpstate: 2021-10-24 20:56:07,机型 Pixel 6(oriole)、Android 12(SD1A.210817.019.B1)、user构建,主屏 1080×2400,当前 90 Hz。落盘:ep10_raw/ext_pixel6_phase.txt。
它是别人的 dump,不是本机跑出来的——所以本篇的呈现方式是逐字引用 + 图注标来源,不渲染成"本机面板"。原文如下:
app phase: 6233333 ns SF phase: 611111 ns
app duration: 16600000 ns SF duration: 10500000 ns
early app phase: 133333 ns early SF phase: -5488889 ns
early app duration: 16600000 ns early SF duration: 16600000 ns
GL early app phase: 133333 ns GL early SF phase: -5488889 ns
GL early app duration: 16600000 ns GL early SF duration: 16600000 ns
HWC min duration: 0 ns
present offset: 0 ns VSYNC period: 11111111 ns
四个差异,逐条对照本机(本机那一列在 §6.2 给原样):
| 对照项 | 真机(Pixel 6 / Android 12 / 90 Hz) | 本机(模拟器 / Android 16 / 60 Hz) |
|---|---|---|
| 三档是否分档 | 三档各不相同 | 三档完全相同 |
| 有没有负值 | 有:early SF phase = −5,488,889 ns |
没有(全为正值 1,000,000 ns) |
app duration 对 SF duration |
16,600,000 对 10,500,000 → 差 6.1 ms | 16,666,666 对 15,666,666 → 差 1 ms |
| 段末两行 | 有 present offset: 0 ns 与 VSYNC period: 11111111 ns |
没有这两行 |
| 刷新周期 | 11,111,111 ns(90 Hz) | 16,666,666 ns(60 Hz) |
四点里最要紧的是第一与第二点:
- 三档各不相同说明这台设备真的配了偏移——regular 档 app 6.233 ms / SF 0.611 ms,early 档 app 0.133 ms / SF −5.489 ms。六个数字没有一个相同;
- 负值出现说明真机把 SF 那一路放到了基准之前——按 §2.3 的定义,"负相位不是错误,是比基准更早"。这条官方性质在真机上得到了实例验证,而本机永远看不到(因为本机是默认值 0 附近的整数)。
这一节要带走的一句话:同一份 AOSP 代码,在两台设备上给出了六个完全不同的数字——差别不在代码里,在配置里。这就是本篇的第三个判据:相位数字是"设备指纹"的一部分。
6.2 本机侧:同一段、另一种形态
OK,为便于逐行比对,把本机同段再列一次(与 §5.3 同源):
app phase: 1000000 ns SF phase: 1000000 ns
app duration: 16666666 ns SF duration: 15666666 ns
early app phase: 1000000 ns early SF phase: 1000000 ns
early app duration: 16666666 ns early SF duration: 15666666 ns
GL early app phase: 1000000 ns GL early SF phase: 1000000 ns
GL early app duration: 16666666 ns GL early SF duration: 15666666 ns
HWC min duration: 0 ns
本机这一段的"缺"比"有"更值得写:
- 六个相位全是
1,000,000 ns→ 切档被短路(§5.2 的源码判据); app duration与SF duration差 1 ms → 与 ep09 三处交叉验证一致;- 段末没有
present offset与VSYNC period两行 → 不能假定"这两行在任何设备上都有";本机的 present offset 只能从Build configuration行的sf PRESENT_TIME_OFFSET=0读到(§4.3)。
这张面板上"缺"的三层含义要分开看,不能笼统写成"本机没配"。 第一层是相位值缺席——两个相位属性不在属性表里,所以六个相位只能来自编译期默认(§4.4);第二层是段末两行缺席——present offset 与 VSYNC period 这两行在 Android 16 的这段输出里根本没有印,所以它们不是"值为 0",而是"没有这一行";第三层是切档从未发生——三档值全同意味着 updatePhaseOffsets() 在第一句就返回(§5.2),所以"当前生效的是哪一档"这个问题在本机没有答案。三层里只有第一层是"没配",另两层是"形态与行为不同",混着写就会得出错误的结论。
6.3 三条口径警示
把这一章变成可引用的结论之前,有三条口径必须钉住:
警示一:真机读数只能用"引用"的姿态出现。 那份 dump 是第三方公开材料,不是本机的实验。凡是从它取的数字,图注与正文都必须标来源;不得把它渲染成本机面板、不得把它与本机读数放进同一个统计(本机三档是默认值,真机三档是配过值,两组的"均值"没有意义)。
警示二:present offset 的两种形态要分开写。 "属性缺席"(本机两个相位属性)与"设成 0"(本机 PRESENT_TIME_OFFSET=0)在 dump 里长得不一样,在含义上也不一样——前者是"这一层没配",后者是"配成了 0"。混着写会让读者以为"没配就是 0"。
警示三:本机的 force_client_composition 不生效,读数不受它影响。 这一条要现场复验,因为它是遗留属性:
adb shell getprop debug.sf.force_client_composition # → 1
adb shell dumpsys SurfaceFlinger | grep -E "usesDeviceComposition|usesClientComposition"
复验结果是物理屏仍 usesDeviceComposition=true / usesClientComposition=false,层级仍 composition=DEVICE (2)——属性存在但开关没生效,与 ep08 §7.5 那条登记完全一致。所以本章与第八章所有读数都不受它影响;这条复验也是"遗留属性要现场核"的一个示范。
七、在设备上怎么读出来
机制、配置、切档、对照都讲完了。这一章回答"在设备上到底读什么"——四个读面,加一条层名输入的坑。
7.1 第一个读面:Scheduler 的相位段
先看看读偏移的第一个面——它就是前面反复引的那七行。它的入口是一段 dumpsys:
adb shell dumpsys SurfaceFlinger | grep -A7 "app phase:"
这段的输出就是 §3.3 / §5.3 / §6.2 引的那七行,字段含义逐项如下:
| 字段 | 含义 | 单位 |
|---|---|---|
app phase / SF phase |
常规档下两路的相位(相对 HW_VSYNC_0) |
ns |
app duration / SF duration |
常规档下两路的预算 | ns |
early app phase / early SF phase |
提前档(事务标 EARLY)下的两路相位 | ns |
early app duration / early SF duration |
提前档下的两路预算 | ns |
GL early app phase / GL early SF phase |
提前档且走 GL 合成时的相位 | ns |
GL early app duration / GL early SF duration |
同上,预算 | ns |
HWC min duration |
这台 HWC 的最小耗时估计 | ns |
这一个面的价值在于"三档一屏可读"——不用切场景、不用抓两次,一次 dumpsys 就能把六个相位与六个预算全拿到。代价是:读到的都是登记值,不是"当前正在生效的是哪一档"。
7.2 第二个读面:唤起点算式与三条目
第二个面要回答"登记的相位与预算,翻译成时间轴是什么样"。 它需要两条命令配合:
adb shell dumpsys SurfaceFlinger | grep -A12 "VsyncDispatch:"
这段里每条目有两行关键信息:上一行是方括号里的三个时刻(wake up / deadline / for vsync),下一行是登记参数(workDuration / readyDuration)。把官方那条算式回代一遍,就能把登记值翻译成时间轴位置:
| 量 | 算式(官方注释) | 代入 app: |
代入 sf: |
|---|---|---|---|
| 唤起点 | vsync − (workDuration + readyDuration) |
V − 32.34 ms |
V − 15.67 ms |
| 截止点 | vsync − readyDuration |
V − 15.67 ms |
V − 0.00 ms |
两行合起来给出一条本篇最要紧的换算:
同一个 vsync 事件上,应用比 SF 早约 16.67 ms 被唤醒——正好一个刷新周期。 而两条的
workDuration只差 1 毫秒。
这正是 ep09 那处"差 1 毫秒"的完整版本(见 §10.1 的逐条核对):登记参数之差(1 ms)与行为之差(16.67 ms)是两个量,前者写在 dump 里,后者要按官方算式算出来。算式可核,数值可推——但它是"算出来的",不是"量出来的",这一条必须在正文里标明。
方括号里那三个时刻的读法也补一句(ep09 §7.2 给过一条 sf: 的例子,这里换成通式):
sf: [wake up in 7.19ms deadline in 22.85ms for vsync 22.84ms from now]
| 字段 | 含义 | 与算式的关系 |
|---|---|---|
wake up in |
打算叫醒的时刻 | vsync − (workDuration + readyDuration) |
deadline in |
必须交付的截止 | vsync − readyDuration |
for vsync |
这一轮服务的是哪一次 vsync | 上两行的参照点 V |
三行互相能对上,就是"这一轮派发照登记执行"的证据;对不上说明这一轮被改过(ep09 §7.2 把这条写成了一条"校验原理",本篇沿用)。本机这一轮三行是自洽的:22.85 − 7.19 = 15.66 ms ≈ workDuration 15.67 ms(sf: 的 readyDuration 为 0,所以截止点就落在 vsync 上)。
7.3 第三个读面:--latency 的结构与三种层名输入
第三个面是唯一能把"相位"换回"时间"的读面——--latency 给的是某一层的逐帧三元组:
adb shell dumpsys SurfaceFlinger --latency "com.google.android.apps.nexuslauncher/com.google.android.apps.nexuslauncher.NexusLauncherActivity#329"
结构两条:首行是刷新周期(本机 16666666),其后是127 行三元组;三元组的排列方向是旧 → 新(实测相邻行时间戳单调递增比例 100%,递减 0%)。三元组三个字段的含义见第八章。
方向这一条为什么要实测:--latency 的输出是一段环形缓冲,文档里没有明写它是"最新在前"还是"最旧在前"。两种方向读出来的结论完全不同——把最新一行当最旧一行,算出来的间隔会整体反号。本轮的判法是看时间戳的单调性(127 行的相邻时间戳 100% 递增、0% 递减),方向就此钉死为"旧 → 新",第一行是这段窗口里最早的一帧。这条判法建议直接用:拿到任何一段逐帧表,先看时间戳往哪边走,再谈结论。
但这条命令有一个必须写进"动作与判据"的坑——三种层名输入会得到三种完全不同的返回:
| 输入 | 返回 | 说明 |
|---|---|---|
完整层名(含斜杠与 #329) |
128 行(1 行周期 + 127 行三元组) | 正常路径 |
壁纸层名(Wallpaper BBQ wrapper#69) |
也是 128 行,但其中 126 行是全零 | 层"活着但平时不产帧" |
RequestedLayerState{…} 或不存在的层名 |
1 行(只有周期) | 认不出来 |
结论是一句判据:"认不出来"与"没产帧"是两回事——前者退化成一行、什么都读不到,后者给你一张零表、能读出"这一层活着但没在动"。这条判据同时修正了 ep09 §9.2 的一处原话(那一篇把壁纸层也归进了"只回一行周期值"),已在 ep09 的修正批次 20260929-203527-ep09fix 改正并重新上线。
7.4 第四个读面:读不到什么
好了,本篇有一半结论是"读不到什么",单独列一面试试:
| 想读什么 | 结果 | 原因 |
|---|---|---|
| 三个偏移属性的实际生效值 | ❌ | 两个相位属性缺席;按官方说明会回退 ConfigStore HAL,而 ConfigStore 在本机也读不到(getprop 与 dump 都没有它的段) |
| 相位是谁、在哪一刻写进去的 | ❌ | dump 只印数值(三档相位),没有写入者、没有时间戳;setPhaseOffsets 的调用记录不可读 |
| 档位切换的历史 | ❌ | 本机三档相同 → 源码短路(§5.2)→ 根本没有切换发生,也就无从观察 |
--latency 里 desired 字段的填法 |
❌ | 记到了两种情形(静置整表非零、滑动最早一段为 0),但官方没有文档说明它怎么被填——所以第八章只报现象、不解释算法 |
| 真机三档在同一时刻的关系 | ➖ | 需真机:那份 dump 是静态快照,看不出三档之间的切换过程 |
| 偏移改动后的受控对比 | ➖ | 需真机 + 重刷:两个 ro.* 属性是只读的,运行期改不了;要做受控实验只能改 BoardConfig.mk 重刷 |
| ARR / 多刷新率下的相位 | ➖ | 需真机支持 ARR(ep11 的正题);本机只有 60 Hz 一档 |
| 逐帧的 vsync 时序 | ➖ | 需真机 + root:Perfetto frametimeline 数据源需采集;本机 user 构建无 root |
为什么要为"读不到"单开一面:因为本篇的主题是配置,而配置的第一层证据(属性)在本机是空的,第二层(ConfigStore)也读不到。把这条说明,比多给三个漂亮读数更有价值——它决定了本篇哪些结论可以引用、哪些只能被当作"一台模拟器的现场记录"。
八、一次"三步舞"的量化
前面七章都在讲"偏移是多少"。这一章换一个问法:三个时刻之间,实际隔了多久? ——这一次是真的量出来的。
8.1 实验设计:只读一个面,分两个窗口
OK,先说做法:同一台设备,同一个读面(--latency 的桌面层三元组),取两个窗口:
# 静置窗口:不触摸、不录屏
adb shell dumpsys SurfaceFlinger --latency "com.google.android.apps.nexuslauncher/com.google.android.apps.nexuslauncher.NexusLauncherActivity#329"
# 滑动窗口:先连续 swipe 制造动画,再取同一条命令
adb shell input swipe 540 1800 540 600 120 # 连续执行若干次
三个时刻的定义(三元组三个字段):
| 字段 | 含义 | 谁写的 |
|---|---|---|
desiredPresentTime |
这一帧"想要"在哪一刻上屏 | 应用 / SF 提交时的期望值 |
frameReadyTime |
这一帧"做好"的时刻(可以交出去了) | 生产侧 |
actualPresentTime |
这一帧"真正"上屏的时刻 | SF 的记账 |
两个间隔就是"三步舞"的量化:做好 → 上屏(第 2 与第 3 步的合计)、想要 → 上屏(第 1 与第 3 步的合计)。
误差口径先声明:--latency 的字段是纳秒整数,统计误差来自采样窗口本身——静置窗口里大量行是全零或重复(那一层没产帧),所以可算行数远小于 127;两个窗口的可算行数会分别报出(下面各面板上都有 n=)。
可算行数为什么值得单列一个数:这段环形缓冲记的是这一层最近 127 次提交,而不是"最近 127 个刷新周期"。如果这层平时不产帧,这 127 行会横跨很长一段时间——本机静置窗口就是这样(相邻上屏间隔中位约 60 秒)。所以 n= 这个数有两层含义:一是统计样本量,二是"这一层在这段时间里到底动了几次"。把它一起报出来,读者才能判断"6.172 ms"这个中位数是建立在多密的样本上。
8.2 两个窗口的结果
静置窗口(ep10_raw/latency_idle.txt):
滑动窗口(ep10_raw/latency_swipe.txt):
两张面板放在一起,三个结论:
- "帧就绪 → 真正上屏"这个间隔不是常数:静置中位 6.172 ms、滑动中位 15.963 ms。滑动时的这个间隔已经接近一个整周期(16.667 ms)——意思是一帧做好之后,平均要等将近一整个刷新周期才出现在屏上。
- 静置时这一层几乎不产帧:相邻帧上屏间隔中位 59,997.876 ms ≈ 60 秒(127 行里只有少数几行间隔是 7–22 ms,其余都是几十秒级)。这是"没人要就不派发"的又一个直接读数——ep09 量的是应用侧 vsync 计数(静置 0.65 Hz),本篇量的是某一层真正产帧的间隔(约每分钟一帧)。
desired字段要谨慎用:在desired > 0的那部分行里,静置与滑动都是 100% 的帧"上屏晚于想要"。但官方没有文档说明这个字段怎么被填,所以这条只作为现象登记(§7.4 已把它列进"读不到"),不解释它的算法。
8.3 三个时刻、两个间隔
把上面两组数合成一张图,就是本篇标题里那个"三步舞"的量化:
这张图要带走的一句话:"三步舞"里真正被量出来的,是"帧做好之后还要等多久"这一个间隔——它把"应用 → SF → 屏幕"这三步里后两步的合计变成了一个可复算的数。而它随负载从 6.172 ms 变到 15.963 ms,说明这个间隔本身也受 SF 的负载影响。
8.4 间隔随负载变化
好了,最后把两个窗口的同一个量直接并排——只改一个变量:有没有人在动:
四条要点:
- 就绪 → 上屏:6.172 → 15.963 ms(约 2.6 倍);
- 相邻帧间隔:约 60 秒 → 19.869 ms(从"几乎不产帧"变成"按周期产帧、但比 16.667 ms 略慢 19%);
- 两个量的性质不同:前者是"帧做好之后还要等多久",后者是"这一层多久产一帧"。混着读会把"SF 变忙"读成"应用变慢";
- 这条对照只有一个变量:设备、命令、读面全都固定,只有"有没有人在动"变了——所以这两组数的差可以归因到负载。
一条与 ep09 接得上的话:ep09 §8.2 量的是派发速率(静置 0.65 Hz / 滑动 57.57 Hz),本篇量的是帧就绪到上屏的间隔(6.172 / 15.963 ms)。两者是同一件事的两端:节拍派得越勤,帧就绪后等的时间越稳定地趋向一个周期;节拍几乎不派时,这一层干脆不产帧。
九、偏移调坏了会怎样
前八章把"偏移是什么、怎么读"讲完了。这一章收一收:如果这个值配得不对,会发生什么。 先说清这一章的边界——本机做不了这个实验。
9.1 三种坏法
先看看这一章能做什么。先说清为什么这里没有本机读数:两个相位属性是 ro.*(只读),运行期改不了;要改就得改 BoardConfig.mk 重刷系统。本机做不到,所以这一章的依据是官方原文 + 一份真机 dump + 通用时序图,没有本机实验——这一点如实写明,不用自绘去冒充实验。
按官方给的两条边界(§1.2 的"减少可用时间"与 §2.3 的"可正可负"),"配坏了"只有三种形态:
| 坏法 | 现象 | 官方依据 | 本质 |
|---|---|---|---|
| 过小(趋近 0) | 三段挤在同一时刻开工,延迟回到"至少两帧" | "The latency is at least two frames."(§1.1) | 没有买到延迟改善 |
| 过大 | 应用与 SF 的可用时间被压掉,"出错概率变大" | "VSync offsets reduce the time available for app and composition, providing a greater chance for error."(§1.2) | 买到了低延迟,但代价过高 |
| 符号搞反(该更早的变成更晚) | 三段的先后关系被颠倒,本来该错开的变成互相挤 | 官方:"offset in nanoseconds after HW_VSYNC_0 … and can be negative"(§2.3) |
负值本身合法,但错的方向不合法 |
这三种坏法可以合成一句判据:
偏移不是一个"越大越好"或"越小越好"的旋钮,而是一次"拿可用时间换延迟"的交易。 官方的做法是"按最坏情况保守设定"(§1.2)——宁可常态有闲置,也不要重载时崩。 真机那份读数(regular 档 app 6.233 ms / SF 0.611 ms、early 档反过来把 SF 拉到 −5.489 ms)正是"不同档位做不同交易"的实例。
9.2 一个通用对照:错开多少才算"错开"
第三种坏法(符号与方向)用一张时序图最好说——看同一个周期被切成几段、边沿错位之后会怎样:
边界照例说清:这张图画的是数字电路的时钟偏斜(同一个时钟到不同点的到达时间差),跟显示没有关系。借它的理由只有一个:"同一个周期被切几段、每段的边沿按固定量错开"这个结构,与 vsync 三路信号是同构的——所以"边沿偏了会发生什么"这件事,在这张图上看得比在 vsync 上清楚。
从这张图要带走的一句话:错开不是"随便错",而是"错多少、往哪边错都有约定"。 vsync 偏移的约定写在官方那两句里(保守取值、可正可负),执行者是设备配置。
9.3 从三条官方句子推出来的三条检查
这一章没有本机实验,那就把它变成可执行的检查。上面用到的三条官方原文,各自能推出一条可以拿去问设备的问题——这三条也就是本篇"能带走的东西"在坏法这一侧的形式:
| 官方那句 | 推出来的原理 | 可以拿去问设备的问题 |
|---|---|---|
| "The latency is at least two frames." | 三段的错位是流水线结构决定的,偏移只改"在哪一刻开工",不改"要经过几段" | 三档相位是否都趋近 0?若都趋近 0,延迟就退回到两帧以上——这时再调其他东西都没用 |
| "set conservatively based on high-load use cases" | 取值原则是按最坏情况留余量,不是按平均负载 | 常规档给应用留的预算,是否够它在最坏情况下跑完?(本机 app duration 16.67 ms,正好一个周期) |
"offset in nanoseconds after HW_VSYNC_0 … and can be negative" |
允许负值意味着基准点前后都可排,方向本身就是配置的一部分 | 三档之间的先后关系是什么?谁更早、谁更晚?(真机 early 档把 SF 拉到 −5.489 ms,就是"更早") |
三问的共同点:它们都不需要改设备,只需要读一次 dump(§7.1 的第一个读面)。官方原文在这里的作用不是"背诵",而是"把一批数字变成三个判断"。
这一章与第八章的分工也要说清:第八章量的是行为(帧就绪到上屏隔了多久),本章谈的是配置(这些数字该怎么排)。两者都在讨论"时间",但一个可实验、一个只能读——想对偏移做受控实验,得有一台能重刷 BoardConfig.mk 的真机(§7.4 已把这一条列进"需真机")。
十、四处说法与四项动作
机制、配置、切档、对照、读面、量化、坏法都讲完了。最后一章:把四处流传很广的说法逐条核一遍,再给四组可以立刻跑的命令。
10.1 四处流传很广的说法,逐条核
OK,机制与实测都讲完了,最后把四处说法逐条对一遍。挑这四条的标准是:它们都能被本篇已有的读数或原文直接判定,不需要引入新证据。
| 说法 | 核对结果 | 依据 |
|---|---|---|
| "App 与 SF 总是同时收到 vsync" | 不成立 | 官方原话是 "three signals with the same period and offset phase"——同周期、有相位差;真机 dump 里三档六个数字无一相同,本机也有 1 ms 的非零相位(§2.2、§6.1) |
| "偏移越大,延迟越低,所以越大越好" | 不成立 | 官方明文 "reduce the time available for app and composition, providing a greater chance for error";取值原则是 "set conservatively based on high-load use cases"(§1.2) |
"workDuration 就是它提前多久被唤醒" |
不成立 | 官方注释:回调在 "workDuration + readyDuration" 之前派发。本机两条的 workDuration 差 1 ms,而唤起点差约 16.67 ms(一个周期)(§7.2) |
| "三档相同说明这台设备不支持切档" | 不成立 | 源码:if (mEarlyPhaseOffset == mLatePhaseOffset) return;——两档相同则切档被短路。是"配置相同",不是"能力缺失"(§5.2、§5.3) |
第四条值得多说一句:它是最容易被"看起来像"骗过去的——三档印在同一段里、机制也写在源码里,看上去像"这三档随时会切"。但本机两档被配成同值,updatePhaseOffsets() 在第一句就返回了。 读数长得一样,成因完全不同:"不支持"是能力问题,"短路"是配置问题。
把四条说法做一次二次归类,它们其实只犯两种错。 第一种是"把配的当成量的":第 1 条与第 4 条都属于这一类——它们把 dump 里印出来的数字当成设备的客观事实,而没问这个数字是怎么来的(一个是"配出来的相位",一个是"被短路掉的档位")。第二种是"把登记当成行为":第 2 条与第 3 条属于这一类——它们把参数表里的值直接当成运行时会发生的事(一个是"偏移量",一个是"唤起点")。两种错法的共同解法只有一句:先问"这个数是谁写的、什么时候生效",再读它的值。 这也正是本篇 §0.4 那三条判据要防的东西。归类的用处在这里体现得很直接:四条说法分开看像是四个知识点,归成两类之后就只剩一条纪律。
10.2 两条口径警示与一处修正登记
本篇实测里出现了两组容易读错的数,各自登记一遍;另有一处跨篇修正也记在这里。
警示一:真机读数与本机读数不能混算。
真机:early SF phase = -5488889 ns ← 配置过的值
本机:early SF phase = 1000000 ns ← 默认值
两组数的来源性质不同:真机那份是某家厂商配过的,本机这份是默认值。把它们放进同一个"平均"没有意义,更不能从本机读数外推"Android 一般是多少"。
警示二:workDuration 差 ≠ 唤起点差。
sf: workDuration 15.67 ms readyDuration 0.00 ms → 唤起点 V − 15.67 ms
app: workDuration 16.67 ms readyDuration 15.67 ms → 唤起点 V − 32.34 ms
登记参数之差是 1 ms,行为之差是 16.67 ms——中间隔着一个 readyDuration。凡是要写"谁比谁早多久"的句子,都必须先回代这条算式,否则会像 ep09 那样把两个量写成一个。
一处跨篇修正的登记:ep09 §2.2 原写"应用比 SF 早叫 1 毫秒",§9.2 原把壁纸层名也归入"--latency 只回一行周期值"。这两处在本篇的复测中被推翻,已在批次 20260929-203527-ep09fix 修正并重新上线(公网验收 11/11)。本篇把修正过程本身当论据:一处是"登记值≠行为值",另一处是"认不出来≠没在动"。
10.3 四项可执行动作与判据
好了,最后给四组命令,每组配一个判据。这四组覆盖了本篇从"确认偏移配了没有"到"量出三步舞的间隔"的完整路径,而且顺序不能换——第 ① 组确认配置层,后面三组才有对象可查。
# ① 确认偏移配了没有(配置层)
adb shell getprop | grep -iE "surface_flinger.*(phase|present)"
adb shell dumpsys SurfaceFlinger | grep -m1 "Build configuration"
# 判据:两个相位属性是否出现;PRESENT_TIME_OFFSET 的值是多少
# 本机:两个相位属性缺席、PRESENT_TIME_OFFSET=0
# ② 三档相位与预算(登记层)
adb shell dumpsys SurfaceFlinger | grep -A7 "app phase:"
# 判据:三档六个相位是否相同
# 相同 → 切档被短路(不是不支持切);不同 → 设备配了分档
# ③ 把登记值翻译成唤起点(算式层)
adb shell dumpsys SurfaceFlinger | grep -A12 "VsyncDispatch:"
# 判据:按 workDuration + readyDuration 回代
# 算出每个条目的唤起点在 vsync 前多久(本机 sf 15.67 ms / app 32.34 ms)
# ④ 量出"帧做好之后还要等多久"(行为层)
adb shell dumpsys SurfaceFlinger --latency "<完整层名含 #NNN>"
# 判据:首行是周期;其后 127 行三元组(旧→新)
# 逐行算 actualPresentTime − frameReadyTime,取中位数
# 本机:静置 6.172 ms / 滑动 15.963 ms
# 注意:给认不出的层名只会回一行周期值(见 §7.3)
第 ④ 组是四组里最有用的一个自检:它把"三步舞"从"看文档猜"变成"逐行减一遍"。而且它有两条独立判据(中位间隔与可算行数)——可算行数太少时说明这个窗口里那一层几乎没产帧,这种错配本身就是值得记的诊断信号(本机静置窗口 127 行里只有 24 行 desired 非零,滑动窗口有 110 行)。
一条口径也重复一次:第 ② 与第 ③ 组读到的都是登记值,第 ④ 组读到的才是行为。两者不能互相代入——这也是本篇与前一篇共用的一条纪律。
10.4 三句结论
好了,把这一篇收成三句话:
第一句:相位偏移是"配"出来的,不是"量"出来的。 同一份 AOSP 代码,本机三档六个相位全是 1,000,000 ns(默认值),真机三档互不相同、还带一个 −5,488,889 ns 的负值。看到相位数字,先问它是配的还是量的;从一台设备的相位外推"Android 一般是多少",是最容易犯的错。
第二句:切档的条件只有两条,而"两档配成同值"会让整段短路。 updatePhaseOffsets() 的第一句就是 if (mEarlyPhaseOffset == mLatePhaseOffset) return;——本机三档相同,不是"不支持切",而是"没配过、于是从来不切"。两条触发条件(事务标 EARLY、上一帧用了 RenderEngine)都指向同一件事:下一帧的活更重时,提前唤醒。
第三句:"三步舞"里能真正量出来的,是"帧做好之后还要等多久"。 本机 --latency 三元组算下来:静置中位 6.172 ms、连续滑动 15.963 ms(≈ 一个整周期),而相邻帧上屏间隔从静置的 ≈60 秒 变成滑动的 19.869 ms。前者是 SF 与屏幕那两步的合计,后者是这一层多久产一帧——两个量性质不同,不能混读。
10.5 下一篇预告
OK,下一篇回到本篇反复提到、但一直没有真机可做的那件事:当刷新率不再是一档时,三档相位怎么办——也就是官方那句"offset 按刷新率分档"在实际设备上怎么落地,以及三缓冲与多刷新率(ARR)叠在一起时,相位、预算、缓冲数量三者怎么互相牵制。也就是本篇 §7.4 里"需真机支持 ARR"那一行没打开的那一块。
相关链接 - 上一篇:【Android 图形显示系统】(九) HWC2:Vsync 回调和虚拟显示路径 - 下一篇(预告):【Android 图形显示系统】(十一) 三缓冲、多刷新率与 ARR - 相关系列:实时渲染管线探析 | 移动端图形与功耗 | GPU 架构剖析 | 图形性能剖析工具链 | 2D 矢量引擎深挖
With Best Wishes.
参考资料
- VSync(三路信号 / offset phase / DispSync 三要素 / 两个相位属性 / "至少两帧" / "providing a greater chance for error" / present offset)
- SurfaceFlinger 的系统属性(三个偏移属性名与"属性 → ConfigStore HAL"两层回退)
- SurfaceFlinger 和 Hardware Composer(虚拟显示没有 VSYNC)
- 硬件合成器 HAL 的 AIDL(onVsyncIdle 与回调约束)
- Hotplug handling(onHotplug 的硬约束)
VSyncModulator.h(setPhaseOffsets/setTransactionStart/setLastFrameUsedRenderEngine/updatePhaseOffsets的短路判据)VsyncConfiguration.h(现代一族:接口 + impl)PhaseOffsets.h(legacy 实现)VsyncSchedule.h(一块物理屏一个 schedule)VSyncDispatch.h(唤起点 = workDuration + readyDuration 之前)Scheduler.h(重同步与相位配置)- Pixel 6(oriole / Android 12 / 90 Hz)bugreport 的 SurfaceFlinger 相位段(本篇第六章的真机读数来源)
- Android Performance|Perfetto 系列 8:深入理解 Vsync 机制
- Android Performance|Systrace 基础 - Vsync 解读
- Android Performance|Perfetto 08 Vsync(英文版)
- AOSPInsider|VSYNC(三路信号与流水线错位)
- nasdaqgodzilla|Android 图形层垂直同步虚拟 VSYNC 机制
- inovex|Glass-To-Glass Latency in Android
- CSDN|Android UI 架构(十三)…SurfaceFlinger 切换帧率(早期/晚期偏移)
- CSDN|SurfaceFlinger 请求 Vsync 流程(工厂二选一与默认构造值)
- CSDN|Android 14 - 绘制体系 - VSync(1)(EventThread 的 workDuration/readyDuration)
- mo4tech|Android-SurfaceFlinger 从绘制原理开始(两个延迟源各加一个相位偏移)
- mo4tech|Android Systrace 基础(七)Vsync 解读
- Wikimedia Commons|Phase shift(两路同频波形错开 θ)
- Wikimedia Commons|Phase locked loop(通用锁相环框图)
- Wikimedia Commons|Clock skew(数字时钟偏斜时序图)
- Wikimedia Commons|Vsync diagram(screen tearing 设置对照;本篇已排除)
- Wikimedia Commons|Vertical Synchronize(模拟视频场同步结构;本篇已排除)
- Wikimedia Commons|Videosignal vsync(PAL 场同步;本篇已排除)
- Wikimedia Commons|Analog PLL (block diagram)(备选)
- Wikimedia Commons|Digital PLL (block diagram)(备选)