1. 从一次花屏说起:为什么需要理解显示链路
几年前我在调试一台车载设备时遇到一个很典型的问题:应用层明明已经把画面绘制完成,日志里也能看到onDraw正常回调,但屏幕上就是隔三差五闪一下黑,偶尔还伴随半屏撕裂。当时我第一反应是应用代码有问题,查了两天绘制逻辑,一无所获。后来把注意力转到系统层,用dumpsys SurfaceFlinger抓了一轮合成状态,才发现问题出在图层合成的时序上——某个 overlay 图层的 buffer 提交节奏和主图层对不上,导致合成器拿到了一帧不完整的画面。
那次经历让我意识到一件事:只懂应用层绘制,不懂系统显示链路,遇到显示类问题基本就是盲人摸象。你能画出来,不代表你能显示出来;你能显示出来,不代表你能稳定、流畅、低延迟地显示出来。这中间隔着一条相当长的链路,从应用进程的 Canvas 绘制,到 Surface 的 buffer 队列,到 SurfaceFlinger 的合成决策,再到 HWC 硬件叠加,最后经 DRM/KMS 送到物理屏幕的时序控制器。任何一环出问题,用户看到的就是花屏、卡顿、撕裂、延迟。
这篇内容就是想把这条链路完整地串一遍。它不是那种只讲概念的科普,而是我这些年做 Android 显示相关调试、性能优化、问题定位时积累下来的实战理解。适合谁看?如果你在做 Android 应用开发但总被"为什么我的动画掉帧"困扰,如果你在做 Framework 或系统定制需要改显示相关逻辑,如果你在做车载、TV、平板这类对显示时序敏感的产品,那这条链路你绕不开。我会尽量用生活化的类比把每个环节讲清楚,同时把关键的调试命令、参数含义、常见坑点都带上,让你看完能直接上手排查问题。
先给一个整体印象:Android 显示链路可以粗略理解成一条"流水线",应用是生产车间,Surface 是传送带,SurfaceFlinger 是总装调度中心,HWC 是自动化装配臂,DRM 是出厂运输通道,屏幕是最终交付的展厅。每个环节都有自己的节奏和缓冲,节奏对不上就会出问题。下面我们逐段拆。
2. 应用侧到 Surface:画面是怎么"交出去"的
2.1 绘制不是直接画在屏幕上
很多人初学 Android 绘图时会有个误解,以为onDraw里画的东西直接就上屏了。实际上应用进程根本没有直接操作屏幕的权限,它画的每一笔都落在一块离屏缓冲区上,这块缓冲区就是 Surface 背后管理的 GraphicBuffer。你可以把 Surface 想象成一块画布,应用在这块画布上作画,画完之后把画布"递"给系统,系统再决定怎么把这块画布贴到屏幕上。
这个"递"的动作,在 Android 里通过 BufferQueue 完成。BufferQueue 是一个生产者-消费者模型:应用进程是生产者,负责 dequeue 一块空闲 buffer、绘制、queue 回队列;SurfaceFlinger 是消费者,负责 acquire 一块已绘制好的 buffer、合成、release 回去。这个模型的好处是生产和消费解耦,应用不需要等系统合成完才能画下一帧,两边可以并行跑,这是流畅显示的基础。
提示:BufferQueue 的默认深度通常是 3(triple buffering),意味着最多可以有 3 块 buffer 在流转。这个数字不是随便定的,它直接决定了应用能提前画多少帧,也影响延迟和内存占用。
2.2 三种绘制路径:Canvas、OpenGL ES、Vulkan
应用往 Surface 上画,有三条主流路径。最上层是Canvas,也就是我们熟悉的View.onDraw(Canvas),底层通过 Skia 图形库渲染,适合常规 UI。往下一层是OpenGL ES,游戏、地图、相机预览这类需要高性能渲染的场景基本都走这条路,通过 EGL 把 GL 上下文和 Surface 绑定。再往下是Vulkan,更接近硬件、开销更低,但开发复杂度也更高,目前主要在高端游戏和部分系统渲染场景使用。
这三条路径最终都会把像素写进 GraphicBuffer,区别在于谁来写、怎么写。Canvas 走的是 CPU 光栅化(部分场景有 GPU 加速),GL/Vulkan 走的是 GPU 渲染。理解这一点很重要,因为当你排查"为什么这帧没出来"时,需要先确认是哪条路径出的问题——是 Skia 没画完,还是 GL 命令没提交,还是 buffer 没 queue 上去。
2.3 Choreographer 与 VSYNC:节奏的指挥棒
画面不是你想画就能随时画的,得跟着屏幕的刷新节奏走。这个节奏由VSYNC(垂直同步信号)驱动,而应用侧接收 VSYNC 的入口就是Choreographer。每次 VSYNC 到来,Choreographer 会依次回调输入、动画、遍历(measure/layout/draw)这几个阶段,应用就在这个窗口期内完成一帧的绘制。
为什么要跟着 VSYNC 走?因为如果应用画得比屏幕刷新快,多出来的帧会被丢弃,白白耗电;如果画得比屏幕慢,就会出现掉帧。跟着 VSYNC 走,能让绘制节奏和显示节奏对齐,这是流畅体验的前提。Choreographer 本质上是一个"节拍器",它把 VSYNC 信号分发给需要感知帧节奏的模块。
这里有个常见的坑:如果某一帧的绘制耗时超过了 VSYNC 周期(比如 60Hz 下是 16.67ms),这一帧就赶不上当前 VSYNC,只能等下一个,用户就会感知到一次卡顿。所以做性能优化时,我们经常用Choreographer.FrameCallback或者 systrace/Perfetto 去测量每一帧的耗时,找出超过 16.67ms 的"坏帧"。
2.4 一个容易被忽略的细节:Surface 的生命周期
Surface 不是永远存在的,它跟着 Window 走。Activity 创建时 Window 被添加,Surface 被创建;Activity 销毁时 Surface 被释放。但有些场景下 Surface 会被销毁重建,比如旋转屏幕、进入画中画、切换分辨率。如果应用在 Surface 已经销毁后还往上面画,就会报Surface has been released之类的错误。
我在做视频播放器时踩过这个坑:播放过程中旋转屏幕,Surface 被重建,但解码器还持有旧的 Surface 引用,结果画面直接黑掉。解决办法是监听SurfaceHolder.Callback的surfaceDestroyed和surfaceCreated,在销毁时暂停渲染、重建时重新绑定。这个细节在文档里往往一笔带过,但实际项目中非常容易出问题。
3. SurfaceFlinger:合成调度中心到底在做什么
3.1 它不只是"合成",更是资源调度者
SurfaceFlinger 这个名字容易让人以为它只负责把多个图层合成一个。实际上它的职责远不止于此:它管理所有图层(Layer)的生命周期,决定每个图层用哪种合成方式,协调 VSYNC 节奏,处理图层可见性、裁剪、变换、透明度,还要和 HWC 协商谁来干实际的合成活。你可以把它理解成一个"总装调度中心",既要管物料(buffer),又要管工序(合成方式),还要管节拍(VSYNC)。
每个 Window 在 SurfaceFlinger 里对应一个 Layer。Layer 记录了这块 Surface 的位置、大小、Z 序、透明度、变换矩阵、裁剪区域等信息。SurfaceFlinger 每收到一个 VSYNC,就会遍历所有可见 Layer,根据它们的属性决定合成策略。
3.2 合成方式:Client 合成 vs Device 合成
合成有两种方式。Client 合成(也叫 GPU 合成)是 SurfaceFlinger 自己用 GPU 把所有图层画到一块 buffer 上,再交给显示设备。Device 合成(也叫 HWC 合成、Overlay 合成)是把图层直接交给 HWC,由显示硬件的叠加器(Overlay)在扫描输出时实时叠加,不需要 SurfaceFlinger 先合成一块完整的 buffer。
哪种更好?Device 合成通常更省电、延迟更低,因为省掉了 GPU 合成这一步,也不需要额外的 buffer 读写。但 Device 合成有硬件限制:叠加器能处理的图层数量有限(常见是 4 到 8 个),对图层的格式、缩放、旋转、混合模式也有约束。一旦超出限制,SurfaceFlinger 就得回退到 Client 合成。
注意:很多显示性能问题,根源就是"本该走 Device 合成的图层被迫走了 Client 合成"。比如图层数量超了、格式不支持、做了非整数缩放,都会触发回退,导致功耗上升、延迟增加。
3.3 用 dumpsys 看清合成现场
排查显示问题,dumpsys SurfaceFlinger是最常用的工具。它能打印出当前所有 Layer 的状态、合成方式、buffer 情况。几个我常用的子命令:
# 列出所有图层及其基本信息 adb shell dumpsys SurfaceFlinger --list # 查看合成相关的详细统计,包括每个图层的合成类型 adb shell dumpsys SurfaceFlinger # 查看指定图层的延迟信息 adb shell dumpsys SurfaceFlinger --latency <layer-name>--latency这个特别有用,它会输出该图层最近 128 帧的时间戳,包括应用绘制完成时间、VSYNC 时间、合成呈现时间。通过这三个时间戳,你能算出应用的绘制延迟和整体呈现延迟。我之前定位一个"操作后画面慢半拍"的问题,就是靠这个命令发现应用绘制完成到最终呈现之间隔了两帧,说明中间有 buffer 积压。
3.4 图层可见性与 Z 序的坑
SurfaceFlinger 只合成可见的图层,不可见的图层会被跳过。但"可见"的判断不只看setVisibility,还要看图层是否被完全遮挡、是否在屏幕外、alpha 是否为 0。有时候一个图层明明设置了可见,但因为被上层不透明图层完全盖住,SurfaceFlinger 会把它标记为不可见,跳过合成。
这个机制本身是优化,但会带来一个调试陷阱:你以为某个图层在合成,实际上它被优化掉了。排查时如果发现某个图层"没显示",先确认它是不是被上层盖住了,或者 Z 序排错了。Z 序由 Window 的层级和setZOrderMediaOverlay之类的 API 决定,搞错了就会出现"画面被盖住"的诡异现象。
4. HWC 与 DRM:从合成到点亮屏幕的最后一段
4.1 HWC 的角色:硬件叠加的"翻译官"
HWC(Hardware Composer)是 Android 显示架构里的硬件抽象层,它向上对接 SurfaceFlinger,向下对接显示驱动。SurfaceFlinger 把图层列表交给 HWC,HWC 根据硬件能力决定哪些图层能走 Overlay、哪些需要 GPU 预合成,然后把最终方案告诉驱动。
HWC 的版本迭代挺快,从 HWC1 到 HWC2,接口变化不小。HWC2 引入了更细粒度的能力查询和更灵活的合成决策,让 SurfaceFlinger 能更精确地知道硬件到底支持什么。做系统定制时,如果 HWC 实现有问题,最典型的表现就是"该走 Overlay 的走了 GPU 合成",或者"图层叠加顺序错乱"。
4.2 DRM/KMS:内核里的显示管家
再往下就是内核层的DRM(Direct Rendering Manager)和KMS(Kernel Mode Setting)。DRM 负责管理显示设备、内存缓冲、命令提交,KMS 负责配置显示模式(分辨率、刷新率、时序参数)。Android 通过 HWC 把合成结果提交给 DRM,DRM 再驱动显示控制器把像素按时序扫描输出到屏幕。
这一层离应用开发者比较远,但做底层调试时绕不开。比如调整屏幕刷新率、配置多屏显示、处理 HDR,都要和 DRM/KMS 打交道。/sys/class/drm/下面能看到各个显示连接器的状态,modetest这类工具能直接操作 KMS 做测试。
4.3 刷新率切换:不只是改个数字
现在高刷屏很普遍,60Hz、90Hz、120Hz 甚至更高。刷新率切换看着简单,实际上涉及整条链路的协同。应用要能感知刷新率变化并调整绘制节奏,SurfaceFlinger 要重新配置 VSYNC 周期,HWC 和 DRM 要切换显示时序。如果某一环没跟上,就会出现切换瞬间的卡顿或闪烁。
我在做高刷适配时遇到过一个典型问题:应用在 120Hz 下画得飞快,但切换到 60Hz 后,Choreographer 的节拍没及时调整,导致应用还在按 120Hz 的节奏提交 buffer,结果 buffer 积压,延迟反而变大。解决办法是监听刷新率变化,动态调整绘制策略。这个细节在官方文档里讲得不多,但实际项目中很关键。
4.4 一个完整的时序视角
把整条链路串起来看,一帧的生命周期大致是这样的:VSYNC 到来,Choreographer 回调应用绘制,应用把 buffer queue 到 BufferQueue;SurfaceFlinger 在下一个 VSYNC 收到 buffer,决定合成方式,交给 HWC;HWC 决定 Overlay 方案,提交给 DRM;DRM 在下一个 VSYNC 把画面扫描输出到屏幕。所以从应用绘制到用户看到,中间至少隔了一到两个 VSYNC 周期,这就是为什么"操作后画面慢半拍"往往不是应用的问题,而是链路本身的延迟。
理解这个时序,对做延迟敏感的应用(比如云游戏、AR、手写笔)特别重要。你要做的不是让应用画得更快,而是想办法压缩链路中的缓冲级数,比如减少 BufferQueue 深度、强制走 Device 合成、优化 VSYNC 对齐。
5. 实战排查:一条命令定位显示问题
5.1 先建立"分层排查"的思路
显示问题千奇百怪,但排查思路可以统一:从应用层往硬件层逐层排除。先确认应用有没有正常绘制(看onDraw回调、看 GPU 渲染耗时),再确认 buffer 有没有正常提交(看 BufferQueue 状态),再确认 SurfaceFlinger 有没有正常合成(看 dumpsys),再确认 HWC 走了哪种合成方式,最后看 DRM 有没有正常输出。
这个顺序的好处是,每一层都有对应的工具和日志,能快速缩小范围。最怕的是一上来就怀疑硬件,结果查了半天发现是应用少调了一次invalidate。
5.2 常用命令速查
我把这些年常用的显示调试命令整理成一张表,方便你按需取用:
| 命令 | 作用 | 适用场景 |
|---|---|---|
dumpsys SurfaceFlinger --list | 列出所有图层 | 确认目标图层是否存在 |
dumpsys SurfaceFlinger | 查看合成详情 | 确认合成方式、buffer 状态 |
dumpsys SurfaceFlinger --latency <layer> | 查看图层延迟 | 定位呈现延迟、buffer 积压 |
dumpsys gfxinfo <package> | 查看应用渲染耗时 | 定位应用侧掉帧 |
dumpsys window | 查看窗口状态 | 确认窗口层级、可见性 |
systrace/Perfetto | 全链路时序追踪 | 定位跨层性能问题 |
提示:
--latency输出的时间戳单位是纳秒,解读时注意换算。三列分别对应"应用绘制完成""VSYNC""呈现",正常情况下三者间隔应该稳定,如果某一段突然变大,说明那一环有瓶颈。
5.3 一个真实案例的排查链路
回到开头那个花屏问题,我当时的排查过程是这样的:先用dumpsys gfxinfo确认应用绘制正常,没有坏帧;再用dumpsys SurfaceFlinger看合成状态,发现主图层走的是 Device 合成,但那个 overlay 图层偶尔会从 Device 合成回退到 Client 合成;进一步查 HWC 日志,发现回退的原因是 overlay 图层的 buffer 格式在某些帧上不一致,触发了硬件不支持。最后定位到是 overlay 数据源的格式转换逻辑有 bug,偶尔会产出非预期格式的 buffer。
这个案例说明,显示问题的根因往往不在你第一眼怀疑的地方。分层排查、逐层验证,比凭直觉猜要靠谱得多。
5.4 性能优化中的几个经验值
做显示性能优化,有几个经验值可以参考。60Hz 下每帧预算 16.67ms,120Hz 下是 8.33ms,应用绘制加合成必须在这个窗口内完成。BufferQueue 深度建议保持默认的 3,太浅容易 underrun,太深增加延迟。Device 合成的图层数量上限因硬件而异,常见是 4 到 8,超过就要考虑合并图层。呈现延迟方面,从触摸到画面更新,做得好的设备能控制在 50ms 以内,超过 100ms 用户就会有明显感知。
这些数字不是绝对的,但能帮你快速判断当前状态是否正常。比如你测出来呈现延迟 200ms,那肯定有问题,得往链路里找瓶颈。
6. 那些文档不会告诉你的坑
6.1 图层数量不是越多越好
有些开发者习惯给每个 UI 元素都开一个独立 Surface,觉得这样灵活。实际上图层数量直接影响合成开销和功耗。图层越多,SurfaceFlinger 遍历和决策的成本越高,HWC 能走 Overlay 的概率越低,越容易回退到 GPU 合成。我的建议是能合并的图层尽量合并,尤其是静态内容,没必要单独占一个图层。
6.2 透明度和混合模式是性能杀手
带 alpha 的图层、用了特殊混合模式的图层,往往无法走 Device 合成,因为硬件叠加器对混合的支持有限。如果你的界面大量使用半透明效果,很可能整条链路都在走 GPU 合成,功耗和延迟都会上去。做 UI 设计时,能不用半透明就不用,非用不可时也要控制范围。
6.3 分辨率适配的隐藏成本
非整数缩放、非原生分辨率渲染,都会增加合成开销。比如在 1080p 屏幕上渲染 720p 内容再放大,HWC 可能不支持这种缩放,只能走 GPU。做多分辨率适配时,尽量让渲染分辨率贴近屏幕原生分辨率,减少缩放带来的额外开销。
6.4 调试时别忘了看功耗
显示链路的很多问题最终会体现在功耗上。同样的界面,走 Device 合成和走 Client 合成,功耗可能差 20% 以上。做优化时,除了看帧率和延迟,也要用功耗工具测一下,确认优化真的有效。我见过不少"优化"只是把延迟从一处挪到了另一处,整体功耗反而上升。
6.5 版本差异要心里有数
Android 每个大版本对显示链路都有调整。比如某个版本改了 BufferQueue 的默认深度,某个版本调整了 HWC 的接口,某个版本引入了新的刷新率切换机制。做跨版本适配时,不能想当然地套用旧经验,得针对目标版本实测。我一般会在项目初期就搭好不同版本的测试环境,避免后期返工。
7. 把这条链路装进脑子里
写到这里,这条从应用到屏幕的完整链路基本串完了。我自己的体会是,理解显示链路最大的价值不是让你记住每个模块的名字,而是让你在遇到问题时知道该往哪个方向看。画面没出来,是应用没画、buffer 没提交、还是合成被跳过?画面卡顿,是应用绘制慢、合成回退、还是 VSYNC 没对齐?有了这条链路的框架,排查就有了章法,不会像无头苍蝇一样乱撞。
如果你刚开始接触这块,建议从dumpsys SurfaceFlinger和dumpsys gfxinfo这两个命令入手,先学会看现状,再逐步深入到 HWC 和 DRM。如果你已经在做系统定制,那 HWC 的合成决策逻辑和 DRM 的时序配置是必须啃下来的硬骨头。这条链路涉及的模块多、层次深,一次看不透很正常,多结合实际项目反复对照,慢慢就形成直觉了。
最后分享一个小习惯:我每次遇到显示相关的 bug,都会先把dumpsys SurfaceFlinger的完整输出存一份,问题复现时再存一份,两份对比着看,往往能发现平时忽略的差异。这个笨办法帮我定位过不少疑难问题,比单纯盯着代码看有效得多。