本文由@溯光 出品,转载请注明出处 站点链接:技术博客页 | 公众号:溯光的图形笔记
核心问题:上一篇结尾问的是"命令从哪条门进 GPU",这一篇要问的是:缓冲交出去之后,系统侧在 Vsync 之后到底做了什么?
一个具体的现象就能把它逼出来:在一块静止不动的屏幕上,SurfaceFlinger 到底还在不在干活? 直觉说"画面没变,它应该闲着";但如果你真去数一数它的送屏次数,会发现在滑动的那 11 秒里有 375 次,而在静置的那 11 秒里是 0 次。不是"少",是"一次都没有"。 这个 0 不是测量失败,而是"没有新内容就不合成"这条规则在设备上的正面证据——本篇就从这里开始拆。
承接上一篇。ep05 讲的是"入口":应用怎么接上 Vulkan、加载器怎么找到驱动、后端怎么被选中。ep04 讲的是"账":一条队列自己怎么记这一笔。这两篇都默认了一件事:缓冲总得有个时刻被推到屏幕上。 把它推上去的,是 SurfaceFlinger(合成器,常简称 SF)。而"什么时候推"这个问题,答案就落在 Vsync 之后的那一段。
这件事在前几篇里早就挂过账。ep03 §0.3 与 ep04 §8 都把"合成怎么决定、送屏怎么对齐"指向了本篇,ep05 §9.3 的下一篇预告里也写明:下一篇讲 SurfaceFlinger 在 Vsync 之后做什么。
所以这一篇是来还债的:把 Vsync 之后 SF 主线程那一轮做完的事一次讲清。它回答的不是"HWC 叠加层怎么实现"(那是 ep08,且需真机),也不是"事务本身怎么提交"(那是 ep07),而是这一轮里的决策与送屏:合什么、谁来合、什么时候推上去。
三篇共用同一条链路,各篇量的东西仍然不同:
| 维度 | ep04《BufferQueue》 | ep05《Vulkan 入口》 | ep06(本篇) |
|---|---|---|---|
| 关心的对象 | 记账:队列自己怎么记这一笔 | 入口:命令从哪条门进 GPU | 关口:缓冲从哪一刻被推上去 |
| 量什么 | 数量与状态 | 加载路径与后端身份 | 合成归属与送屏节拍:合成类型、present 次数、五段时间量 |
| 主要命令 | SurfaceFlinger 的队列段 |
getprop + dumpsys gpu + vkjson |
dumpsys SurfaceFlinger + --timestats + --latency |
| 典型结论 | 公式值 2、实打 2、已分配 3 | 本机默认 GL;六版本号并存 | 静止时一帧都不合成(0 次);滑动时 375 次 present;峰值 16 ms 对上 60 Hz 周期 |
本篇也不涉及:HWC 的叠加层实现与合成模式细节(ep08,需真机)、ASurfaceControl 的事务语义(ep07)、Vsync 偏移三步舞(ep10)、多刷新率与 ARR 的完整决策(ep11,需真机)、事务本身怎么提交与合并(ep07 起)、Perfetto 与 Winscope 自身的方法论(《图形性能剖析工具链》已写)、渲染算法与画面技术(《实时渲染管线探析》已写)、GPU 硬件内部(《GPU 架构剖析》已写)。为什么不涉及:这一篇的价值集中在"一轮里的决策与送屏"这一个点,把合成决策、RenderEngine 的登场条件、present 与刷新周期的对齐三样讲透,掺进别的题域,这件事就讲不透了。完整边界见 §0.3。
系列文章写作风格说明(沿用前五篇,不再重复解释)
- 术语双语:首次出现的核心术语写成「中文(English)」,后文可只用英文;
- 三层分开说:官方文档怎么写、AOSP 源码怎么做、本机跑出来是什么,这三层分开陈述,不混成一句;
- 读数标口径:每一个数字都标来源与条件(机型、Android 版本、命令、采样时刻);
- 模拟器与真机分开写:本机走 gfxstream + SwiftShader 软实现,凡是性能类结论一律不外推为真机;HWC overlay 与多刷新率这类拿不到的东西如实标"需真机"。
系列主要内容总览
- 【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
相关资源链接
- 官方一手来源:SurfaceFlinger 与 WindowManager | 实现 HWC | Hardware Composer | 实现 Vsync | 多刷新率
- 读数口径:Frame rate(
--timestats官方口径) | dumpsys 工具 | Perfetto FrameTimeline - 源码入口:
services/surfaceflinger/SurfaceFlinger.cpp|CompositionEngine/src/Output.cpp|DisplayHardware/HWComposer.cpp|libs/renderengine/ - 与本文强绑定的前置:ep01《总览》| ep02《Choreographer》| ep03《RenderThread 与帧提交》| ep04《BufferQueue》| ep05《Vulkan on Android》
这一篇回答什么,不回答什么
OK,先把坐标系挪正。前五篇给了地图、给了一条叫醒链、给了一次交接、给了一本账、给了一个门;这一篇要给的是关口——缓冲从哪一刻被推上去,以及推上去之前 SF 内部做了什么决定。
0.0 内容总览:本篇知识地图
好了,先把十章各自回答的问题钉在一张表上。它同时也是本篇的目录总览。如果时间紧,第一章(三件事)、第三章(合成决策)、第六章(五个时间量)是这篇最不可替代的三章:第一章给骨架,第三章讲决定,第六章把决定变成可读的数字。
| 章 | 回答的问题 | 关键结论 |
|---|---|---|
| 零 | 这一篇补哪一段 | 一篇讲关口的文章;与 ep07/ep08/ep10/ep11 的分工 |
| 一 | Vsync 之后有哪几件事 | 官方三段:事务 → latch → 合成;只有前两段改了内容才合成本次 |
| 二 | latch 是什么 | 新缓冲的接管与"必须一直显示点什么"的兜底;本机静置窗口 0 次 present |
| 三 | 合成决策怎么被标出来 | SF 给清单、HWC 逐层标 device/client;三种模式与 overlay 数量约束 |
| 四 | 谁执行 GPU 合成 | RenderEngine 只在 client 路径上登场;本机它的时间量只有 4 个样本 |
| 五 | present 怎么与刷新周期对齐 | present fence 语义;峰值 16 ms 对上 60 Hz 的 16.67 ms |
| 六 | 五个时间量分别量什么 | desired → latch → acquire → post → present 的逐段释义与本机读数 |
| 七 | 一套可复现的读数流程 | --timestats 四步法 + --latency 形状 + 自洽核对方法 |
| 八 | 四处流传很广的说法 | 逐条核对,不立空靶 |
| 九 | 四项可执行动作 | 每条命令与判据 |
0.1 本篇那个"反直觉的现象"
好,先把这个现象摆出来,因为它撑住了整篇的骨架。
在一块静止的屏幕上,SurfaceFlinger 一帧都不合成。 这不是我的推测,是两个窗口的对照读数:11 秒的滑动窗口里有 375 个 present,11 秒的静置窗口里是 0 个——同一台设备、同一条命令、同一个采样方法,只差"屏幕上有没有东西在变"。
为什么这件事重要?因为它一句话就说明了 SF 的工作方式:它不是"每帧都重画一遍",而是"有变化才出手"。 后面第三、四章讲的那些"决策"和"谁执行",说到底都是在解决同一个问题——这一次出手,到底该由谁来做、做多少。
0.2 本篇不涉及什么(边界声明)
开篇把边界说死,是这几篇的统一约定。本篇:
- 不讲 HWC 叠加层的实现与合成模式的硬件细节:本机是模拟器,走的是 gfxstream 在宿主侧提供的合成,"device 合成"这三个字在真机上的含义与这里不同(§3.2 展开)——那部分留给 ep08,且需真机。
- 不讲 事务本身怎么提交与合并:
Transaction的语义属于 ep07 与它的后续,本篇只把"处理事务"当作一轮三段里的第一段,一句带过。 - 不讲 Vsync 偏移三步舞(应用 / SF / 屏幕三步)与多刷新率的完整决策:分别属 ep10 与 ep11(后者需真机)。本篇只讲"给定 60 Hz 这一个周期,一轮怎么对齐并送出去"。
- 不搬厂商配图、不使用任何 logo 文件:Commons 上 Android / SurfaceFlinger / HWC 相关标识均为注册商标,且对技术论证无贡献(§8.4)。
- 不假装读到读不到的东西:真机的 HWC overlay 数量、多刷新率档位、以及官方那条"强制 GPU 合成"的调试开关(本机被 SELinux 拒绝),都如实标注(§7.2)。
本机观测环境(本篇 11 张本机真图的来源,先交代清楚):无物理设备,统一用 Pixel_7 无窗冷启动(-no-window -no-audio -no-boot-anim -gpu swiftshader_indirect -no-snapshot);宿主 Android emulator,图形后端 gfxstream;系统镜像 android-36/google_apis_playstore/x86_64;设备侧 ro.build.version.release = 16、sdk = 36、user / release-keys、ro.debuggable = 0。
0.2.1 本机观测环境与四类读数来源面板
dumpsys SurfaceFlinger / --timestats / --latency,本篇 11 张本机真图全部来自这套无窗冷启动环境@ 本机实测(Pixel_7 AVD,Android 16 / API 36)这五条边界不是客套,它们直接决定了本篇的证据类型。原理上,一篇技术文章的结论强度受限于它的取证手段:能用命令读出来的(字段、直方图、时间戳)是一手证据;官方文档写明的规则是权威依据;社区文章与教程只能作旁证。本篇把这三类出处逐处标出来,凡属"模拟器环境产物"的结论一律不外推。作为对照,实现 HWC 与本篇关系最紧——官方那句"一轮三段"就出自它,凡是它已经写明的事,本篇尽量引原话。
一、Vsync 之后有哪几件事
OK,这一章先给骨架。看似"SF 在每个 Vsync 醒来干活",但它醒来以后做的事有明确的先后次序。
1.1 官方定义的三段:事务 → latch → 合成
AOSP 的《实现 HWC》把这件事写得最权威。原文的意思是:每个 Vsync 周期,如果 SF 有新内容要合成,它就会依次做三件事——
- 处理事务(Handles transactions):把应用交上来的状态变更落下去;
- latch 新的图形缓冲(Latches new graphic buffers):把新缓冲接到对应的层上;
- 做一次新的合成(Performs a new composition)——注意官方在这句上加了条件:只有第 1 步或第 2 步改变了显示内容,才会走到这一步。
这三段里,第一段是 ep07 的题域(事务怎么提交、怎么合并),本篇只在它"改了内容"这个作用上提一句;第二、三段才是本篇的主角。而这个"带条件"的第三段,正是 §0.1 那个现象的来源:没有事务、没有新缓冲,就没有合成。
把三段各自"改了什么"分清,后面的读数才不会读错:第一段改的是状态(某个层的可见性、位置、透明度);第二段改的是内容(这一轮该显示哪一块缓冲);第三段改的是像素(这一帧最终长什么样)。三段里只有后两段真正影响屏幕上的画面,而第三段又是唯一会消耗 GPU 或硬件叠加资源的一段。这也解释了官方为什么要把"是否改变了显示内容"写成第三段的前置条件——内容没变还去合成,等于白花一次资源。
配套还有两条官方口径值得记住:
- SF 掌握 Vsync 的开关:它通过调用 HWC 的
setVsyncEnabled来决定要不要让 HWC 产生 Vsync 事件,从而与显示器的刷新周期同步。这句话有个容易被忽略的推论:"每帧醒来一次"这件事本身是 SF 决定的,不是天生如此; - SF 必须一直显示点什么:官方原话是它"总是要显示点什么,所以它会把一块缓冲一直攥在手里"——这句话解释了为什么"没变化"时屏幕不会黑,也预告了第二章 latch 的兜底逻辑。
1.2 一轮刷新长什么样
把三段摊在时间轴上,一轮的形状就清楚了。从应用侧看,事情早在 Vsync 之前就开始了:应用画完一帧、把缓冲交给队列,然后睡下;随后 SF 自己的一个 EventThread 把它叫醒,触发合成与显示输出。官方的 Systrace 教程把这一段拆成了六步,其中后半段与本篇直接相关:
第 4 步:SF 里的第二个 EventThread 叫醒 SF,触发合成与显示输出; 第 5 步:SF 用 HWC 处理合成(官方给的量级是"通常 4 到 6 毫秒"); 第 6 步:SF 把最终结果交给显示驱动送出去,然后回去睡下。
官方给的"4 到 6 毫秒"是个量级,不是本机的数(本机是模拟器软实现,这个量级不适用)。记住它的用处在于:一轮合成是有时间预算的——刷新周期 16.67 ms,SF 自己只占其中一段,剩下的要留给应用和送屏。第六章那五个时间量,量的就是这条预算怎么被分摊。
1.3 本机这一轮的现场:合成列表
好,骨架立住了,落回本机看一眼真实的一轮。dumpsys SurfaceFlinger 里有一段叫 Composition list(自下而上的合成列表),它把这一轮参与合成的层逐个列出来,每层带上当前缓冲、帧号、边界与变换:
Composition list (bottom to top)
LayerStack=0
Layer [69] Wallpaper BBQ wrapper#69
visible reason= buffer=3938485010435 frame=1 ...
bounds={-54,-120,2520,2323.1} toDisplayTransform={ scale x=2.5782 y=2.5781 ... }
Layer [100] com.google.android.apps.nexuslauncher/...NexusLauncherActivity#100
visible reason= buffer=4548370366494 frame=15 ...
bounds={0,0,2400,1080}
Layer [104] StatusBar#104
visible reason= buffer=3938485010453 frame=18 ...
Layer [91] Taskbar#91
visible reason= buffer=4548370366483 frame=6 ...
Layer [74] ScreenDecorOverlay#74
visible reason= buffer=3938485010448 frame=15 ...
1.3.1 合成列表现场
dumpsys SurfaceFlinger 的 Composition list 段)这一屏能读出三件事。第一,参与合成的层是"被挑出来的":列表里有 6 层,而系统里注册的层远不止 6 个(dumpsys SurfaceFlinger --list 一次能列出上千行)。第二,每层带着自己的帧序号:壁纸还停在 frame=1,桌面已经走到 frame=15,状态栏 frame=18——同一时刻各层的进度并不一致(这一点 ep04 已经用另一条命令量过)。第三,变换(toDisplayTransform)也在这里:壁纸那层带着 2.578 倍的缩放与平移,说明它并不是原样贴上去的。
口径警示 ①:合成列表是一次快照。它说明"这一瞬间有哪些层参与、各自的归属是什么",不能用来推断"某一层永远参与"或"某一层永远不参与"——那要靠连续采样或用
--timestats那种累计读数。
二、latch:把新缓冲扣到层上
好了,三段里的第二段叫 latch。这个词在中文里没有现成译法,直译成"闩住"更贴它的本意:把队列里已经可用的那块缓冲,扣到对应的层上,让它成为这一轮要显示的那一块。
2.1 latch 要解决的两个问题
先来看看 latch 这一步:它看着简单,其实要同时回答两个问题。
第一个问题:这一轮该显示哪一块? ep04 讲过,队列里的位置处在什么状态是可以读出来的(FREE / DEQUEUED / QUEUED / ACQUIRED)。latch 做的事,就是从"已经交出、还没被取用"的那一批里挑出该显示的那一块,把它记在该层名下。
这一步与 ep04 那本"账"是同一件事的两面:ep04 讲的是队列自己怎么记(位置处在什么状态、谁正拿着它),latch 讲的是 SF 从这本账里读出"该显示哪一块"。两篇接起来看,一条缓冲的完整生命周期就是"应用申请 → 画出内容 → 交给队列 → SF 取走并扣到层上 → 交给合成与送屏"。注意 "取走" 与 "扣到层上" 是两个动作:前者发生在使用方那一侧(ep04 的口径),后者是 SF 把取到的结果记进这一轮的显示集合里。
第二个问题更要紧:如果这一轮没有新缓冲呢? 官方的答案是那句"SF 必须一直显示点什么,所以它会把一块缓冲一直攥在手里"(原句见 AOSP 的《实现 HWC》)。关键在于:latch 允许"沿用上一次那一块"——没有新缓冲不是错误状态,而是常态,此时层继续显示它手上那块,屏幕不会黑、也不会闪。
这两条合起来,就解释了 §0.1 那个现象的一半:没有新缓冲 → latch 没改变显示内容 → 第三段的合成条件不成立 → 这一轮不合成。
2.1.1 latch 的兜底逻辑
2.2 本机实测:静置 11 秒,一次都没合成
好了,这条规则能不能在设备上看到?能,而且看到的是一个极端干净的 0。
方法是用 --timestats 开一个累计窗口,分别在两种情况下各测 11 秒:一种是持续滑动(每隔 0.55 秒一次 input swipe,制造持续的 UI 变化),一种是不做任何输入(点亮屏幕后静置)。判据看两个数:窗口内的 totalFrames,以及 presentToPresent 直方图的样本总数。
【滑动窗口 11.0 秒】
totalFrames = 377
displayOnTime = 10909 ms
displayRefreshRate = 60 fps
averageFPS = 34.122
presentToPresent 直方图:375 个样本(峰值 16ms 处 42 次)
【静置窗口 11.1 秒】
totalFrames = 1
displayOnTime = 11058 ms
displayRefreshRate = 60 fps
presentToPresent 直方图:0 个样本(整行全为 0)
2.2.1 静置态与滑动态的对照
dumpsys SurfaceFlinger --timestats)这个 0 值得单独说清怎么读,因为它非常容易被误读成三种东西:
- 不是"测量失败":同一窗口里
displayOnTime = 11058 ms、displayRefreshRate = 60 fps都读到了,说明窗口是开着并且有数据的,只是没有一帧进入 present 统计; - 不是"SF 挂了":
frameDuration直方图里还有 1 个样本(5 ms),说明这一轮窗口内至少发生过一次合成动作; - 不是"屏幕黑了":
totalFrames = 1说明这一轮里显示输出仍是活着的(frameDuration那一次就是这个 1 的来源)。
正确的读法是:这 11 秒里没有新事务、没有新缓冲,于是 latch 没改变显示内容,第三段的合成条件不成立——一帧都没合成。 官方那句"只有第 1 步或第 2 步改变了显示内容才会做新的合成",在这里变成了一个可以直接指认的读数。
口径警示 ②:
totalFrames与presentToPresent的样本数是两个不同的量。前者数的是"这一段显示时间里有几帧被送出去",后者统计的是"相邻两次 present 之间的间隔"。静置窗口里totalFrames = 1而直方图 0 样本,恰恰说明只有 1 帧、没有"相邻两次"可言。把 0 样本读成"0 帧"就把两个量混为一谈了。
顺带说一句:滑动窗口里的 375 个样本也不是"375 帧画面"。它是 375 个 present 间隔,而显示侧 totalFrames = 377。两个数差不多,是因为它们量的是同一件事的两个侧面(帧数 vs 间隔数),差的 2 正好是首尾那两帧不产生间隔。
三、合成决策:谁来决定这一轮怎么合
OK,三段里的第三段是"做一次新的合成"。但"合成"这个词本身很含糊:是把所有层画到一张图上,还是让硬件各自叠? 这一步的选择,就是本篇第二章之后最重要的内容。
3.1 决策权的分配:SF 给清单,HWC 逐层标注
官方的表述非常清楚,几乎可以直接当结论用:SF 把一份完整的层清单交给 HWC,并问一句"你想怎么处理?";HWC 回来时把每一层标成 device 或 client 合成;SF 负责处理所有被标为 client 的层(把它们的合成结果做成一块输出缓冲交给 HWC),剩下的交给 HWC 自己处理。
这句话里有三个角色,值得逐一对上:
- SF(SurfaceFlinger):出清单的那一方,它不预设答案,只提供候选;
- HWC(Hardware Composer):给答案的那一方,它按自己的硬件能力逐层标注;
- client 合成:被标为 client 的层由 SF 自己动手合(走 GPU);device 合成:被标为 device 的层交给显示硬件自己叠。
顺带说清一件常被忽略的事:合成清单里的层不只是应用自己的界面。本机那 6 层里,桌面那一层属于应用,而壁纸、状态栏、任务栏、屏幕装饰属于系统侧——它们和应用层一起参与同一轮合成,也一起竞争有限的合成资源。 这就是"层数超过 overlay 数"在现实中很容易发生的原因:一台设备上同时活跃的层,往往比你以为的多。
3.1.1 合成决策流程
这个分工有个容易忽略的推论:"这一帧由谁合成"不是 SF 单方面决定的,也不是应用能指定的。应用能影响的是"我这一层长什么样、要不要更新";SF 能影响的是"我把哪些层报上去";最后拍板的那个是 HWC,而它拍板的依据是硬件能力。这就是为什么同一段代码在不同机型上的合成路径可能完全不同——这是设备差异,不是代码差异。(决策原句与三步调用序列见 AOSP 的 Hardware Composer 与《实现 HWC》。)
3.2 本机的合成归属:6 层全 device
决策落地之后,归属这件事能不能读出来?能。dumpsys SurfaceFlinger 里有显示级的一段,也有逐层的标注。
显示级原文(Composition Display State 段):
isEnabled=true isSecure=true usesDeviceComposition=true
usesClientComposition=false flipClientTarget=false reusedClientComposition=false
compositionStrategyPredictionState=DISABLED
逐层那边,每层都带一行 hwc: layer=0x08a composition=DEVICE (2) 这样的标注。把全文里所有这类标注数一遍:
composition=DEVICE (2) × 6
6 层、全部 DEVICE、client 合成一个都没有。 这与显示级那两行完全对得上——层级与显示级两个口径互相印证。
为什么要用两个口径来读同一件事? 因为这两处读数出自 SF 内部不同的代码路径:显示级那两行是"这一轮整体上有没有走 GPU 合成"的汇总结论,逐层那些标注是"每一层各自被判成什么"的明细。汇总与明细互相印证,比只看一处可信得多——如果只看显示级,你不会知道 6 层里有几层被改过归属;如果只看逐层,你又要自己数一遍才敢下结论。两处一起看,结论才是站得住的。
3.2.1 显示级合成状态字段
usesDeviceComposition=true / usesClientComposition=false,与逐层的 6 条 composition=DEVICE 互相印证@ 本机实测(Pixel_7 AVD,Android 16)口径警示 ③(本篇最要紧的一条):模拟器上的"DEVICE"不等于真机的硬件叠加层。 本机的 HWC 是 gfxstream 在宿主侧提供的合成路径(ep05 已实测宿主日志里
useVulkanComposition: false、useVulkanNativeSwapchain: false)。所以"6 层全 DEVICE"在这台机器上说明的是"这一轮的合成归属被标成了 device",不能读成"真机上这 6 层会走 DPU 的 overlay"。真机 overlay 的数量与决策留给 ep08,且需真机。
3.3 三种模式与那条决策序列
官方还给了两个可引用的细节。第一个是三种模式:在 SF 调用 HWC 的 validateDisplay() 之后,每一帧都会落到 GLES / MIXED / HWC 三种模式之一。可以这样理解这三档:
- HWC 模式:所有层都由硬件叠(理想情况,最省电);
- GLES 模式:所有层都交给 SF 走 GPU 合成(最费电,但兼容性最好);
- MIXED 模式:一部分 device、一部分 client——这也是现实中最常见的形态。
3.3.1 三种合成模式的分岔
第二个细节是决策落地的调用序列,它把"逐层标注"这件事拆成了可指认的步骤:
validateDisplay # 让 HWC 看一遍清单并给出意见
getChangedCompositionTypes # 取回"哪些层的归属被改了"
acceptDisplayChanges # SF 接受这些改动
setClientTarget # 若有 client 层,把它们的合成结果交给 HWC
presentDisplay # 收尾:完成合成并输出
注意 acceptDisplayChanges 这一步的名字——"接受"意味着决定权在对面。SF 是把清单交出去、听取意见、然后接受结果的那一方。这与 §3.1 那句官方原话是同一件事的两种说法。
把这条序列读顺了,你会发现它其实是一个"协商"过程:SF 先把清单递过去,HWC 看过硬件能力之后给出它愿意接手的部分,SF 再把改动的部分认下来、把需要自己画的部分画好交回去,最后由 HWC 收尾输出。整条链上没有哪一步是"单方面决定"——这也是为什么同一份清单在不同设备上会得到不同的归属:协商的对手不同,结果自然不同。
3.4 为什么层多了就一定会有一部分落到 client
好了,那 HWC 凭什么改归属?官方给了一条很实在的理由:叠加上层(overlay plane)的数量是有限的,官方举例说"Android 设备通常支持四层 overlay;当要合成的层比 overlay 多时,系统会对其中一部分改用 GLES 合成"。
3.4.1 overlay 数量约束
官方还补了一句反直觉的:当屏幕上没有任何东西在变化时,overlay 反而可能比 GL 合成更低效,此时 HWC 可以要求对部分或全部层改用 GLES 合成,并把合成结果保留下来复用。这正好和第二章那个"静置 0 次合成"互为补充:
- 没有变化 → 根本不需要合成(第二章那个 0);
- 有变化但变化很小 → 可能用 GL 合一次然后复用(
reusedClientComposition这个字段就是为这种情况准备的,本机默认态读到的值是false)。
四、谁执行 GPU 合成:RenderEngine 的登场条件
OK,第三章讲了"归属由 HWC 标"。那么被标为 client 的那些层,由谁动手画?答案是 RenderEngine。这一章要讲的不是它怎么画,而是它什么时候才会出场——这个条件关系比它的内部实现要紧得多。
4.1 RenderEngine 是谁
官方的定义句很短,可以直接引:"the SkSL shader is plugged into the Skia implementation for RenderEngine, the GPU-accelerated compositing component for SurfaceFlinger." 翻译过来就是:RenderEngine 是 SF 的 GPU 加速合成组件,它把 Skia 的实现接上 SF 的合成需求(源码在 frameworks/native/libs/renderengine/)。
一句话概括它的定位:它是 client 合成这条路上的执行者。
4.2 登场条件:只有 client 合成才走它
把第三章和这一章接起来,就得到一个非常干净的条件关系:
HWC 把某层标成 device → 该层由显示硬件叠 → 不经过 RenderEngine
HWC 把某层标成 client → 该层由 SF 走 GPU 合成 → 经过 RenderEngine
4.2.1 RenderEngine 的登场条件
这个条件关系的实际用法,是判断"这台设备上 RenderEngine 值不值得关注":如果一台设备上的合成基本都落到 device(比如屏幕内容简单、层数很少),那么 RenderEngine 在这一帧的路径上几乎不出现——你去调它的参数、去分析它的耗时,等于在分析一条没被走的路。反过来,如果层数多、透明度混合多,client 合成的占比就会上来,RenderEngine 才真正进入关键路径。所以,"要不要关心 RenderEngine"这个问题,得先问"这一帧有多少层被标成了 client"。 这层因果关系把第三章和本章绑在了一起。
4.3 本机实测:它的时间量只有 4 个样本
这个条件关系在本机有一处非常直接的印证。--timestats 除了 presentToPresent,还会给每一层打一组分段直方图,其中包括 renderEngineTiming(RenderEngine 花的时间)、latch2present、desired2present、acquire2present、post2acquire。同一份 11 秒滑动窗口里,这些量的样本数并不一样:
| 量 | 样本数 | P50 / P90 / P99(ms) |
|---|---|---|
presentToPresent(显示级) |
375 | 18 / 34 / 250 |
frameDuration |
377 | 5 / 8 / 16 |
latch2present |
362 | 5 / 9 / 14 |
desired2present |
362 | 23 / 34 / 40 |
acquire2present |
362 | 9 / 27 / 38 |
renderEngineTiming |
4 | 14 / 15 / 15 |
4.3.1 本机 RenderEngine 段与样本数
vulkan_renderengine: false,而同窗口里它的时间量只有 4 个样本——"这一轮几乎没走它"的量化证据@ 本机实测(Pixel_7 AVD,Android 16)这张表最值得看的一列是"样本数",不是"毫秒数":同一段显示时间里,present 有 375 个样本、而在 RenderEngine 上花过时间的机会只有 4 次。这与第三章"6 层全 DEVICE"完全吻合——归属全走 device,client 路径自然几乎不被触发。
口径警示 ④:
renderEngineTiming的 4 个样本不能用来评价 RenderEngine 快不快(样本太少,中位数没有统计意义),它唯一的用处是说明它在这一轮里出场了几次。把"样本少"读成"它很快",是把"频次"当成了"速度"。
另外,RenderEngine 段里那几个字段本身也值得过一眼:
| 字段 | 本机读数 | 读的是什么 |
|---|---|---|
RenderEngine tracked buffers |
19 | 它跟踪的缓冲数(ep05 同一环境读到 18,说明这个数会随运行状态浮动) |
RenderEngine shaders cached since last dump |
134 | 自上次 dump 以来编译缓存下来的着色器数 |
RenderEngine runtime effects |
5 | 挂着的运行时特效数 |
vulkan_renderengine |
false |
合成后端是不是 Vulkan(本机走 GL,与 ep05 的结论一致) |
最后这个字段还牵出一件事:RenderEngine 自己也有两条后端路(走 GL 或走 Vulkan),vulkan_renderengine 读的就是它当前选的是不是 Vulkan 那条。框架侧合成渲染器的后端,与应用侧渲染管线的后端,是两套彼此独立的选择,各有各的开关与判据——ep05 讲的是后者的选择逻辑,这里的字段是前者。把两者当成同一个,就会出现"我切了应用的后端,为什么合成那边没变"这类困惑。
4.4 一条决定"要不要等"的差别:device 阻塞、GPU 并行
最后补一条 Perfetto 官方给出的关键结论,它是理解第五章"present 什么时候发生"的前提:
device 合成是在主线程上的阻塞调用;而 GPU 合成是并行的——SF 做完必要的绘制调用后,把 GPU 的 fence 交给显示设备,显示设备等这个 fence 被 signal,然后 present。
4.4.1 device 阻塞与 GPU 并行的差别
这条差别的实际后果是:走 device 时,SF 主线程要等合成真正完成;走 GPU 时,它把"等"这件事转交给了显示设备的一句 fence 判定。所以"这一帧由谁合成"不只影响省不省电,还影响 SF 主线程能不能早一点空出来去做下一轮。 而"等 fence"这件事本身怎么运作,就是下一章的内容。
五、present:什么时候真的推上去
好了,前面四章讲的全是"这一轮怎么合"。但读者最关心的那个问题还没答:合完之后,画面什么时候真的出现在屏幕上? 这就是 present 要回答的事。
5.1 present 与那句 fence 判定
官方给的收尾动作叫 presentDisplay——"告诉 HWC 完成合成流程并把最终结果输出"。但"输出"这个词掩盖了一个关键细节:GPU 合成出来的画面,并不一定已经画完了。
官方的原文给了答案:当应用提交一块在 GPU 上生产的缓冲时,它同时会提交一个同步栅栏(sync fence)对象;在缓冲被显示之前,HWC 会检查这个 fence 是否已经 signal,如果已经 signal 就显示它。
把它翻译成一句可记的话:present 不是一个"拷贝"动作,而是一次"条件判定"——真正的分界线是 fence 有没有被 signal。(fence 这段原文见 AOSP 的《实现 HWC》;Vsync 的开关由 SF 掌握,见《实现 Vsync》。)
口径警示 ⑤:官方这句表述里的 fence 是从缓冲生产者传来的同步栅栏,它与第四、六章里那些
*2present的时间量不是一回事。前者是"要不要显示",后者是"从某个时刻到显示之间隔了多久"。把两者混起来谈,就会出现"fence 决定延迟"这类似是而非的说法。
再把栅栏的几处位置分开说,这是最容易混的地方。ep04 讲过队列里那块缓冲的两条栅栏:生产者交出时的栅栏(还没画完就别急着用)与使用方归还时的栅栏(用完了才能被复用)。而本章这一处说的是第三条:在"要不要显示"这个判定上,HWC 要等的那条栅栏。 三条栅栏管三段不同的事,各自有各自的判据;混起来谈,就会出现"栅栏一多就慢"这种没有指认对象的说法。
5.2 本机实测:峰值 16 ms,正好对上 60 Hz
对齐这件事能不能量化?能,而且本机有两个互相独立的读数指向同一个答案。
第一个读数来自 --timestats 的 presentToPresent 直方图。它统计的是"相邻两次 present 之间的间隔",11 秒滑动窗口里共 375 个样本。把桶值排出来看,形状非常集中:
2ms=1 3ms=1 4ms=3 5ms=4 6ms=7 7ms=3 8ms=4 9ms=4 10ms=7
11ms=3 12ms=6 13ms=10 14ms=18 15ms=23 【16ms=42】 17ms=39 18ms=31
19ms=24 20ms=21 21ms=10 22ms=4 23ms=10 24ms=17 …(尾部零散)
250ms=11 300ms=2
峰值落在 16 ms 处,出现 42 次——而 60 Hz 的刷新周期是 16.67 ms。"相邻两次 present 的间隔最常见的是恰好一个刷新周期",这就是"送屏跟着刷新周期走"的直接证据。
5.2.1 presentToPresent 直方图
presentToPresent 直方图:峰值 16 ms(42 次)对上 60 Hz 的 16.67 ms 周期,说明送屏跟着刷新节拍走;尾部同时露出 250 ms 的长尾@ 本机实测(Pixel_7 AVD,Android 16,dumpsys SurfaceFlinger --timestats)第二个读数来自另一条命令 --latency。它的第一行就是刷新周期本身:
$ dumpsys SurfaceFlinger --latency "<桌面层名>"
16666666 ← 首行:刷新周期,单位 ns,即 16.666666 ms
<其后 127 行,每行三个时间戳>
16,666,666 ns = 16.666666 ms,与直方图峰值所在的 16 ms 桶是同一个周期。两条命令、两种统计方式,给出同一个数——这是本篇最硬的一处证据:一个是累计直方图的峰值,一个是单次读数的首行,两者互不依赖却指向同一个刷新周期。
5.2.2 --latency 的读数形状与刷新周期
--latency 的读数形状:首行 16,666,666 ns 就是刷新周期,与 --timestats 直方图峰值 16 ms 两个独立命令互相印证@ 本机实测(Pixel_7 AVD,Android 16)5.3 那 127 行三列时间戳该怎么读
先来看看 --latency 后面那 127 行:每行三个整数,按社区长期沿用的口径依次是:期望 present 的时刻 / 实际 present 的时刻 / 这块缓冲就绪的时刻(单位都是 ns)。
这里必须踩一脚刹车。把两列相减得到的差值,不能读成"丢了几帧":
实际 − 期望:中位数 24.51 ms(最小 7.10 / 最大 39.96)
实际 − 就绪:中位数 9.77 ms(最小 2.52 / 最大 39.73)
晚于一个刷新周期的行:114 / 晚于两个周期的:13 / 落在一个周期内的:12
如果按"晚于一个周期即丢帧"去读,就会得出"127 行里丢了 114 帧"这个荒谬结论——但同一窗口的 averageFPS = 34.122 说明它明明还在出帧。 矛盾在哪?在于两列的时间基准不同:期望时刻是系统按某种节奏推算出来的目标点,实际 present 是真实发生的时刻,两者之间有固定的系统性偏移(这一台上大约 1.5 个周期),把它当成"逐帧丢失数"是把偏移当成了丢失。
正确的读法是:这一台的送屏在时间轴上整体后移了约 1.5 个刷新周期(模拟器软实现 + 无窗渲染的产物,不外推真机);而"就绪 → 上屏"那一段中位约 9.77 ms,才是真正落在系统侧的那段延迟。
口径警示 ⑥:
--latency的三列是三种不同基准的时刻。任何两列相减都要先说清基准,否则得出的差值没有物理含义。这一条是本篇最容易踩的坑,第六章的五段时间量会再遇到一次同构的问题。
5.4 刷新率不等于帧率
OK,聊到这里,有个最常见的混淆值得单独拎出来:"刷新率"和"帧率"是两件事。
- 刷新率(refresh rate):显示器每秒把画面上屏的固定次数,本机是 60 Hz,周期 16.67 ms;
- 帧率(frame rate):内容每秒真正更新多少次,本机滑动时段由
averageFPS读到 34.122。
这两者关系不是"越高越上限",而是"帧率被刷新率切格":内容以 34 fps 产出时,它落在 60 Hz 的格子里,就会出现"某一格没新内容、沿用上一格"的情形——这正是第二章 latch 兜底逻辑在宏观上的表现。
顺带交代官方对刷新率是怎么选的:SF 按"活跃层"(会排队帧更新的层)来决定刷新率;如果层的属主给该层设了帧率,SF 会尽量把刷新率设成那个帧率的整数倍——官方举的例子是:两个活跃层分别设 24 与 60,SF 会选 120 Hz(如果设备支持)。这条决策的完整逻辑与多档切换属 ep11(需真机),本篇只取"本机单档 60 Hz"这一个前提。
如果对齐做不好会怎样?会撕裂(tearing)——上一帧还没画完,扫描线已经把它读出去了。同步机制存在的意义就是把这种"边写边读"隔开。
这张图也得说清边界:它是通用显示环境的实拍,不是 Android 的现场。本篇只借它承担"时机错位的后果"这一处示意,不把它当作 Android 合成链的证据。
六、五个时间量:把一轮拆成五段
好了,第五章给了一个总账:present 之间隔多久、周期是多少。但"一轮里时间花在哪一段"还没答。这一章就把它拆开——--timestats 已经替我们把这件事做好了。
6.1 五个量的定义:desired → latch → acquire → post → present
在同一个窗口里,SF 会给每一层打一组分段直方图。它们的名字本身就把一轮拆成了五段:
| 量 | 起点 | 终点 | 量的这段是什么 |
|---|---|---|---|
desired2present |
期望 present 的时刻 | 实际 present | 从"本该上屏"到"真的上屏" |
acquire2present |
取到缓冲的时刻 | 实际 present | 从"缓冲到手"到"上屏" |
latch2present |
latch 的时刻 | 实际 present | 从"扣到层上"到"上屏" |
post2acquire |
提交的时刻 | 取到的时刻 | 从"应用交出"到"SF 取到" |
presentToPresent |
上一次 present | 本次 present | 相邻两次上屏的间隔 |
6.1.1 五段时间量的定义轴
这张表最有用的一列是最后一列:它把五个名字从"看起来很像的一堆缩写"变成"五种不同的问题"。想查"应用交得晚不晚"就看 post2acquire;想查"SF 从拿到到推上去花了多久"就看 acquire2present;想查"整体节拍稳不稳"就看 presentToPresent。
6.2 本机这五段各是多少
把本机 11 秒滑动窗口里的这五个量并列出来(单位 ms,取 P50 / P90 / P99):
| 量 | 样本数 | P50 | P90 | P99 |
|---|---|---|---|---|
presentToPresent |
375 | 18 | 34 | 250 |
post2acquire |
346 | 11 | 21 | 32 |
acquire2present |
362 | 9 | 27 | 38 |
latch2present |
362 | 5 | 9 | 14 |
desired2present |
362 | 23 | 34 | 40 |
6.2.1 五段 per-layer 时间量面板
latch2present 最小(5 ms)、desired2present 最大(23 ms),两头夹出系统的"目标—实际"偏移@ 本机实测(Pixel_7 AVD,Android 16)这五个数放在一起,可以读出三件事:
第一,latch2present 是最短的一段(P50 = 5 ms)。 从"扣到层上"到"真的上屏",本机只花了 5 ms 中位数——SF 拿到该显示的那一块之后,出手很快。
第二,acquire2present(9 ms)比 latch2present(5 ms)长,差值约 4 ms 就是"从取到缓冲到 latch"这一段。这两个数一起说明:一轮里 SF 自己的两段加起来是 10 ms 量级,而刷新周期是 16.67 ms——留给应用与送屏的时间是够的(至少在这一台上是够的)。
这两个数之间还有一条顺序约束值得记住:acquire(取到)必然在 latch(扣到层上)之前,所以 acquire2present 永远不会小于 latch2present——本机 9 ms 对 5 ms 正符合。反过来说,哪天你读到 latch2present > acquire2present,那要么是两段的定义记反了,要么是存在一条你没考虑到的时间路径。 用这种顺序约束去校验读数,比死记数值更可靠:数值会随设备变,先后关系不会。
第三,desired2present(23 ms)明显大于前两者,因为它量的是"本该上屏 → 真的上屏",含了系统性偏移(与 §5.3 那个 24.51 ms 是同一现象的另一种测法)。这两个数互为旁证:一个是 --timestats 的分段量,一个是 --latency 的两列之差,量纲相同、量级一致。
6.3 三口径自洽核对:怎么确认解析没错
读数最怕的是"解析错了还不知道"。本机这套数据可以用三个互不相同的口径对一次:
口径 A(报告值):averageFPS = 34.122
口径 B(帧数 ÷ 时间):totalFrames = 377,displayOnTime = 10909 ms
→ (377 − 1) ÷ 10.909 s ≈ 34.47 fps
口径 C(间隔样本数):presentToPresent 样本 375 / present2present 样本 362
6.3.1 三口径自洽核对
A 与 B 差约 1%,B 与 C 差 2 个样本。 这三个口径来源不同(一个由 SF 自报、一个由帧数与时间算出、一个由间隔样本数给出),却指向同一个水平,这就是"解析口径没问题"的证据。 反过来说,如果三者差出一个量级,那首先要怀疑的是自己的解析,而不是设备。
这一条不是空谈。本篇阶段 2 就踩过:第一轮解析把
--timestats的三条直方图混读(presentToPresent/frameDuration/renderEngineTiming里同名键互相覆盖),一度得到"样本 4、P50 = 14 ms"的错误结论;按标题分段之后才是"样本 375、峰值 16 ms"。教训沿用本系列那条:对不上时先怀疑解析口径,不要先怀疑内容。
6.4 直方图怎么读:别只看平均值
下面我们看一件读图的手艺。--timestats 给的是直方图,而不是一个平均值——这是它最值钱的地方,因为平均值会把长尾吃掉。
以本机的 presentToPresent 为例:P50 是 18 ms(一半的间隔落在这以下),P90 是 34 ms,而 P99 是 250 ms。如果只看平均值(由 §6.3 的口径 B 反推约 29 ms),你会得到一个"还行"的数;但直方图尾部那 11 个落在 250 ms 桶里的样本说明:这一轮里有十几次间隔长达四分之一秒——那不是"慢了一点",那是明显的卡顿。
6.4.1 直方图怎么读
--timestats 给直方图而不是给均值的意义@ 本系列绘制所以读这套数有三条顺序:先看样本数(够不够统计)、再看 P50(典型节奏对不对得上刷新周期)、最后看 P90/P99 与尾部桶(有没有长尾)。只报一个平均值,等于把最有价值的那部分藏起来。
七、一套可复现的读数流程
OK,前面六章把原理和读数都摆过了。这一章把它们收成一套你自己可以在任一台设备上照做的流程。
7.1 四步法:把 --timestats 用对
--timestats 的正确用法是四步,顺序不能乱:
① dumpsys SurfaceFlinger --timestats -clear -enable # 清零并开始累计
② (让设备跑一段:滑动、播放、游戏,持续 ≥10 秒) # 这一步决定你测的是什么场景
③ dumpsys SurfaceFlinger --timestats -dump # 导出,含 averageFPS 与各直方图
④ dumpsys SurfaceFlinger --timestats -disable # 停掉累计
7.1.1 timestats 四步法与读数形状
--timestats 的四步法与 dump 里的三类读数:清零、跑场景、导出、停掉;-dump 输出里 averageFPS、totalFrames 与各直方图的位置一目了然@ 本机实测(Pixel_7 AVD,Android 16)第②步是这套流程里最容易做错的一步:如果不指定一个固定场景,"测出来的数"就没有对照意义。本机那两个窗口就是刻意设计成一组对照的:一个持续滑动、一个完全静置,其他条件都一样——所以第 2 章那个"375 vs 0"是可以互相解释的。
判据:-dump 的输出里,displayRefreshRate 应与你预期一致(本机 60 fps);averageFPS 与 totalFrames ÷ displayOnTime 应大致吻合(不一致就先查解析)。两条都成立,这份读数才值得往下读。
7.2 读归属:两条路,一条看显示级、一条看逐层
要判断"这一轮谁在合成",有两条互补的路:
- 快路(显示级):
dumpsys SurfaceFlinger里搜Composition Display State,看usesDeviceComposition/usesClientComposition两个布尔——一眼知道这一轮有没有 client 合成; - 细路(逐层):搜
composition=,把每层的标注数出来(本机是composition=DEVICE (2)× 6);想连缓冲与变换一起看,就搜Composition list。
判据:显示级的两个布尔与逐层标注必须自洽——显示级说 usesClientComposition=false,逐层就不该出现 composition=CLIENT。两者矛盾时,先怀疑自己搜错了段(比如把上一条 dump 的残留当成了本次的)。
先来看看还有第三条路:能看"某一层这一轮到底更新了没有":Composition list 段里每层都带 buffer= 与 frame= 两个信息。同一个层在连续两次快照里如果 frame= 没变,就说明它这一轮沿用了上一块缓冲——也就是 latch 走了第二章那条"沿用"分支。本机那次快照里,壁纸停在 frame=1,桌面已经到 frame=15,一眼就能看出这两层的活跃程度完全不同:这是"更新"与"沿用"在同一张快照里的并置证据,也是判断"哪个层在动"最省事的一个办法。
7.3 本机做不到的那件事
有一件事必须如实交代,因为它正好是很多人教的做法。
官方在 Tone mapping 那页给过一条调试命令,用来强制走 GPU 合成:
adb shell service call SurfaceFlinger 1008 i32 1 # 0 = 允许 HWC 合成,1 = 强制 GPU 合成
本机的真实返回是:
Result: Parcel(Error: 0xffffffffffffffff "Operation not permitted")
7.3.1 本机做不到的那件事
Operation not permitted——user 构建 + ro.debuggable = 0 下 SELinux 拦住了这个 service call;替代方案是用只读证据来论证@ 本机实测(Pixel_7 AVD,Android 16)为什么被拒:本机是 user 构建、ro.debuggable = 0,这两条合起来把应用侧提权与不少 service call 都关掉了。所以"切一脚强行走 GPU 再对比"这个实验在本机做不了——不是命令写错了。
那替代方案是什么?用只读证据做同一件事。 本篇一共用了三组:显示级的两个布尔、逐层的 6 条 composition= 标注、以及 renderEngineTiming 的样本数——三组都指向"这一轮几乎全走 device",结论一致。 这也回答了一个更普遍的问题:"做不了对照实验"不等于"得不出结论",只等于"要换一组证据"。
顺带登记第二条:debug.sf.force_client_composition 这个属性在本机确实能写(setprop 后读回 1),但我没有观察到合成类型随之变化,所以正文只写"属性可写"这个事实,不写"它能强制切合成"——没验证过的因果不写进结论。
7.4 找层名与看形状
--latency 需要一个层名,而层名的找法很多资料没写清。可用的是:dumpsys SurfaceFlinger --timestats -dump 的输出里有 layerName =,把它原样贴给 --latency 即可。本机桌面层的名字长这样:
com.google.android.apps.nexuslauncher/com.google.android.apps.nexuslauncher.NexusLauncherActivity#100
拿到之后,--latency 给你的就是 §5.2 那个形状:首行周期 + 127 行 × 3 列。
八、四处流传很广的说法,逐条核
OK,收集资料时遇到不少流传很广的说法,本篇把它们逐条拿到本机读数或官方文字上核一遍。口径:每条都给"流传说法 → 出错处 → 实证/官方依据 → 本篇结论"。
8.1 四处说法一览
先把四处说法汇总成一张对照表:每条都给"原文、它错在哪一步、拿什么去核、本篇最终怎么落笔"四列。这样读者不用翻正文也能一眼看到四处结论各自站哪、依据是什么;逐条独立核验,不把几条说法含糊捏成一段。
8.2 说法的分辨
说法一:「SurfaceFlinger 每一帧都在把整个屏幕重画一遍」。 出错在把"合成"想成了"每帧全量重绘"。实证:本机静置 11 秒窗口里 presentToPresent 0 个样本、totalFrames = 1;而滑动窗口里 375 个样本。依据:官方明写"只有事务或 latch 改变了显示内容才会做新的合成"。本篇结论:SF 是"有变化才出手"的一方,静置时它一帧都不合。
说法二:「合成这件事全是 GPU 干的」。 出错在忽略合成归属这件事。实证:本机 6 层全 composition=DEVICE (2)、usesClientComposition=false,同窗口 renderEngineTiming 只有 4 个样本。依据:官方"SF 给清单、HWC 逐层标 device/client"。本篇结论:被标为 device 的层根本不经过 RenderEngine;"全在 GPU 上合"只在 GLES 模式(或 MIXED 里的 client 部分)成立。
说法三:「帧率必须跟上刷新率,否则就是不正常」。 出错在把两个量当成一个。实证:本机 displayRefreshRate = 60 fps 而 averageFPS = 34.122。依据:刷新率是显示器的固定节拍,帧率是内容更新频率;二者关系是"帧率被刷新率切格"。本篇结论:34 fps 落在 60 Hz 的格子里完全正常,缺格由 latch 沿用上一块缓冲兜住。
说法四:「把 --latency 前两列相减,大于一个周期就是掉帧」。 出错在没看时间基准。实证:本机实际−期望中位 24.51 ms,看似"大面积掉帧",但同一窗口 averageFPS = 34.122 说明帧在正常出。依据:三列是两个不同基准的时刻,差值含系统性偏移。本篇结论:这条命令要分开读——"就绪 → 上屏"(9.77 ms)才是系统侧那一段。
把四处说法各配一句"正确写法",方便直接对照。"每帧全量重画"这条,正确写法是"只有内容变化才合成,静置时可以一帧都不合";"合成全是 GPU 干的"这条,正确写法是"归属由 HWC 逐层标注,device 层不经过 RenderEngine";"帧率得跟上刷新率"这条,正确写法是"刷新率是固定节拍、帧率是内容产出频率,帧率被刷新率切格";"两列相减即掉帧"这条,正确写法是"先分清时间基准,再看是偏移还是丢失"。
8.2.1 每条说法"错在哪一步"的证据链
原理上讲,这四处错的是同一件事:把"机制的名字"当成了"机制的实现"。合成、GPU、帧率、延迟这四个词各自都很容易唤起一个想当然的画面,而一个读数、一句官方原文就能把它校正过来。
8.3 两次"搜不到",性质完全不同
好了,本篇在准备外部素材时做了 9 组检索,其中有两次"搜不到",但它们必须分开看:
- 第一次:搜缩写
vertical blanking interval,命中 20 条——全部是注册号 VH-VBI 的客机照片。这是撞词噪声:素材是有的,只是我的关键词撞上了别的东西。换成全称与名词短语(raster scan display、double buffering computer graphics)之后,第一轮就命中了本篇的题图。 - 第二次:搜
hardware composer android surfaceflinger,命中 0 条。这是真稀缺:Commons 上确实没有 Android 合成链的专属图,换词也变不出来,只能自绘。
把这两者混为一谈,会往两个相反的方向出错:把撞词噪声当成"素材很多",就会拿无关图充数;把真稀缺当成"再搜搜就有",就会浪费大量检索轮次还拿不到东西。判据很简单:看命中条目的相关性,而不是看命中条数。(本篇引用的两条读数命令的官方口径分别见 Frame rate 与 dumpsys 工具。)
8.4 商标与素材边界
属于素材纪律:Commons 上 Android、SurfaceFlinger、Khronos 相关的标识全是注册商标(标 PD 只因为低于原创性阈值,商标权没放弃),所以本篇不使用任何 logo 文件。logo 对技术论证没有贡献,反而挤掉一张机制图,还有商标风险。
同理,本篇三张外部图都是通用显示机制图(Vsync 时序、刷新率与帧率、撕裂),不是 Android 的合成链;尤其那张撕裂图是通用显示环境的实拍,只承担"时机错位的后果"这一处示意,不当作 Android 的现场。
九、四项可执行动作
好了,把这一章的内容收成四项动作,每条都给命令与判据。
9.1 四项动作与判据
动作一:看这一轮谁在合成。
dumpsys SurfaceFlinger | grep -A3 "Composition Display State"
dumpsys SurfaceFlinger | grep -c "composition=DEVICE"
判据:显示级的 usesDeviceComposition / usesClientComposition 与逐层标注必须自洽;两者矛盾时先怀疑搜错了段。
动作二:量送屏节拍对不对得上刷新周期。
dumpsys SurfaceFlinger --timestats -clear -enable
(跑一段固定场景 ≥10 秒)
dumpsys SurfaceFlinger --timestats -dump
dumpsys SurfaceFlinger --timestats -disable
判据:presentToPresent 直方图的峰值桶应落在刷新周期附近(本机 16 ms ↔ 60 Hz);同时核 averageFPS 与 totalFrames ÷ displayOnTime 是否吻合。
动作三:判断"没变化"是不是真的没合成。
(静置 10 秒以上,不做任何输入)
dumpsys SurfaceFlinger --timestats -dump
判据:presentToPresent 样本数接近 0 是正常现象(不是测量失败);此时应能看到 totalFrames 仍非零,说明显示输出是活的。
动作四:读一轮里各段花了多久。
dumpsys SurfaceFlinger --latency "<层名>"
判据:首行就是刷新周期;三列分开读——"就绪 → 上屏"是系统侧那段,"期望 → 实际"含偏移,别当丢帧数。
9.1.1 四项可执行动作与判据面板
9.2 本篇的结论
好了,把十章收成三句。
第一句:Vsync 之后 SF 做的是"有变化才出手"的三段事。 事务、latch、合成,第三段带条件。本机那组对照(滑动 375 次 present vs 静置 0 次)是这件事最直白的现场证据——静置时它一帧都不合。
第二句:合谁、由谁合,是两件事,而且后者的决定权不在 SF 手上。 SF 出清单、HWC 逐层标注 device/client;被标为 device 的层根本不经过 RenderEngine。本机 6 层全 device、renderEngineTiming 只有 4 个样本,两个读数互相印证。
第三句:对齐是可以被量出来的,但每个数都要说清基准。 presentToPresent 峰值 16 ms 与 --latency 首行 16,666,666 ns 两个独立命令指向同一个 60 Hz 周期;而 --latency 三列相减的差值含系统性偏移,不能当丢帧数。
未解的挑战也留一句:真机上"device 合成"到底落到几个 overlay、多刷新率怎么选档,在本机只能答到"归属被标成 device"和"单档 60 Hz",真正要答清需要真机。这部分留给 ep08 与 ep11。
9.3 下一篇预告
OK,下一篇回到链路的下一站,讲SurfaceControl 的那层透明事务:应用怎么在不重建界面的前提下改一个表面的属性,事务怎么攒、怎么合、什么时候一次交出去——也就是本篇一路带过的那个"第一段",下一篇要把它打开看。
相关链接 - 上一篇:【Android 图形显示系统】(五) Vulkan on Android:低开销渲染入口 - 下一篇(预告):【Android 图形显示系统】(七) ASurfaceControl:Android 10 的透明事务 - 相关系列:实时渲染管线探析 | 移动端图形与功耗 | GPU 架构剖析 | 图形性能剖析工具链 | 2D 矢量引擎深挖
With Best Wishes.
参考资料
- SurfaceFlinger and WindowManager
- Implement Hardware Composer HAL
- Hardware Composer
- Implement VSync
- Multiple refresh rate
- Graphics architecture
- Graphics(总览)
- Surface 与三重缓冲(arch-sh)
- Tone mapping(含 RenderEngine 定义与 1008 调试命令)
- Systrace(一轮刷新的六步)
- Winscope overview
- Winscope · analyze SurfaceFlinger
- Winscope · capture via adb
- Perfetto · FrameTimeline
- Perfetto · ATrace
- Perfetto · SQL tables
- Perfetto · Android log
- Android · Frame rate(timestats 官方口径)
- Android · Frame rate 与应用侧 setFrameRate
- Android · dumpsys 工具
- Android Studio · jank 检测
frameworks/native/services/surfaceflinger/SurfaceFlinger.cppCompositionEngine/src/CompositionEngine.cppCompositionEngine/src/Output.cppCompositionEngine/src/OutputLayer.cppDisplayHardware/HWComposer.cpplibs/gui/include/gui/JankInfo.hlibs/renderengine/(RenderEngine 源码目录)- AOSP 课程 · SurfaceFlinger
- DeepWiki · CompositionStrategy
- juejin · CompositionEngine 逐行解读
- androidperformance · Perfetto 08 Vsync
- eyan0221 · SurfaceFlinger 读数格式
- Wikimedia Commons · Vsync diagram
- Wikimedia Commons · Display refresh rate vs video FPS
- Wikimedia Commons · Screen tearing on DOSV