Android 图形显示系统 · 第 六 篇

SurfaceFlinger:在 Vsync 之后做什么

2026-09-2914,037 字28 张配图

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

Vsync 与刷新周期的时序关系
题图Vsync 与刷新周期的时序关系,本篇借它立起「一帧什么时候被推上去」这个认知;但要先说清:这张图画的是通用显示器的垂直同步时序,不是 Android 的合成链,本篇只取它的节奏感作开题@ Wikimedia Commons · Mercury(CC0)

核心问题:上一篇结尾问的是"命令从哪条门进 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 与多刷新率这类拿不到的东西如实标"需真机"。

系列主要内容总览

  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,先把坐标系挪正。前五篇给了地图、给了一条叫醒链、给了一次交接、给了一本账、给了一个门;这一篇要给的是关口——缓冲从哪一刻被推上去,以及推上去之前 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 个——同一台设备、同一条命令、同一个采样方法,只差"屏幕上有没有东西在变"。

四篇各量哪一段
图 6-1本篇在链路里的位置:ep04 讲缓冲怎么记账、ep05 讲命令从哪条门进 GPU,本篇讲的是缓冲从哪一刻被推上去——Same three段都在 SF 内部,再往后才是 ep08 的 HWC 叠加层@ 本系列绘制

为什么这件事重要?因为它一句话就说明了 SF 的工作方式:它不是"每帧都重画一遍",而是"有变化才出手"。 后面第三、四章讲的那些"决策"和"谁执行",说到底都是在解决同一个问题——这一次出手,到底该由谁来做、做多少。

0.2 本篇不涉及什么(边界声明)

开篇把边界说死,是这几篇的统一约定。本篇:

  1. 不讲 HWC 叠加层的实现与合成模式的硬件细节:本机是模拟器,走的是 gfxstream 在宿主侧提供的合成,"device 合成"这三个字在真机上的含义与这里不同(§3.2 展开)——那部分留给 ep08,且需真机。
  2. 不讲 事务本身怎么提交与合并:Transaction 的语义属于 ep07 与它的后续,本篇只把"处理事务"当作一轮三段里的第一段,一句带过。
  3. 不讲 Vsync 偏移三步舞(应用 / SF / 屏幕三步)与多刷新率的完整决策:分别属 ep10 与 ep11(后者需真机)。本篇只讲"给定 60 Hz 这一个周期,一轮怎么对齐并送出去"。
  4. 不搬厂商配图、不使用任何 logo 文件:Commons 上 Android / SurfaceFlinger / HWC 相关标识均为注册商标,且对技术论证无贡献(§8.4)。
  5. 不假装读到读不到的东西:真机的 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 本机观测环境与四类读数来源面板

本机观测环境与四类读数的来源
图 6-2本机观测环境与四类读数的来源:属性族 / dumpsys SurfaceFlinger / --timestats / --latency,本篇 11 张本机真图全部来自这套无窗冷启动环境@ 本机实测(Pixel_7 AVD,Android 16 / API 36)

这五条边界不是客套,它们直接决定了本篇的证据类型。原理上,一篇技术文章的结论强度受限于它的取证手段:能用命令读出来的(字段、直方图、时间戳)是一手证据;官方文档写明的规则是权威依据;社区文章与教程只能作旁证。本篇把这三类出处逐处标出来,凡属"模拟器环境产物"的结论一律不外推。作为对照,实现 HWC 与本篇关系最紧——官方那句"一轮三段"就出自它,凡是它已经写明的事,本篇尽量引原话。


一、Vsync 之后有哪几件事

OK,这一章先给骨架。看似"SF 在每个 Vsync 醒来干活",但它醒来以后做的事有明确的先后次序。

1.1 官方定义的三段:事务 → latch → 合成

AOSP 的《实现 HWC》把这件事写得最权威。原文的意思是:每个 Vsync 周期,如果 SF 有新内容要合成,它就会依次做三件事——

  1. 处理事务(Handles transactions):把应用交上来的状态变更落下去;
  2. latch 新的图形缓冲(Latches new graphic buffers):把新缓冲接到对应的层上;
  3. 做一次新的合成(Performs a new composition)——注意官方在这句上加了条件:只有第 1 步或第 2 步改变了显示内容,才会走到这一步。
