本文由@溯光 出品,转载请注明出处 站点链接:技术博客页 | 公众号:溯光的图形笔记
核心问题:前面十一篇里,"屏"这个字一直是默认成立的——它在那儿,有刷新率,有 vsync,有一棵挂在它下面的层树。
这一篇问一个更基础的事:如果一块屏根本就不存在呢?
开场先看两个同框的现场读数,都来自本机。第一个来自 SurfaceFlinger:
Display 4619827259835644672 (HWC display 0): port=0 pnpId=GGL displayName="EMU_display_0" Display 11529215048127873138 (Virtual display): displayName="ScreenRecorder" uniqueId=""屏从一个变成了两个。 而第二块的类型写作
(Virtual display),名字是ScreenRecorder——它是被screenrecord现造出来的一块屏,硬件上并不存在。第二个现场来自另一个服务,同一时刻:
MEDIA PROJECTION MANAGER (dumpsys media_projection) Media Projection: null同一个"录屏"动作,一边多出一块屏,另一边一条记录都没有。 两台账本记的是同一件事,却各记了一半——其中一本还什么都没记。
这一篇就把这本"半边账"翻完:先立"它不是一块屏、却被当成一块屏",再拆"它借谁的节拍",最后落到"录制到底录了什么"。
承接上一篇。ep11 量的是"真实那块屏有几个档":supportedRefreshRates [60.000004]、hasArrSupport false、refreshRateSwitches=0。可那篇通篇默认了一件事——"屏"是硬件上插着的那一块。 这一篇把那个默认打开:如果那块屏是凭空造出来的,前面十一篇里那些"屏的属性"还成立吗?
这件事在前面几篇里都挂过账。ep09 讲节拍派发时留过一句话——虚拟显示没有自己的 vsync,那句话当时只是顺手一提,本篇给它正式落地;ep10 §0.3 把"显示识别与 display id"挂在 ep11,ep11 又把它推到了 ep12;ep04 讲了 BufferQueue 的四态迁移,但没讲"生产者可以不是应用"这种情形——而 screenrecord 恰好就是反过来的:SurfaceFlinger 当生产者。这一篇来还这三笔债。
四篇共用同一条链路,各篇量的东西仍然不同:
| 维度 | ep04《BufferQueue》 | ep09 / ep10《Vsync》 | ep11《三缓冲与 ARR》 | ep12(本篇) |
|---|---|---|---|---|
| 关心的对象 | 缓冲区的流转 | 节拍的来源与相位差 | 队列有多深 × 屏幕有几个档 | 一块虚拟屏的档案 × 它的合成路径 × 录制录了什么 |
| 量什么 | 四态迁移与引用计数 | 派发对象、速率、三档相位 | 槽位数、BufferStuffing、三个区间、投票汇总 | display 数、六行档案、层数 104→105→104、两本账的差 |
| 主要命令 | dumpsys SurfaceFlinger 的 Layer 段 |
Scheduler 相位段 |
Slots: 段 + --timestats + DMD 日志 |
--display-id + ScreenFlinger 层树 + screenrecord --verbose + dumpsys media_projection |
| 典型结论 | 四态迁移与 fence 语义 | 本机三档全 1 ms | 槽位只有两格;refreshRateSwitches=0 |
虚拟显示六行档案里有 isGpu=true;14 秒只录到 2 帧 |
本篇也不涉及:无线投屏协议栈(Miracast / WiDi / Cast)的握手与编解码协商(未公开)、MediaCodec / Codec2 编解码器内部实现(属媒体专题)、应用侧 Choreographer 的调度细节(ep02)、厂商私有的多屏与投屏策略(折叠屏形态切换、厂商投屏白名单)、逐帧 jank 时序与 Perfetto 用法(本系列 ep13 与 T01 的工具链专题)。为什么不涉及:这一篇的价值集中在"一块不存在的屏"这一个点上——把"它凭什么算一块屏、借谁的节拍、录了什么"这三问讲透,掺进协议栈与编解码内部就讲不透了。完整边界见 §0.3。
系列文章写作风格说明(沿用前十一篇,不再重复解释)
- 术语双语:首次出现的核心术语写成「中文(English)」,后文可只用英文;
- 三层分开说:官方文档怎么写、AOSP 源码怎么做、设备上读出来是什么,这三层分开陈述,不混成一句;
- 读数标口径:每一个数字都标来源与条件(机型、Android 版本、命令、采样时刻);
- "谁的账本"要先问:这是本篇最要紧的一条纪律——同一时刻,不同的服务对同一件事的记录口径可以完全不同。凡引用一处读数,先标明它是哪个服务印的、命令是什么、我比对的是它的哪一段;
- 模拟器结论不外推:本篇所有读数来自一台 Android 16 模拟器。凡是"真机上会怎样"的话,本篇一律标为转述官方或标为待验证。
系列主要内容总览
- 【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
相关资源链接
- 官方文档:Layers and displays(AOSP) | Implement Hardware Composer HAL(AOSP) | Graphics architecture(AOSP) | Multiple displays(AOSP)
- 应用侧:Media projection(Android Developers) |
MediaProjection|DisplayManager|VirtualDisplay - 工具:adb 的
screenrecord(Android Developers) | bigflake《Android screenrecord stuff》 - 源码:
frameworks/native/services/surfaceflinger/下的DisplayDevice.h、DisplayHardware/VirtualDisplaySurface.h
这一篇回答什么,不回答什么
好了,先把坐标系挪正。前面十一篇给了管线、叫醒链、交接、邮局、入口、关口、改动、归属、节拍、错开、队列深度;这一篇要还的是"屏"这个词的账——我们一直在说"上屏""送到屏上""屏的刷新率",可从来没问过"这块屏是什么"。这一篇问的,就是最基础的那一层。
先说清它跟前一篇的关系:ep11 结尾把"多刷新率"这一侧量完了,给出的关键结论是档位表只有一行:supportedRefreshRates [60.000004]。它用的命令是 dumpsys display。而这一篇会发现,同一台设备上还有第二个地方能列出显示清单,那里的口径不一样——dumpsys SurfaceFlinger --display-id 在录屏期间会多印一行。这就有意思了:同一时刻,两处列的是同一批显示,行数却不同。 这正是本篇要拆的第一件事。
0.0 内容总览:本篇知识地图
好了,先把十一章各自回答的问题钉在一张表上。它同时也是本篇的目录总览。如果时间紧,第三章(合成走哪条路)、第四章(六行档案)、第六章(镜像的三处标记)是这篇最不可替代的三章:第三章给"它凭什么能合成",第四章给"设备怎么记它",第六章给"它到底挂在哪棵树上"。
| 章 | 本章问句 | 关键读数或原文 |
|---|---|---|
| 零 | 这一篇回答什么、不回答什么 | Display ... (Virtual display) / Media Projection: null |
| 一 | 虚拟显示是什么、算不算一块屏 | "rendered into an off-screen buffer";display 数 1 → 2 |
| 二 | 它为什么没有自己的 vsync | "There's no VSync for a virtual display";@60.00fps |
| 三 | 它的画面是谁合出来的 | isGpu=true;GLES / MIXED / HWC 三模式 |
| 四 | 设备怎么记这一条屏 | 六行:name / powerMode / optimizationPolicy / isGpu / uniqueId |
| 五 | 录屏有哪两条路 | screenrecord --help;三态全 null 的 media_projection |
| 六 | 镜像在 SF 里留下什么标记 | (Mirroring) …#45/mirrored from 349/CLONE;层数 104→105→104 |
| 七 | 为什么两处读数对不上 | 同一段 6007 字节前后逐字节相同;句柄两次不同 |
| 八 | 录制到底录了什么 | recorded 2 frames in 14 seconds;107,703 / 107,137 字节 |
| 九 | 它这些年在往哪走 | 4.4 → 5 → 10 → 12L → 14 → 15 QPR1 |
| 十 | 哪些只能上真机 | --latency 三探皆 16666666;缺口分档 |
0.1 本篇那个"反直觉的对照"
好了,先把这个对照摆出来,因为它撑住了整篇的骨架。
第一件事:这块屏是"造"出来的,而造它的命令只干了一件事——录屏。
本机执行 adb shell screenrecord --time-limit 24 /sdcard/ep12_rec.mp4,在它跑起来的那十几秒里,SurfaceFlinger 的显示清单变成了两行:
Display 4619827259835644672 (HWC display 0): port=0 pnpId=GGL displayName="EMU_display_0"
Display 11529215048127873138 (Virtual display): displayName="ScreenRecorder" uniqueId=""
OK,这里有两处细节值得停一下。第一处是类型的写法:物理那块写 (HWC display 0),虚拟那块写 (Virtual display)——括号里的东西不一样,一个带编号,一个不带。第二处是 displayName:物理那块叫 EMU_display_0(模拟器给的),虚拟那块叫 ScreenRecorder——这个名字是创建者起的,不是设备起的。名字跟着请求方走——设备只是照记。
第二件事:同一个时刻,另一个服务里什么都没有。
MEDIA PROJECTION MANAGER (dumpsys media_projection)
Media Projection:
null
录前是 null、录中是 null、录后还是 null。screenrecord 明明在跑,屏幕上明明有一块虚拟屏,而 MediaProjection 服务里一条投影记录都没有。
说白了,这两件事合起来就是本篇的主轴:"虚拟显示"这个东西,不是一处登记、处处可见的;它是各服务按自己的口径各记各的。 你要读它,得先知道你在读谁的账本。
0.2 与相邻篇目的分工
| 篇 | 量什么 | 留给本篇什么 |
|---|---|---|
| ep09 | 节拍的来源与派发对象 | 留了一句"虚拟显示没有自己的 vsync",本篇给它落地 |
| ep10 | 三路节拍错开多少 | 留了"显示识别与 display id"这笔账 |
| ep11 | 真实那块屏有几个档 | 留了"dumpsys display 与 dumpsys SurfaceFlinger 两处口径不同"这条线 |
| ep13 | 逐帧时序:Perfetto FrameTimeline 抓 jank | 本篇不碰逐帧时序,只碰"帧有没有被录到" |
0.3 边界声明:本篇不涉及什么,以及关键参数
本篇不涉及(只给指针、不配图):
| 不涉及的内容 | 为什么不写 | 去哪看 |
|---|---|---|
| 无线投屏协议栈(Miracast / WiDi / Cast)的握手与编解码协商 | 未公开;本篇只写 AOSP 通用链路 | 各协议规范原文 |
| MediaCodec / Codec2 编解码器内部 | 属媒体专题;本篇只到"帧进了编码器" | T0x 媒体专题 |
| 应用侧 Choreographer 的调度 | 已由 ep02 讲完 | ep02 |
| 厂商私有的多屏与投屏策略 | 未公开 | — |
| 逐帧 jank 时序与 Perfetto 用法 | 本系列 ep13 与 T01 | ep13 |
关键参数与观测条件(凡引用本篇读数,条件一律照此):
| 项 | 值 |
|---|---|
| 设备 | emulator-5554(Pixel_7 AVD · Android 16 / API 36 · ranchu · user 构建) |
| 基线显示 | Display 4619827259835644672 (HWC display 0): port=0 pnpId=GGL displayName="EMU_display_0",一个 |
screenrecord |
/system/bin/screenrecord,v1.4;命令面见 §5.2 |
| 实验形态 | 一次完整的"录前 → 录中 → 录后"三段对照,共做两轮(第一轮 24 秒、第二轮 14 秒) |
| 本机台账 | ep12_raw/(35 份,正文所有读数以此为准) |
0.4 本篇怎么读:三条判据贯穿全篇
- 虚拟显示是"另一块屏",但它不是一块"屏"。 SF 给它注册 display、给它名字与档案(第一章、第四章),而另一处的账本对它毫无记录(第七章);它的画面走 GPU 而不是 HWC(第三章)。读它之前先问:这是谁的账本。
- 它没有自己的 vsync。 官方原话(第二章)+ 本机
screenrecord从内屏拿到@60.00fps(第二章)。因此"虚拟屏的帧率是多少"这个问题本身要改写:先问节拍是谁的、再问它被要求对齐到谁。 - 屏幕录制不是"按帧率录",是"有变化才有帧"。 本机 14 秒录到 2 帧(第八章);两轮相差 10 秒而文件只差 566 字节。所以"录屏 60 fps"与"内容 60 fps"是两件事。
外加一条跨篇经验复用:dumpsys SurfaceFlinger --latency 认不出的名字,只回一个周期值(第十章)——这条坑是 ep09 上线后修正过、ep11 复现过一次的,本篇用三个不同的名字再复现一遍,并识别出那个数就是 60 Hz 的周期。
本章的源码与文档:Layers and displays(AOSP) | Implement Hardware Composer HAL(AOSP)。这一章的定义可以收成一句:虚拟显示是一块内容写进离屏缓冲、不送物理屏的显示,而它在各服务账本上的记录口径并不统一。其中:坐标系是输入,三个问题(凭什么算一块屏、借谁的节拍、录了什么)是输出,而"先问这是谁的账本"就是本篇的量尺。
一、虚拟显示是一块不存在的屏
OK,第一章先把"它是什么"讲清楚。注意这一章的证据分三层:官方文档怎么写、AOSP 源码怎么做、本机读出来是什么——三层分开说。
1.1 官方给的定义,以及它的输出去哪了
先来看看官方那句话,它短得出奇:
"Clients request virtual displays, whose contents are rendered into an off-screen buffer instead of to a physical display."
——AOSP《Layers and displays》
"instead of to a physical display"——这一句就把性质定死了:它的内容不往物理屏上送,而是往一块离屏缓冲(off-screen buffer)里送。 那这块缓冲去哪了?同页紧接着回答:
"Virtual displays can be used to record the screen or send the screen over a network. Frames generated for a virtual display are written to a BufferQueue."
——同上
写进一条 BufferQueue。 这就和 ep04 接上了:ep04 讲的是"缓冲区流转的邮局",说的是应用是生产者、SF 是消费者;而虚拟显示这条队列是反过来的——同页另一句说得更直白:
"The
screenrecordutility then asks SurfaceFlinger to create a virtual display that mirrors the main display … and directs it to send output to the surface that came from themediaserverprocess. In this case, SurfaceFlinger is the producer of buffers rather than the consumer."——同上
这是本篇第一个"换位":ep04 到 ep11 里,SF 一直是收帧的那一方;到了虚拟显示这条路,SF 变成了产帧的那一方——它把合成结果写进队列,交给编码器去消费。而这条队列的另一端,是 mediaserver 进程。
边界声明:这是一张结构示意图,画的是 AOSP 文档描述的两条去向,不含具体厂商实现;图上的"编码器/网络发送端"是文档里点到的两类 consumer 的统称,不是某个具体进程名。
本章的源码与文档:Layers and displays(AOSP) | Graphics architecture(AOSP)。这一章的定义可以收成一句:虚拟显示是一块内容写进离屏缓冲、不送物理屏的显示。其中:定义管"去哪",用途管"干什么",而"谁生产"这件事决定了它和 ep04 那条队列的方向相反。
1.2 本机读出来是什么:显示清单从一行变两行
好了,定义讲完了,上设备看。本机录屏期间,dumpsys SurfaceFlinger --display-id 的输出是两行:
Display 4619827259835644672 (HWC display 0): port=0 pnpId=GGL displayName="EMU_display_0"
Display 11529215048127873138 (Virtual display): displayName="ScreenRecorder" uniqueId=""
逐字段读:
- 显示数从 1 变 2。录前的清单只有第一行,录后也回到一行——这块屏是随录制而生、随录制而灭的;
- 新来那块的类型写作
(Virtual display),不是(HWC display N)——括号里没有编号; - 名字是
ScreenRecorder,由创建者给的; uniqueId=""(空串)——这一条值得单列,因为它和官方文档给的形式不一样,第四章和第七章都会回到它;- 句柄
11529215048127873138是一个 64 位量级的大数(0xa00000007ac00072)。第二轮录制时它变成了11529215049275252095(0xa0000000bf239d7f)——两次创建,两个句柄。
Virtual display,名字是创建者起的 ScreenRecorder@ 本机 dumpsys SurfaceFlinger --display-id 实测(emulator-5554)1.3 外围对位:把屏幕送到另一处
下面进入另一面。这一节换一个视角。虚拟显示的用途官方写了两条——录屏和把屏幕经网络送出去。后一条在现实里的样子,就是"投屏"。
边界声明:这张图是无线投屏的通用示意,讲的是"屏幕内容送到另一处"这个现象;它不是 Android 的实现图,也不含 Miracast / WiDi 的协议细节(本篇不涉及,见 §0.3)。本篇只借它说明虚拟显示的第二类用途长什么样。
本章的源码与文档:Implement Hardware Composer HAL(AOSP) | Multiple displays(AOSP)。这一章的结论可以收成一句:虚拟显示是内容写进离屏缓冲、不送物理屏的一类显示。其中:官方给"定义与两类用途",本机给"它确实被登记进显示清单",而外围对位给"送出去"长什么样。
二、它为什么没有自己的 vsync
好了,第一章立了"它是一块屏"。这一章就碰那个最要紧的问题:这块屏的节拍从哪来? 前面十一篇里,"节拍"这件事有独立的章——ep09 讲它从哪来、派给谁,ep10 讲三路错开多少。到了虚拟显示这里,节拍这件事变得很短:因为它没有自己的。
2.1 官方原话:内屏的 vsync 触发所有显示的合成
先来看看官方原话。AOSP《Layers and displays》里有一句,是 ep09 那条伏笔的官方答案:
"There's no VSync for a virtual display, so the VSync for the internal display triggers composition for all displays."
——AOSP《Layers and displays》
这句话得逐段读,因为它同时回答了三个问题:
- "There's no VSync for a virtual display"——虚拟显示没有自己的 vsync 信号;
- "the VSync for the internal display"——节拍还是内屏那条,就是 ep09 讲的那条
VSYNC-sf; - "triggers composition for all displays"——一次节拍触发所有显示的合成,不是一块屏一条节拍。
所以虚拟屏的合成不是被"它自己的"心跳唤醒的,而是被内屏的心跳顺带唤醒的。 这解释了一个很容易被忽略的事:虚拟屏的合成节奏,天然是跟着内屏走的。
2.2 本机拿到的是内屏的 60.00 fps
OK,官方话讲完了,看设备印出来的。screenrecord --verbose 在起录时打的第一行是:
Display is 1080x2400 @60.00fps (orientation=ROTATION_0), layerStack=0
注意它拿到的 @60.00fps 是谁的。 设备上只有一块物理屏,它的档位表里也只有一行(ep11 量过:supportedRefreshRates [60.000004])。虚拟屏自己没有档位表——它没有自己的 vsync,也就无从谈自己的帧率。所以这个 60.00 是内屏报给它的,不是它自己的。
同一份日志的另外两行也印证了这条链:
Configuring recorder for 1080x2400 video/avc at 20.00Mbps
Content area is 1080x2400 at offset x=0 y=0
video/avc at 20.00Mbps—— 编码格式与码率(与官方默认 20Mbps 一致);layerStack=0—— 录的是主屏的层栈;Content area is 1080x2400 at offset x=0 y=0—— 内容区就是整个屏幕,偏移为零。
screenrecord 的日志抬头:它拿到的 @60.00fps 来自内屏,layerStack=0 说明录的是主屏层栈@ 本机 screenrecord --verbose 实测(emulator-5554)2.3 "虚拟屏的帧率是多少"这个问题要改写
好了,把前两节合起来,能得到本篇第二件反直觉的事:"虚拟屏的帧率是多少"这个问法本身有问题。
为什么?因为"帧率"这个词在两个不同的地方指两件事:
| 说法 | 指的是什么 | 本机的值 |
|---|---|---|
| 虚拟屏的"帧率" | 它被要求对齐到的节拍(来自 pacesetter 屏) | 内屏的 60 Hz |
| 录到文件的"帧率" | 实际发生变化的次数 | 14 秒 2 帧(第八章) |
AOSP 的源码里还给了第三条线索——DisplayDevice.h 里有一条字段,注释写得很明确:
"// Requested refresh rate in fps, supported only for virtual displays. when this value is non zero, SurfaceFlinger will try to drop frames for virtual displays to match this requested refresh rate."
"// Round the requested refresh rate to match a divisor of the pacesetter display's refresh rate. Only supported for virtual displays."
——AOSP
services/surfaceflinger/DisplayDevice.h
这条很少被提到:虚拟显示有一条"请求刷新率",而且非零时 SF 会主动丢帧去凑它——虚拟屏也不是"来一帧合一次",它可以被人为限流。而且这条数值会被"取整到 pacesetter 屏帧率的约数上"——它借的不只是节拍的方向,连节拍的粒度都是借的。
一处如实登记:本机
dumpsys SurfaceFlinger里那条虚拟显示的档案只有六行(第四章),不含 requested refresh rate 这个字段。这条字段的存在来自源码注释,而不是来自本机读数——本篇按"源码层"陈述,标为"本机未印出"。
本章的源码与文档:Layers and displays(AOSP) | AOSP 镜像 · DisplayDevice.h。这一章的定义可以收成一句:虚拟显示没有自己的 vsync,它的合成由内屏节拍触发,且它的"请求刷新率"只被允许落在 pacesetter 屏帧率的约数上。其中:官方管"节拍从哪来",源码管"能不能限流",本机日志管"实际拿到的是 60.00"。
三、合成走哪条路:GLES / HWC / MIXED
好了,前两章说了"它是一块屏"和"它借谁的节拍"。这一章说的是最实的一层:它的画面,到底是谁合出来的。
3.1 三种模式,以及随模式变的输出格式
先来看看官方的分法。官方对虚拟显示的合成给了两条分支,写得像一段伪代码:
"Virtual displays are created by SurfaceFlinger calling
createVirtualDisplay()to request a display. If the HWC supports virtual display composition, it returns a handle. Then, SurfaceFlinger delegates the display's composition to the HWC. If the HWC doesn't support virtual display composition, SurfaceFlinger creates the handle and composites the display."——AOSP《Implement Hardware Composer HAL》
注意这两条分支的差别:差别不在"能不能合成",而在谁来合成——HWC 支持就交给 HWC,不支持就 SF 自己来。而"SF 自己来"这条路上,合成是靠 GPU 做的。
同一页还给了每帧的三种模式,判据是 SF 调用 validateDisplay() 之后的结论:
"Each frame is in one of three modes after SurfaceFlinger calls the
validateDisplay()HWC method: GLES … MIXED … HWC — HWC composites all layers and writes directly to the output buffer."——同上
而输出格式随模式变,这一点常被忽略:
"GLES mode — The EGL driver sets the output buffer format in
dequeueBuffer(), typicallyRGBA_8888… MIXED and HWC modes — If the consumer needs CPU access, the consumer sets the format. Otherwise, the format isIMPLEMENTATION_DEFINED…"——同上
把这三段收成一张表:
| 模式 | 谁在合 | 输出缓冲的格式谁来定 | 本机这条走的是它吗 |
|---|---|---|---|
| GLES | GPU(SF 自己合) | EGL 驱动在 dequeueBuffer() 里定,典型 RGBA_8888 |
是(§3.2 的 isGpu=true) |
| MIXED | 一部分 HWC、一部分 GPU | 若消费者要 CPU 访问则由消费者定,否则 IMPLEMENTATION_DEFINED |
未印出 |
| HWC | 全部 HWC,直接写输出缓冲 | 同上 | 否 |
这张图怎么读:一列一列往下看——先看"由谁合成",再看"输出缓冲的格式谁说了算"。本机落在第一列,下一节的 isGpu=true 就是它的判据。要提醒的是:这三列不是"性能由高到低的排序",而是 SF 调用 validateDisplay() 之后对这一帧给出的判定结果——同一台设备的相邻两帧,也可能落在不同的列里。
3.2 本机这条走的是 GPU:isGpu=true
好了,官方把三条路摆出来了,那本机这条走哪条? 设备自己回答了。录屏期间那条虚拟显示的档案里,有一行是:
isGpu=true
这一行的意思很直白:这条虚拟显示是用 GPU 合成的。 它正对上 §3.1 的三种模式之 GLES 那一支,而不是 HWC。
这条读数的价值在于——它把一句本来只停留在"业界经验"的结论,落到了设备自报的字段上。同类文章里有一条被反复引用的实测:录屏时帧率从 60 掉到约 31、Draw 与 Process 耗时翻倍,作者把因果链写成"虚拟屏合成通过 GLES 调用 GPU 完成……物理屏合成耗时很小,它通过 HWC 合成"。那条结论的设备侧字段级落点,就是这一行 isGpu=true。
口径提醒:
isGpu=true是这台模拟器上这一条虚拟显示的取值。真机上 HWC 支持虚拟显示合成时,这一行可能是别的值——本篇不外推。
isGpu=true 正对上官方三模式中的 GLES 一支——物理屏走 HWC、虚拟屏走 GPU,合成代价因此不同@ 本机 dumpsys SurfaceFlinger 实测(emulator-5554)3.3 MIXED 帧为什么要一块"草稿纸"
说白了,三种模式里,MIXED 是最别扭的一种,AOSP 的源码注释专门解释了它为什么别扭:
"On MIXED frames, things become more complicated, since some h/w composer implementations can't read from and write to the same buffer. This class has an internal BufferQueue that it uses as a scratch buffer pool."
——AOSP
VirtualDisplaySurface.h
"can't read from and write to the same buffer"——有些硬件合成器不能一边读一边写同一块缓冲。于是这一类帧就需要一块额外的"草稿纸":先把中间结果写进 scratch,再从 scratch 读到输出缓冲。
顺便,这段注释的上一句还把"必须提供几个缓冲"说清了:
"If h/w composer might be used, then each frame will fall into one of three configurations: GPU-only, HWC-only, and MIXED composition. In all of these, we must provide a FB target buffer and output buffer for the HWC
set()call."——同上
两类缓冲:一个 FB target(framebuffer 目标)、一个 output。而 GLES 与 HWC 这两支各只有一个"主力"缓冲区被真正用到——这也是为什么 scratch 只在 MIXED 下才成为必需品。
VirtualDisplaySurface.h 类头注释)本章的源码与文档:Implement Hardware Composer HAL(AOSP) | AOSP 镜像 · VirtualDisplaySurface.h。这一章的定义可以收成一句:虚拟显示的每一帧都要先被判定走 GLES / MIXED / HWC 三者之一,模式不同则由谁合成、输出什么格式都不同。其中:官方给三条路与各自格式,源码给 MIXED 为什么需要 scratch,本机给出的则是"这条走的是 GLES"。
四、一条虚拟显示的档案
好了,第三章讲了机制,这一章开始查实——设备把这一条虚拟显示记成了什么样。
4.1 六行,就是全部
先来看看设备到底记了什么。录屏期间,dumpsys SurfaceFlinger 里多出一段。它只有六行(含标题行):
Virtual Display 11529215049275252095
name="ScreenRecorder"
powerMode=On
optimizationPolicy=optimizeForPower
isGpu=true
uniqueId=
这就是设备为这块屏记下的全部。 没有分辨率、没有刷新率、没有 layerStack、没有它下面挂着几层——和物理屏那份长篇档案相比,它简朴得几乎不像一块屏。
顺便值得对照的是物理屏那一段,紧跟在它后面:
Display 4619827259835644672 (physical, "EMU_display_0")
Composition Display State:
两个关键词的差别:虚拟那段的标题是 Virtual Display <句柄>,物理那段的标题是 Display <句柄> (physical, "<类型名>")。一处写 Virtual、一处写 physical——设备自己在类型上就把两者分开了。
dumpsys SurfaceFlinger 实测(emulator-5554)这张面板怎么读:左边是设备原样印出的行,右边是我数出来的行号——一共六行。读它有一条捷径:先看有没有 isGpu 与 optimizationPolicy 这两行,本篇第三章与本章的结论都挂在它们上面;uniqueId 那一行留到最后再看,因为它是空的——而"空"本身就是一条读数。
4.2 逐行读:哪几行是真有信息的
OK,六行里,有两行是别处看不到的。逐行过一遍:
| 行 | 值 | 它说明了什么 |
|---|---|---|
name |
"ScreenRecorder" |
名字是请求方给的;同一块屏换个请求方就会换个名字 |
powerMode |
On |
虚拟显示也有电源模式;录屏期间它是点亮的 |
optimizationPolicy |
optimizeForPower |
它带一条优化策略,值为"为省电优化" |
isGpu |
true |
合成走 GPU(第三章) |
uniqueId |
(空) | 与文档给的形式不符(§4.3) |
optimizationPolicy 这一行值得多说两句:它公开资料里极少被提到,本机却自己把它印了出来。"虚拟显示要省电"这件事,从字段名上就能读出来——它不是"尽力跑快",而是"为功耗优化"。这与 §3.2 里那条"虚拟屏合成代价更高"的说法放在一起看很有意思:代价更高,所以策略偏省电,逻辑上是通的。
dumpsys SurfaceFlinger 实测(emulator-5554)4.3 文档给的形式,与设备印出的空串
好了,现在回到那处落差。官方在讲 display ID 演进时,给过 uniqueId 的一种具体形式:
"Android 10 起显示获得稳定持久的 ID;
uniqueId以virtual:<package-name-and-name>形式出现。"——AOSP《Multiple displays》(转述)
照这个说法,虚拟显示的 uniqueId 应该长得像 virtual:com.example.app/MyName 这样。 而本机印出来的是:
uniqueId=
空串。 一边是文档给的形式,一边是设备印出的空白——两者对不上。
本篇不臆测成因。 最省事的解释当然是"shell 侧的 screenrecord 没有包名可拼",但那只是猜测:我没有取到构造这一字段的源码原文,也没有在真机上对照过应用侧 MediaProjection 建出来的虚拟显示长什么样。 因此本篇把它作为一处如实登记的落差:文档给了一种形式,本机印了另一种,待复核。
virtual:<包名与名称>,一边是空白——这处落差本篇如实登记,不臆测成因@ 本系列绘制(左栏依据 AOSP 文档,右栏依据本机 dumpsys SurfaceFlinger)4.4 成因未明就登记成因未明
说白了,这一节很短,但它是本篇的一条纪律。
从第一章到第四章,本篇已经登记了两处成因未明的读数:uniqueId 为空、以及(第五章会讲到的)两轮录制句柄不同。对这两处的态度是同一套:
- 记录"设备印了什么"——这是可核验的事实;
- 记录"文档怎么写的"——这也是可核验的;
- 两者的差如实登记;
- 但不写"因为……所以……"——因为那需要源码原文或真机对照,而本篇两样都没有拿到。
这条纪律不是保守,它是有代价换来的。 前几篇里就出过一次教训:一处读数的成因写得像结论,后来被源码核对推翻,只好在上线后发修正。"我读到了什么"和"它为什么这样"是两句话,不能合成一句。
本章的源码与文档:Multiple displays(AOSP) | SurfaceFlinger 与 WindowManager(AOSP)。这一章的结论可以收成一句:设备为一条虚拟显示只记六行,其中 optimizationPolicy 与 isGpu 两行是别处读不到的。其中:官方管"ID 该长什么样",本机管"实际印出了什么",而两者的落差按纪律登记不解释。
五、屏幕录制的两条路
好了,前四章讲的都是"这块屏本身"。这一章换个问法:是谁把它造出来的? 答案有两个,而且它们走的路几乎不相干。
5.1 分水岭:shell 工具与应用 API
先来看看这两条路各自怎么走。本篇测的是第一条路——shell 侧的 screenrecord。它是设备的系统工具,通过 shell 侧的通道让 SurfaceFlinger 建虚拟显示,不需要任何应用参与。
第二条路是应用侧的 MediaProjection。官方对它的定位是:
"The
android.media.projectionAPIs introduced in Android 5 (API level 21) enable you to capture the contents of a device display as a media stream…""A media projection captures the contents of a device display or app window and then projects the captured image to a virtual display that renders the image on a
Surface."——developer.android.com《Media projection》
注意最后半句:应用侧也是建一块虚拟显示,只是创建者是应用、入口是 MediaProjection。官方把这个入口说得很清楚:
"The centerpiece of a media projection is the virtual display, which you create by calling
createVirtualDisplay()on aMediaProjectioninstance"——同上
两条路通向同一个东西,但入口完全不同——这一点在第七章会有个很干净的旁证:screenrecord 在跑,而 MediaProjection 服务里一条投影都没有。
shell 路:screenrecord(shell 工具)→ 请求 SurfaceFlinger 建虚拟显示 → 不需要应用参与
应用路:应用 → MediaProjectionManager → MediaProjection → createVirtualDisplay() → 虚拟显示
还有一条权限边界值得记下,它解释了"为什么应用造不出自动镜像":
"Creating an auto-mirroring virtual display requires the
CAPTURE_VIDEO_OUTPUTorCAPTURE_SECURE_VIDEO_OUTPUTpermission. These permissions are reserved for use by system components and are not available to third-party applications. Alternatively, an appropriateMediaProjectionmay be used…"——developer.android.com
DisplayManager
"自动镜像"这件事被权限挡住了:第三方应用不能自己造一块自动跟随主屏的虚拟显示,要么用 MediaProjection,要么什么也做不了。
screenrecord 直接让 SurfaceFlinger 建虚拟显示;应用侧要先拿 MediaProjection、再 createVirtualDisplay()——入口不同,账本也不同@ 本系列绘制这张图怎么读:只看两条路的起点——上面那条从 shell 出发,下面那条从应用出发,它们在图的右侧汇合到同一个虚拟显示实体上。汇合点之后的流程(合成、上屏、被录下来)两条路完全一样,差别全部在入口这一侧:谁有权建这块屏、要不要经过用户同意。
5.2 screenrecord 自己的账:参数面与硬限制
好了,第一条路上的工具是 screenrecord。本机是 v1.4,它的参数面在官方文档里列得很清楚,本机的 --help 也印了一遍:
| 参数 | 作用 | 官方给的默认值或边界 |
|---|---|---|
--size |
输出分辨率 | 未指定则用设备原生分辨率 |
--bit-rate |
码率 | "Default 20Mbps." |
--time-limit |
时长上限 | "Default is 180. Set to 0 to remove the time limit." |
--rotate |
旋转输出 | "Rotation of the screen during recording is not supported." |
--bugreport |
附一份 bugreport | — |
--display-id |
录哪一块显示 | "specify the physical display ID to record. Default is the primary display. see dumpsys SurfaceFlinger --display-id for valid display IDs." |
--verbose |
打日志 | 本篇的读数就来自它 |
--version |
版本号 | 本机 v1.4 |
--display-id 这一条把本篇和前面几篇直接连起来了:它的取值从 dumpsys SurfaceFlinger --display-id 里查——就是第一章那张两行的清单。录屏录哪块屏,用的就是同一份显示清单。
官方还列了几条硬限制,它们不是性能建议,是"做不到":
"Audio is not recorded with the video file." / "Video recording is not available for devices running Wear OS." / "Rotation of the screen during recording is not supported." / "Some devices might not be able to record at their native display resolution…"
——developer.android.com
adb
注意第一条:录屏不含音频。这不是配置问题,是这条工具的能力边界。
screenrecord 的参数面:其中 --display-id 的取值来自同一份显示清单——录哪块屏,查的就是第一章那两行@ 本机 /system/bin/screenrecord v1.4 实测(emulator-5554)这张面板怎么读:八行参数里只有 --display-id 那一行会把本篇和前面几篇连起来——它的取值来自同一份显示清单。其余参数分两类:一类改输出规格(--size、--bit-rate、--time-limit),一类改行为(--rotate、--bugreport、--verbose)。
5.3 外围对位:屏幕内容跨越机器边界
OK,第二条路(应用侧)本篇测不到——本机没装第三方录屏应用。但它想干的事,在现实里有个更常见的形态:远程桌面。
边界声明:这张图是远程桌面的通用界面,讲的是"屏幕内容在别处被消费"这个现象;它不是 Android 虚拟显示的实现图,也不含任何远程桌面协议细节(本篇不涉及,见 §0.3)。
本章的源码与文档:Media projection(Android Developers) | DisplayManager | adb 的 screenrecord。这一章的结论可以收成一句:造虚拟显示有两条独立的路——shell 工具与媒体投影,前者不需要应用、后者受权限约束。它的原理是:两条路各自持有自己的显示创建权限与账本,谁都不替对方登记——所以同一次录屏,只能在一处的账上读到。其中:官方管"两条路各怎么走",本机管"第一条路的参数面与版本",而账本上的差别留到第七章。
六、镜像在 SF 里长什么样
好了,第五章把"谁造的"讲完了。这一章上设备,看一件更实的事:那块虚拟屏到底挂了什么。 前面几篇都是"读一块屏自己的属性";这一章的读法不一样——要去读一棵层树,看它被改动过哪里。
言归正传。screenrecord 要录的是主屏,而主屏上层的那些窗口并不是"复制"过去的,是 SF 另外挂了一套影子层。这套影子层在 SF 的 dump 里留下了三处标记,我们一处一处来。
6.1 第一处标记:层树上的 (Mirroring) 节点
先来看看第一处。第一处标记出现在 SF 的层树(layer hierarchy)里,长这样:
├─ (Mirroring) Display 0 name="Built-in Screen"#45
├─ (Mirroring) Input Consumer recents_animation_input_consumer#128 parent=4294967295
"Display 0 name=\"Built-in Screen\"" 很好懂——它指的是主屏那棵树。关键词是前缀那个 (Mirroring):它把这一节点标成"这是镜像出来的",而不是"本来就长在这儿"。
有意思的是第二行:连 Input Consumer recents_animation_input_consumer 这样的输入消费者节点,也跟着被镜像了一份。被镜像的不只是"画出来的东西"——整套显示树里该有的角色都被挂了一遍。
(Mirroring) 节点——主屏那棵树被另挂了一套影子节点,连输入消费者也在其中@ 本机 dumpsys SurfaceFlinger 实测(emulator-5554)6.2 第二处标记:每个影子层都带 mirrored from
好了,第二处标记比第一处安静得多——它不在层树上,在每一条层的属性里。录屏期间,有 20 个层带着同一个尾巴:
Layer [69 mirrored from 349,] Wallpaper BBQ wrapper#69
Layer [329 mirrored from 349,] com.google.android.apps.nexuslauncher/…NexusLauncherActivity#329
Layer [104 mirrored from 349,] StatusBar#104
Layer [137 mirrored from 349,] Taskbar#137
Layer [74 mirrored from 349,] ScreenDecorOverlay#74
Layer [77 mirrored from 349,] ScreenDecorOverlayBottom#77
Layer [128 mirrored from 349,] Input Consumer recents_animation_input_consumer#128
Layer [97 mirrored from 349,] IME Handwriting Surface#97
Layer [138 mirrored from 349,] [Gesture Monitor] swipe-up#138
Layer [81 mirrored from 349,] [Gesture Monitor] edge-swipe#81
…(共 20 条)
这 20 条就是"被镜像是谁"的清单:壁纸、桌面、状态栏、任务栏、屏角装饰、手势监视器、输入法surface——日常那条主屏上看得见的每一层,都在里面。
那个 349 是什么? 这里要说准确,因为很容易读错。349 是"镜像根层"的 id,不是 display id。 判据来自同一轮录制的另一份台账——dumpsys SurfaceFlinger --list 的第 105 行:
RequestedLayerState{MirrorRoot-4619827259835644672#349 mirrorId={45,70,72,128,} layerStack=2064014427}
这一行的名字叫 MirrorRoot-<物理屏 id>,它的层号就是 #349——和 20 条层里那个 349 是同一个数。所以正确的读法是:这些层带着"我属于 349 这个镜像根"的标记,而 349 这个数字恰好就是主屏物理 id 4619827259835644672 出现在名字里的那一层。
还有一处必须说明:两轮录制里这个数不一样——第一轮是 347,第二轮是 349。层 id 是每次会话重新分配的,所以给数字之前一定得说清是哪一轮。
mirrored from 349——而 349 正是那一刻 MirrorRoot 层的层号,两轮录制里它分别是 347 与 349@ 本机 dumpsys SurfaceFlinger 实测(emulator-5554)6.3 第三处标记:input 里的 CLONE
说白了,第三处标记藏在层的 input 属性里。录屏期间,这些影子层的 input 一栏长这样:
input{(NOT_FOCUSABLE | TRUSTED_OVERLAY | CLONE) touchableRegion={0,0,136,1080}}
input{(NO_INPUT_CHANNEL | NOT_VISIBLE | NOT_FOCUSABLE | NOT_TOUCHABLE | CLONE) replaceTouchableRegionWithCrop touchableRegion={-10800,-24000,24000,10800}}
input{(DUPLICATE_TOUCH_TO_WALLPAPER | CLONE) touchableRegion={0,0,2400,1080}}
input{(NO_INPUT_CHANNEL | NOT_FOCUSABLE | CLONE) replaceTouchableRegionWithCrop touchableRegion={-10800,-24000,24000,10800}}
四条里都带 CLONE,另外还各带了一串别的标记:NOT_FOCUSABLE(不可聚焦)、NOT_TOUCHABLE(不可触摸)、NOT_VISIBLE(不可见)、NO_INPUT_CHANNEL(没有输入通道)。
这四条的合意是清楚的:影子层不接受输入。 屏幕上你照样能点、能滑,因为那些操作走的是主屏那一份;影子那一份只是拿来被拍下来的,它不参与输入。
顺带解开一个小疑问:很多人录屏时会觉得"录像里怎么没有我点的那个圈"。原因就在这里——触控反馈没有发生在影子层上。反过来,录屏时屏幕照样能正常操作,也是同一条原因。
一处引用归属要说明:上面这四条不在那份"镜像层表"里——那份台账只抓了
(Mirroring)节点与mirrored from两族行,不含input{...}。这四条来自同一轮录屏的另一个探针文件。核对读数时,行落在那份文件里,就得说那份文件的名字。
CLONE——它们不接受输入,所以录屏时屏幕照样能操作,而录像里也不会出现额外的触控反馈@ 本机 dumpsys SurfaceFlinger 探针实测(emulator-5554)6.4 层数 104 → 105 → 104:多出来的那一层是谁
OK,三处标记读完了,现在看一笔最能说明问题的账。dumpsys SurfaceFlinger --list 会把这个时刻的层清单整个打出来,我数了它的行数:
| 时刻 | 行数 | 文件字节数 | sha256(前 16 位) |
|---|---|---|---|
| 录前 | 104 | 7345 | 785e9d348a4b7b38 |
| 录中 | 105 | 7449 | 011802c27201a1d5 |
| 录后 | 104 | 7345 | 785e9d348a4b7b38 |
录屏期间恰好多一层,录完就没了。 而且这个"没了"不是说"少了一层差不多的"——录前与录后那两份文件是逐字节相同的(sha256 都是 785e9d348a4b7b38,各 7345 字节)。它们不是"看起来一样",是完全一样。
那多出来的这一层是谁? 答案就在第 105 行——就是我们刚才读的那一行:
RequestedLayerState{MirrorRoot-4619827259835644672#349 mirrorId={45,70,72,128,} layerStack=2064014427}
镜像根层。 前面三处的标记((Mirroring) 的影子节点、20 条 mirrored from、input 里的 CLONE)都是围着它转的——它是这套镜像结构的挂载点,而它只在录制期间存在。
所以"录屏时 SF 多做了什么"这个问题,有一份很具体的答案:它多挂了一层镜像根,并把主屏那 20 个层按影子复制了一份挂上去。"复制一份画面"这个说法,从账上看是远远不够的。
dumpsys SurfaceFlinger --list 实测(emulator-5554)本章的源码与文档:Layers and displays(AOSP) | SurfaceFlinger 与 WindowManager(AOSP)。这一章的结论可以收成一句:镜像不是"复制一份画面",而是在层树上另挂一套带 (Mirroring) 与 CLONE 标记的影子层,并多出一个镜像根层。其中:官方管"镜像这件事该怎么理解",本机管"它在 dump 里留下了哪三处标记、以及多出来的那一层是谁"。
七、两个账本不一致
好了,第六章读完了一侧的账。这一章要把两侧的账摆在一起——因为这一章才是本篇题眼真正落地的地方。
7.1 SF 有,dumpsys display 那段没有
下面进入对照:先把同一时刻两处 dump 的读数并排放:
| 取值处 | 录前 | 录中 | 是否看得出录屏 |
|---|---|---|---|
dumpsys SurfaceFlinger --display-id |
1 个 display | 2 个 display | 看得出 |
dumpsys SurfaceFlinger 的 Display identification data 段 |
1 行 | 2 行 | 看得出 |
dumpsys display 的 DisplayDeviceInfo 一族字段(我取的那一段) |
6007 字节 | 6007 字节,与录前逐字节相同(sha256 aa59afb8b9f09fb7) |
看不出 |
dumpsys display 的 mViewports |
[DisplayViewport{type=INTERNAL, …}] |
同左(只有一个 INTERNAL) | 看不出 |
两处都是"显示清单",一处列两行,一处列一行。
这里必须把措辞写全,否则就会读成错的结论:
- 我只取并比对了
dumpsys display里DisplayDeviceInfo一族字段所在的那一段(6007 字节),以及mViewports那一行; - 那一段在录前与录中逐字节相同;
- 但我没有逐段核过
dumpsys display的完整输出里是否另有记录虚拟显示的段。
所以本篇只主张一件事:"我比对的那一段前后逐字节相同。" 不主张"dumpsys display 看不到虚拟显示"。 这两句话看着只差几个字,但一句是我读到的,另一句是我没读全就下的判语。
dumpsys display 里我比对的那一段前后逐字节相同——这不等于说该服务看不到它@ 本机两处 dump 并置(emulator-5554)7.2 句柄每次不同
好了,第二处不一致更小,但一样值得记。两轮录制拿到的 display 句柄是:
| 轮次 | 十进制句柄 | 十六进制 |
|---|---|---|
| 第一轮 | 11529215048127873138 |
0xa00000007ac00072 |
| 第二轮 | 11529215049275252095 |
0xa0000000bf239d7f |
两次创建、两个句柄。 两个数都是 64 位量级,高 32 位都是 0xa0000000,低 32 位不一样。
官方在讲 display ID 演进时提过一句:Android 9 及以下用 32 位 id(0 内屏、1 外屏、[2, INT32_MAX] 是 HWC 虚拟显示、−1 表示非 HWC 虚拟显示);Android 10 起"显示获得稳定持久的 ID"。
本篇的边界在这里:我只登记"本机两次录制得到两个不同的大整数句柄"这一事实,并给出它的十六进制样子。我不主张它是否"稳定"、也不主张那几位编码了什么——那需要看 VirtualDisplayId 的构造源码,而本篇没有取到那份文件的原文。
screenrecord 实测(emulator-5554)7.3 读之前先问这是谁的账本
好了,把这一章的两处合起来,本篇的题眼就到齐了:
- 同一时刻,两个服务对同一件事的记录口径不同——一个列出两个显示,一个那段里一点变化都没有;
- 同一个对象,两次创建给了两个不同的标识——而"稳定持久的 ID"这句话在文档里写着。
这两件事指向同一条读法:读任何一个读数之前,先问"这是谁的账本"。
为什么这条读法值得单列一节? 因为它是可以迁移的。前面几篇里已经出现过同一族现象:同一个名字(missedFrames)在 timestats 与 Pacesetter 两处是两个数(ep11 §10.1);同一个概念在 dumpsys display 与 dumpsys SurfaceFlinger 里换了名(baseModeId 与 defaultModeId 差 1,ep11 §5.1)。本篇把这条读法从"顺带一提"提到了"读法"的位置——因为虚拟显示这个题目,本质上就是一个不被统一登记的对象。
本章的源码与文档:Multiple displays(AOSP) | SurfaceFlinger 与 WindowManager(AOSP)。这一章的结论可以收成一句:同一个虚拟显示,在 SF 的账上有、在另一处被比对的那一段里没有,而它的标识两次创建还不一样。其中:官方管"ID 应该是稳定持久的",本机管"实际印出的两处不一致",而正确处置是把口径标出来,而不是替两边下判断。
八、录制到底录了什么
好了,前七章讲的都是"这块屏"。这一章换个角度:那块屏拍下来的东西,到底录了什么? 答案在 screenrecord 自己的收尾日志里,而它比想象中夸张得多。
8.1 recorded 2 frames in 14 seconds
先来看看这段日志。第二轮录制(--time-limit 14)跑完时,设备打出了这几行:
Display is 1080x2400 @60.00fps (orientation=ROTATION_0), layerStack=0
Configuring recorder for 1080x2400 video/avc at 20.00Mbps
Content area is 1080x2400 at offset x=0 y=0
Time limit reached
Encoder stopping; recorded 2 frames in 14 seconds
Stopping encoder and muxer
Executing: /system/bin/am broadcast -a android.intent.action.MEDIA_SCANNER_SCAN_FILE -d file:///sdcard/ep12_rec2.mp4
cmd: Failure calling service activity: Failed transaction (2147483646)
盯住那一行:
Encoder stopping; recorded 2 frames in 14 seconds
14 秒,2 帧。 而抬头明明写着 @60.00fps。如果按 60 fps 算,14 秒应该有 840 帧左右。实际录到 2 帧。
这不是 bug,是设计。 原因是那一刻屏幕上几乎没有变化——前台就一个静止的设置页。而录制的语义是"有变化才有帧":屏幕更新了,录制端就拿到一帧;屏幕不动,就没有帧进来。前 Android 平台工程师 Andy McFadden 把这句写得很直白:
"When the screen is updated, screenrecord gets a frame; when nothing changes, no frames are received. Gaps in time are 'recorded' by advancing the presentation time stamp in the video file, not by repeating frames."
——bigflake《Android screenrecord stuff》
最后半句是本篇要划的重点:时间上的空档,是靠"把时间戳往前推"补上的,不是靠"补帧"。 所以文件里的时间轴是连续的、内容是稀疏的——播放器看到的是 14 秒,可这 14 秒里只真有 2 帧是新画出来的。
最后两行也如实记一下:收尾时 screenrecord 想通过一条广播让媒体扫描器登记这个文件,本机这条广播失败了(Failed transaction (2147483646))。产物本身是正常的(下一节会给出字节数),只是没被媒体库索引。这处失败本篇不做解释——它与录制本身无关,成因本篇没有查到。
@60.00fps,收尾却说只录到 2 帧——因为录制记的是"变化",不是"节拍"@ 本机 screenrecord --verbose 实测(emulator-5554)8.2 两轮产物对照:差 10 秒,只差 566 字节
OK,第二份旁证是文件本身。两轮录制都落在静止画面上:
| 轮次 | 时长 | 编到的帧数 | 文件大小 | 落盘时间 |
|---|---|---|---|---|
| 第一轮 | 24 秒 | —(未打印帧数) | 107,703 字节 | 2026-09-29 22:49 |
| 第二轮 | 14 秒 | 2 帧 | 107,137 字节 | 2026-09-29 22:50 |
两轮相差 10 秒,而文件只差 566 字节。
这个对照很能说明问题:如果录制真是"按 60 fps 灌帧",10 秒的差距在 20 Mbps 码率下会是几兆字节的量级——而实际差的是五百多字节,也就是"几乎没多任何东西"。
所以"录屏 60 fps"与"内容 60 fps"是两件事。 前者是能力的声明(抬头那个 @60.00fps),后者是内容的实况(14 秒 2 帧)。混淆这两件事,就会得出"录屏不卡但文件很小"这种自相矛盾的结论。
screenrecord 产物实测(emulator-5554)8.3 "有变化才有帧":空档靠时间戳补
说白了,把前两节合起来,得到一条可以画成时序的机制。录制的真实节奏是这样的:
- 屏幕变了 → 合成产出新帧 → 录制端拿到一帧 → 写进文件,记下这一帧的时间戳;
- 屏幕没变 → 没有新帧过来 → 文件里不写任何东西;
- 播放时 → 播放器按时间戳把时间轴铺满——空档是"时间过去了",不是"重复了同一帧"。
这条机制同时解释了三件事:为什么静止画面录出来的文件极小;为什么录制时长与文件大小不成比例;以及为什么"录屏"这件事的帧率不是一个固定值。
本章的源码与文档:adb 的 screenrecord(Android Developers) | bigflake《Android screenrecord stuff》 | Media projection(Android Developers)。这一章的结论可以收成一句:录制记的是变化事件,不是节拍——14 秒 2 帧是正常行为,空档靠推进时间戳补上。其中:官方管"工具能做什么",第三方资料管"为什么没有帧",本机管"这次到底录到了几帧、文件多大"。
九、版本演进与权限收紧
好了,第八章把"录了什么"讲完了。这一章往后撤一步,看时间轴——虚拟显示与屏幕录制这两件事,这些年在往哪个方向走。
9.1 时间线:从 4.4 到 15
先来看看时间轴。把官方文档里的几个节点按时间排一遍:
| 版本 | 关键变化 | 依据 |
|---|---|---|
| Android 4.4(API 19) | screenrecord 可用("recording the display of devices running Android 4.4 (API level 19) and higher") |
§5.2 |
| Android 5(API 21) | android.media.projection 引入("introduced in Android 5 (API level 21)") |
§5.1 |
| Android 9 及以下 | 32 位 display id:0 内屏、1 外屏、[2, INT32_MAX] 是 HWC 虚拟显示、−1 表示无效或非 HWC 虚拟显示 |
官方《Multiple displays》 |
| Android 10 | 显示获得稳定持久 ID;uniqueId 出现 virtual:<package-name-and-name> 形式;ASurfaceControl 引入 |
官方《Multiple displays》《SurfaceFlinger and WindowManager》 |
| Android 12L(API 32) | 虚拟显示 Surface 上的内容等比缩放并居中("the system scales the content uniformly, maintaining the aspect ratio") | 官方《Media projection》 |
| Android 14(API 34) | ①应用屏幕共享(单窗口)②createVirtualDisplay()每个 token 仅一次,否则抛 SecurityException ③必须声明 mediaProjection 前台服务类型 |
官方《Media projection》 |
| Android 15 QPR1 | 状态栏大号隐私 chip 提示投屏中;锁屏自动停止投屏 | 官方《Media projection》 |
Android 4.4(19) → screenrecord 可用
Android 5(21) → media.projection 引入
Android 9 及以下 → 32 位 display id
Android 10 → 显示获得稳定持久 ID
Android 12L(32) → 虚拟显示内容等比缩放并居中
Android 14(34) → 一次性令牌 + 前台服务类型
Android 15 QPR1 → 隐私 chip + 锁屏自动停止投屏
这条时间线的方向很清楚:前一半在"开"(4.4 给 shell 工具、5 给应用 API、10 给稳定标识),后一半在"收"(14 给一次性令牌与前台服务约束、15 给用户可见的隐私提示与锁屏自动停止)。能力和约束是同步长的。
9.2 Android 14 的那三条收紧
好了,时间线上最后一段值得单独拆开,因为它是一次集中收紧。官方对 Android 14 的原文是三条:
"Android 14 (API level 34) introduces app screen sharing, which enables users to share a single app window instead of the entire device screen…"
"On Android 14 or higher, the
createVirtualDisplay()method throws aSecurityExceptionif your app … Passes anIntent… togetMediaProjection()more than once / CallscreateVirtualDisplay()more than once on the sameMediaProjectioninstance""the app manifest must include a permission declaration for the
mediaProjectionforeground service type"——developer.android.com《Media projection》
三条各管一件事:
| 收紧 | 管的是 | 面向谁 |
|---|---|---|
| 单应用共享 | 把"共享"的粒度从整屏细化到单窗口 | 用户(隐私) |
| 一次性令牌 | MediaProjection 实例只能用一次,createVirtualDisplay() 一个实例只能调一次 |
开发者(防止后台偷偷重开) |
| 前台服务类型声明 | 录屏必须挂在有 mediaProjection 声明的前台服务上 |
开发者(让录屏可见、可被杀) |
注意这三条的共同点:它们都在把"录屏"这件事从能力变成需要被看见的行为。一次性令牌挡的是"悄悄重开一路录制",前台服务类型挡的是"没有明显标志的后台录制",而 Android 15 QPR1 的状态栏 chip 与锁屏自动停止,是把这件事直接摊给用户看。
本章的源码与文档:Media projection(Android Developers) | Multiple displays(AOSP) | VirtualDisplay | HDR screenshots(AOSP)。这一章的结论可以收成一句:这门技术十年里前一半在开能力、后一半在加约束,而 Android 14 是一次集中收紧。这条时间线背后的原理是:一块虚拟屏能被谁创建、能被创建几次,直接决定了它的可追溯程度——能力越大,暴露面越大。其中:官方管"哪个版本加了什么",本篇管"把这条线读成一条方向"。
十、真机才能做的四件事
好了,最后一章。前面九章能测的都测了,这一章把测不了的摆清楚,再给出可以照着做的动作。
10.1 本机做不到的清单,按档记
"做不到"OK,这件事要分档,因为它们的原因不一样、补救方式也不一样:
| 项 | 分档 | 依据 |
|---|---|---|
| 应用侧 MediaProjection 的完整链路 | 需真机 + 装应用 | 本机无第三方录屏应用;dumpsys media_projection 全程 null |
| 单应用共享(Android 14 的 app screen sharing) | 需真机 + 应用 | 同上 |
虚拟显示的请求刷新率(DisplayDevice.h 那条字段) |
dump 里没有印出 | 本机那条档案只有六行,不含 requested refresh rate |
| 无线投屏(Miracast / WFD) | 本机未启用 | mWifiDisplayScanRequestCount=0;且协议栈属越界 |
uniqueId 为空的原因 |
读数留存、成因未明 | 与官方给的 virtual:<package-name-and-name> 形式不符,未臆测 |
| 收尾媒体扫描广播失败 | 读数留存、成因未明 | Failed transaction (2147483646);产物本身正常 |
| 录屏对帧率的定量影响(同类文章测到 60→31) | 本机不适合做 | 模拟器本身不在稳定 60 fps 上运行;硬测会把模拟器噪声当成结论 |
| MediaCodec / Codec2 内部 | 越界 | 属媒体专题,本篇不写 |
五档的分法是有用的:第一、二档告诉你"换台真机就能做";第三档告诉你"这条只能靠源码承认,别硬找";第四、五档告诉你"这是留存下来的现象,别急着解释";第六、七档告诉你"这台机器上做不出可信结论,别做"。
10.2 一处跨篇复用:--latency 认不出的名字
先来看看这条坑的历史。这一节是本篇与前面几篇的一条连线,它值得单列,因为它演示了"一条坑的经验怎么跨篇复用"。
dumpsys SurfaceFlinger --latency <名字> 是用来查某一层上屏时间戳的。我拿三个名字各试了一遍:
| 输入 | 它是什么 | 返回 |
|---|---|---|
'ScreenRecorder' |
虚拟显示的名字 | 1 行:16666666 |
'11529215048127873138' |
display 句柄 | 1 行:16666666 |
'Built-in Screen' |
物理显示的名字 | 1 行:16666666 |
三个名字,三行一样的数。 原因很简单——这三个都不是"层名",所以 --latency 一个都认不出来,于是它一律退化成"回一个周期值"。而 16666666 纳秒正好是 60 Hz 的一个周期(16.666666 毫秒)。
这条坑的经验是跨篇来的:ep09 上线后修正过一次——认不出来的名字只回一个周期值;ep11 又复现了一次(--latency 对不认识的名字回一行)。本篇是第三次遇到它,而且这次把那个数认出来了:它就是周期。 一个工具在"输入不合法"时退化成"回一个默认值",如果不知道这件事,就会把 16666666 当成某个层真实的时序读数。
--latency 认不出的名字只回一个周期值——而 16666666 正是 60 Hz 的周期@ 本机三探实测(emulator-5554)10.3 四项可执行动作与判据
下面进入可执行的部分。如果你手上有一台真机,这四组动作可以直接照做。每组都给出判据——做完之后怎么算"看对了",也一并写清。
动作一:看着虚拟显示出生和死去。
adb shell dumpsys SurfaceFlinger --display-id
adb shell screenrecord --time-limit 20 /sdcard/probe.mp4
adb shell dumpsys SurfaceFlinger --display-id
判据:第一条命令应只有一行;第二条跑起来后第三条应变成两行,多出来那行类型写作 (Virtual display)、名字是 ScreenRecorder。录完再查一次,回到一行。
动作二:读那条虚拟显示的档案。
adb shell dumpsys SurfaceFlinger | Select-String -Pattern 'Virtual Display' -Context 0,6
判据:应能读到六行,其中至少 isGpu 这一行要在。如果在真机上读到 isGpu=false,那就是一条与本文不同的读数——说明那台设备的 HWC 接管了虚拟显示合成。
动作三:数层数的三态。
adb shell dumpsys SurfaceFlinger --list | Measure-Object -Line
adb shell screenrecord --time-limit 20 /sdcard/probe2.mp4
adb shell dumpsys SurfaceFlinger --list | Measure-Object -Line
判据:录中应比录前恰好多一层,且录后回到录前的数目。多出来那一层的记录里应能找到 MirrorRoot- 这个前缀。
动作四:看录了多少帧。
adb shell screenrecord --verbose --time-limit 15 /sdcard/probe3.mp4
判据:先让屏幕静止再看,收尾那行 recorded N frames in 15 seconds 的 N 应该很小(本篇是 2);然后让屏幕持续变化再录一次,N 会显著变大。两次对照,就证明了"有变化才有帧"。
10.4 三句结论
第一句:虚拟显示是"另一块屏",但它不是一块"屏"。 它有名字、有档案、有电源模式、有优化策略(第四章);但它的画面走 GPU 而不是 HWC(第三章),它在另一处的账本上那段里一点变化都没有(第七章)。读到它之前先问:这是谁的账本。
第二句:它没有自己的 vsync,所以"虚拟屏的帧率是多少"这个问题要改写。 官方说得明白(第二章),本机 screenrecord 拿到的 @60.00fps 也是内屏的(第二章);而它的"请求刷新率"还要取整到 pacesetter 屏的约数上(第二章)。先问节拍是谁的,再问它被要求对齐到谁。
第三句:屏幕录制不是"按帧率录",是"有变化才有帧"。 14 秒 2 帧(第八章);差 10 秒只差 566 字节(第八章)。所以"录屏 60 fps"与"内容 60 fps"是两件事——把这两件事分开,"文件为什么这么小"就不再是个疑问。
10.5 下一篇预告:逐帧时序
这一篇量的是"这块屏和它录了什么",它始终是"按时刻取一次读数"——一个时刻一个值。可前面几篇里那些"掉帧""晚了""卡了"的说法,本质上都是一帧一帧的时序:某一帧从应用画完到上屏花了多少、错过 deadline 的是哪一帧、错过几毫秒。这些用 dumpsys 是量不出来的——dumpsys 给的是快照,而 jank 是过程。
好了,下一篇进 ep13《剖帧实战:Perfetto FrameTimeline 抓 jank》:用 Perfetto 的 FrameTimeline 数据源拿到逐帧的起止与 deadline,把"哪一帧晚了、晚在哪一段"读到具体帧上。那篇需要真机 + root,形态从"快照对照"换成"抓一条 trace 再剖开"。
如果你是从这一篇开始的,建议的阅读顺序是:ep11(真实屏有几个档)→ ep10(三路节拍错开多少)→ ep09(节拍从哪来)→ 本篇(一块不存在的屏)。前三篇给的是"真实的屏怎么运转",本篇给的是"不存在的屏凭什么混进来"——反着读一遍,虚拟显示那些奇怪的读数会突然变得好懂。
本章的源码与文档:Media projection(Android Developers) | adb 的 screenrecord(Android Developers) | Multiple displays(AOSP)。这一章的定义可以收成一句:测不到的东西要按档记,测得到的东西要给出判据。其中:缺口是输入,四组动作是手段,三句结论是输出。这套分档背后的原理是:不同缺口的原因不同,补救路径也不同——混成一张"做不到"清单,等于把能做的事也一起放弃了。
最后落个款。 这一篇的读数全部来自一台模拟器,其中三处成因未明的落差没有解释——它们不是没查,是按纪律先登记着。如果你也在一台真机上照着 §10.3 做过一遍,并且读到了与本文不同的 isGpu 或层数,欢迎带着读数来对。希望能对大家有所帮助。
参考资料
官方文档(已取正文)
- Layers and displays(AOSP) —— 虚拟显示定义 / "no VSync for a virtual display" /
screenrecord链路 - Implement Hardware Composer HAL(AOSP) ——
createVirtualDisplay()两条分支 / 三模式 / 输出格式 - Graphics architecture(AOSP) —— 虚拟显示定位
- SurfaceFlinger and WindowManager(AOSP) —— SurfaceControl / ASurfaceControl
- Reduce memory consumption(AOSP) —— Android 14 buffer cache 清除
- Multiple displays(AOSP) —— display ID 演进 /
virtual:<package-and-name>/ 隐藏虚拟显示窃取输入 - HDR screenshots(AOSP) —— Android 15 QPR1 截图局部色调映射
- Media projection(Android Developers) —— MediaProjection 全链路 / Android 14 与 15 QPR1 变化
MediaProjection(Android Developers) ——createVirtualDisplay与清理约束DisplayManager(Android Developers) —— 自动镜像 / 安全虚拟显示的权限边界VirtualDisplay(Android Developers) ——resize/setSurface的版本节点- adb 的
screenrecord(Android Developers) ——screenrecord参数与硬限制
AOSP 源码(经同源镜像取到正文)
- AOSP 镜像 ·
VirtualDisplaySurface.h—— 虚拟显示合成类头注释、scratch BufferQueue - AOSP 镜像 ·
DisplayDevice.h——isVirtual()与虚拟显示的请求刷新率 - AOSP 镜像 ·
Layer.cpp(目录级) —— 层 dump 中mirrored from一族字段的出处(本篇未取到该文件的具体行,故正文只按打印结果陈述)
同类文章 / 第三方(背景,不作官方依据)
- bigflake《Android screenrecord stuff》 —— Andy McFadden;"有变化才有帧"与工具限制
- 《Android 性能优化:以后台录屏进程为例》(CSDN) —— 60→31 fps;虚拟屏走 GLES、物理屏走 HWC
- 同上(掘金) —— 同一文的另一站点
- 《Android 图形层垂直同步虚拟 VSYNC 机制》 —— 虚拟 VSYNC 与两条 EventThread
- 《Android codec2 编码 —— 基于录屏》(CSDN) —— 消费者侧路径(本篇列为越界)
- 《android13 screen record》(掘金) —— SystemUI 侧录屏链路(本篇列为未取到正文)
- 《Android 录屏》(lixiaogang03) —— MediaProjection 服务侧链路
screenrecord.cpp路径佐证(Evervolv Gitiles) ——/system/bin/screenrecord的来源
视觉资源(Wikimedia Commons)
- Wikimedia Commons|Category:Screencasting(类目枚举结果,仅 2 个文件)
- Wikimedia Commons|
Wireless_Screencasting.png—— 入选(CC BY-SA 2.0) - Wikimedia Commons|Category:Remote desktop(12 个文件)
- Wikimedia Commons|
Cc0-remote_desktop-dion_dresschers.png—— 入选(CC0) - Wikimedia Commons|Category:Multi-monitor(118 个文件,本篇未取)
- Wikimedia Commons|Category:Video projectors(39 个文件,本篇未取)
- Wikimedia Commons|Category:Teleconferencing(49 个文件,本篇未取)
- Wikimedia Commons|Category:Virtual reality headsets(40 个文件,不对位)
本机台账(ep12_raw/,35 份,正文所有读数以此为准):rec_before_* 4 份 / rec_during_* 8 份 / rec_after_* 2 份 / rec_screenrecord_log、rec_file_info / p2_before_* 3 份 / p2_during_* 9 份 / p2_after_* 3 份 / p2_latency_* 3 份 / p2_screenrecord_help、p2_file_info。