Vsync 之后的三段
图 6-3Vsync 之后 SF 主线程做的三件事:事务 → latch → 合成,而第三段带条件——前两段改了显示内容才做@ 本系列绘制

这三段里,第一段是 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 把最终结果交给显示驱动送出去,然后回去睡下。

一轮刷新的完整时序
图 6-4一轮刷新的完整时序:应用交完缓冲就睡,SF 被 EventThread 叫醒后走三段并 present,然后自己睡下——本篇关注的是唤醒之后那一段@ 本系列绘制

官方给的"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 合成列表现场

本机 Composition list 的现场
图 6-5本机 Composition list 的现场:6 层自下而上排开,逐层带当前缓冲、帧序号与变换;同一份 dump 里每层的合成归属也一并标出@ 本机实测(Pixel_7 AVD,Android 16,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 的兜底逻辑

latch 的两条分支
图 6-6latch 的两条分支:接管新缓冲,或沿用上一次攥在手里的那一块——后者正是"必须一直显示点什么"的落地方式@ 本系列绘制

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 静置态与滑动态的对照

静置与滑动两个窗口的对照
图 6-7静置与滑动两个 11 秒窗口的对照:同一台设备、同一条命令,滑动时 375 个 present、静置时 0 个——"有变化才出手"的正面证据@ 本机实测(Pixel_7 AVD,Android 16,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 合成决策流程

合成决策流程
图 6-8合成决策流程:SF 出清单、HWC 逐层标注、SF 只处理被标为 client 的那些层——决策权在 HWC 手上,不在 SF@ 本系列绘制

这个分工有个容易忽略的推论:"这一帧由谁合成"不是 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 显示级合成状态字段

显示级合成状态字段
图 6-9本机显示级合成状态字段逐项释义: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 三种合成模式的分岔

三种合成模式的分岔
图 6-10三种合成模式的分岔:GLES 全走 GPU、HWC 全走硬件、MIXED 两者混合——MIXED 才是现实中的常态@ 本系列绘制

第二个细节是决策落地的调用序列,它把"逐层标注"这件事拆成了可指认的步骤:

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 数量约束
图 6-11overlay 数量约束:层数在 overlay 数以内可全走 device,超出的部分必须落到 client 由 GPU 合成——"层多必有一部分走 GPU"的根因@ 本系列绘制

官方还补了一句反直觉的:当屏幕上没有任何东西在变化时,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 的登场条件
图 6-12RenderEngine 的登场条件:只有被标为 client 的层才经过它——device 层完全绕过,这个条件关系解释了本机为什么几乎读不到它的时间量@ 本系列绘制

这个条件关系的实际用法,是判断"这台设备上 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 段与样本数

RenderEngine 段读数与样本数
图 6-13本机 RenderEngine 读数与它的样本数:段里读到 tracked buffers 19 / shaders cached 134 / 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 合成与 GPU 合成的时间线差别
图 6-14device 合成与 GPU 合成的时间线差别:前者把 SF 主线程占住,后者把等待转移给显示设备——这决定了主线程什么时候能开始下一轮@ 本系列绘制

这条差别的实际后果是:走 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 直方图
图 6-15本机 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 的读数形状与三列统计
图 6-16本机 --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 兜底逻辑在宏观上的表现。

刷新率与帧率的对照
图 6-17刷新率与帧率的对照:显示器用固定节拍上屏,内容用不固定节拍产出,两者不是同一个量——这就是"帧率被刷新率切格"的来源@ Wikimedia Commons · Rotocoasa(CC BY 4.0)

顺带交代官方对刷新率是怎么选的:SF 按"活跃层"(会排队帧更新的层)来决定刷新率;如果层的属主给该层设了帧率,SF 会尽量把刷新率设成那个帧率的整数倍——官方举的例子是:两个活跃层分别设 24 与 60,SF 会选 120 Hz(如果设备支持)。这条决策的完整逻辑与多档切换属 ep11(需真机),本篇只取"本机单档 60 Hz"这一个前提。

如果对齐做不好会怎样?会撕裂(tearing)——上一帧还没画完,扫描线已经把它读出去了。同步机制存在的意义就是把这种"边写边读"隔开。

送屏时机错位的后果
图 6-18送屏时机错位的后果:未对齐时同一屏上会同时出现两帧的内容(撕裂),对齐后一屏只有一帧@ Wikimedia Commons · Darklanlan(CC0)

这张图也得说清边界:它是通用显示环境的实拍,不是 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 五段时间量的定义轴

一轮刷新的五个时间量定义轴
图 6-19一轮刷新的五个时间量定义轴:desired → post → acquire → latch → present,相邻两点之间就对应一个量名@ 本系列绘制

这张表最有用的一列是最后一列:它把五个名字从"看起来很像的一堆缩写"变成"五种不同的问题"。想查"应用交得晚不晚"就看 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 时间量面板

本机五段时间量
图 6-20本机五段时间量的 P50 / P90 / P99: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 三口径自洽核对

三口径自洽核对
图 6-21三口径自洽核对:报告的 34.122 fps 与"帧数 ÷ 时间"推出的 34.47 fps 相差约 1%,间隔样本 375 与帧数 377 也对得上——三个口径互不矛盾,说明解析没问题@ 本机实测(Pixel_7 AVD,Android 16)

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 直方图怎么读

直方图怎么读
图 6-22直方图怎么读:P50 看典型、P90 看波动、P99 与尾部桶看卡顿——平均值会把长尾吃掉,这是 --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 的四步法
图 6-23--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 本机做不到的那件事

强制 GPU 合成被拒的真实返回
图 6-24本机执行官方那条"强制 GPU 合成"命令的真实返回: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 四处说法一览

先把四处说法汇总成一张对照表:每条都给"原文、它错在哪一步、拿什么去核、本篇最终怎么落笔"四列。这样读者不用翻正文也能一眼看到四处结论各自站哪、依据是什么;逐条独立核验,不把几条说法含糊捏成一段。

四处流传说法的逐条对照
图 6-25四处流传说法的逐条对照:原文、出错点、依据、本篇写法四列并排,逐条独立核@ 本系列绘制

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 每条说法"错在哪一步"的证据链

四处说法的证据链
图 6-26四处说法"错在哪一步"的证据链:每条的出错点都对应到一个可复现的本机读数或一句官方原文@ 本系列绘制

原理上讲,这四处错的是同一件事:把"机制的名字"当成了"机制的实现"。合成、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 四项可执行动作与判据面板

四项可执行动作与判据
图 6-27四项可执行动作与判据:查归属、量节拍、判断静置、读分段——每条命令配一个"何时才算成立"的判据@ 本系列绘制

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.


参考资料

  1. SurfaceFlinger and WindowManager
  2. Implement Hardware Composer HAL
  3. Hardware Composer
  4. Implement VSync
  5. Multiple refresh rate
  6. Graphics architecture
  7. Graphics(总览)
  8. Surface 与三重缓冲(arch-sh)
  9. Tone mapping(含 RenderEngine 定义与 1008 调试命令)
  10. Systrace(一轮刷新的六步)
  11. Winscope overview
  12. Winscope · analyze SurfaceFlinger
  13. Winscope · capture via adb
  14. Perfetto · FrameTimeline
  15. Perfetto · ATrace
  16. Perfetto · SQL tables
  17. Perfetto · Android log
  18. Android · Frame rate(timestats 官方口径)
  19. Android · Frame rate 与应用侧 setFrameRate
  20. Android · dumpsys 工具
  21. Android Studio · jank 检测
  22. frameworks/native/services/surfaceflinger/SurfaceFlinger.cpp
  23. CompositionEngine/src/CompositionEngine.cpp
  24. CompositionEngine/src/Output.cpp
  25. CompositionEngine/src/OutputLayer.cpp
  26. DisplayHardware/HWComposer.cpp
  27. libs/gui/include/gui/JankInfo.h
  28. libs/renderengine/(RenderEngine 源码目录)
  29. AOSP 课程 · SurfaceFlinger
  30. DeepWiki · CompositionStrategy
  31. juejin · CompositionEngine 逐行解读
  32. androidperformance · Perfetto 08 Vsync
  33. eyan0221 · SurfaceFlinger 读数格式
  34. Wikimedia Commons · Vsync diagram
  35. Wikimedia Commons · Display refresh rate vs video FPS
  36. Wikimedia Commons · Screen tearing on DOSV