☰
一张图看懂Android显示完整链路:从VSYNC到屏幕刷新
2026/10/7 14:46:12 网站建设 项目流程

干 Android Framework 这几年,最常被问的问题之一就是:我在手机上轻轻点了下屏幕,从 App 到像素点,中间到底经历了什么?网上讲显示链路的文章不少,但大多只讲了 App 侧的 measure/layout/draw,把 SurfaceFlinger 和 HWC 一笔带过。还有不少文章刚好反过来,只盯着 SurfaceFlinger 的合成逻辑,完全不提应用侧的 View 体系是怎么把一帧画出来的。真正想要“一张图看懂 Android 显示完整链路”的人,需要的是一条能从头串到尾、每一段都标清楚的路线图,而不是零散的知识点缝合。这篇是“一张图看懂 Android 显示完整链路”系列的第 1 篇,我会把整条链路按真实的数据流方向拆开,从 App 收到 vsync 信号开始,一直讲到显示面板刷新出画面,把中间每个环节的职责、关键源码位置、以及我实际调试时常用到的验证手段都说清楚。适合正在做应用层性能优化、准备转 Framework 方向、或者面试被问到“屏幕上的一帧是怎么来的”而一时语塞的朋友。

1. 一句话先讲清楚:这条链路到底长什么样

整个 Android 显示完整链路,用大白话说就是三句话:App 按系统给的节奏把一帧画好,放进一块共享缓冲区;系统侧的 SurfaceFlinger 把各个 App 画好的图层按顺序合成为一屏画面;显示硬件按照固定刷新率把这个合成结果扫描到屏幕上。三条句子听起来简单,但每句话背后都牵扯了一个完整子系统,我下面分别展开。

1.1 应用侧与系统侧的分界

先记住一个最重要的分界线:应用侧和系统侧在“缓冲区”这里交接。应用侧包括我们写的 Activity、View 树、ViewRootImpl、HWUI(硬件加速渲染库)以及 RenderThread;系统侧包括 BufferQueue、SurfaceFlinger、HWC(Hardware Composer)以及 Display 设备。应用侧负责“生产”帧,系统侧负责“消费”和“合成”帧,两边不直接互相调用,而是通过一块块 GraphicBuffer 来完成交接,这个解耦设计是整个显示链路能够稳定的根基。

应用侧里面也有一个容易混淆的概念:Window 和 Surface 的关系。每个 Activity 在 WindowManager 里注册一个 Window,Window 内部持有一个 Surface,这个 Surface 背后连接着系统的 BufferQueue。你在代码里拿到的 Surface,其实只是 BufferQueue 的“生产者”侧接口,真正消费者是 SurfaceFlinger。所以平时做开发时,不要老想着“直接往屏幕上画”,你的绘制成果最终都会被装进 Buffer,由别人来搬运和呈现。

系统侧的核心则是 SurfaceFlinger,它运行在独立的 system 进程里,管理着一堆 Layer,每个可见窗口对应一个 Layer。SurfaceFlinger 收到各个 App queueBuffer 上来的帧,再结合每个 Layer 的位置、大小、透明度、圆角裁剪等属性,决定合成方式,最终输出到显示设备。这个“合成”动作不是简单地把 bitmap 贴在一起,它要处理遮挡关系、旋转、缩放、色彩空间转换,还要尽量借助硬件来减少 GPU 负载。

1.2 数据流、控制流和反馈回路

看显示链路时,建议把“数据流”和“控制流”分开想。数据流是像素的搬运路线:App 的 Canvas 绘制指令经过渲染线程变成 GPU 纹理,再生成 GraphicBuffer,然后被 SurfaceFlinger 读取、合成、送显。这条路线是单向的,方向固定。控制流则复杂一点:SurfaceFlinger 通过 BufferQueue 的机制通知生产者“你可以生产下一帧了”,也通过 vsync 信号驱动整个系统的节奏,同时 WindowManager 还会管理哪些 Layer 需要显示、Z 轴顺序怎么排、窗口焦点在谁身上。

更值得强调的是 fsync 之外的第二个反馈回路:SurfaceFlinger 在合成完一帧后,不会立即把这块 Buffer 还给 App,而是要等显示设备真的扫完了这一块内存中的数据,才会触发 release 回调,让 Buffer 回到可用池子里。这就是为什么帧率、缓冲区数量、显示刷新率三者必须放在一起考虑。如果只盯着 App 的绘制快慢,忽略了下游消费节奏,很容易调了半天还是很卡。

1.3 一张图的整体布局与读图顺序

我画这张图的时候,刻意把上下分成两层:上面是 App 进程,下面是系统进程,右侧是显示硬件,左侧是输入/同步信号。读图建议从左往右看:左边是 vsync 信号进来,中间是 App 绘制和 Buffer 流转,右边是 SurfaceFlinger 合成和屏幕显示。我还在图上重点标出了两个时钟节点,一个是 vsync-app,一个是 vsync-sf,这是很多人看源码时容易漏掉的关键细节。Android 之所以把 vsync 分成两路,就是为了让 App 的生产节奏和 SurfaceFlinger 的合成节奏可以错开,避免两边互相等待产生死锁。

我在每一条数据流边上还标注了对应的“可观测点”,也就是调试时能看到这段状态的地方。比如 App 侧的 doFrame 日志、RenderThread 的 execute 阶段、BufferQueue 的 queueBuffer 计数、SurfaceFlinger 的 present fence 等。有了这些标注,你在 system trace 或者 dumpsys 里看到一段日志时,就能马上认出它属于链路里的哪一段,不至于拿着一堆 trace 不知道从哪里下手。

2. 链路第一棒:从 VSYNC 到 View 绘制

整条链路的第一棒,在 App 进程里完成,核心目标是“根据应用状态生成一帧内容”。这一棒包含两个层次:UI 线程算布局和绘制指令,渲染线程执行真正的 GPU 渲染。我先说节奏来源,再说这两层各自干什么。

2.1 Choreographer 与 VSYNC:一切的时钟

如果你看过 Android 源码,一定见过 Choreographer 这个类。它可以说是 App 渲染的心跳。系统通过 SurfaceFlinger 或硬件抽象层产生的心跳信号(VSYNC),一份发给 App 进程(vsync-app),一份发给 SurfaceFlinger(vsync-sf)。App 进程收到 vsync-app 后,Choreographer 会回调我们在代码里注册的各种 Callback,包括 input、animation、traversal 三类,这三类回调的执行顺序也很有意思:先处理输入事件,再跑动画,最后才做 measure/layout/draw 的遍历。这样做是为了保证动画和手势能基于最新输入状态去计算,避免画面内容滞后。

很多没有接触过系统层的开发者会误以为 vsync 只有一个,实际上在 Android 4.1 Jelly Bean 之后,系统就引入了“Vsync 相位偏移”的概念,通过两个虚拟 vsync 把 App 的生产和 SurfaceFlinger 的合成错开。简单说,App 画完一帧放到缓冲区之后,SurfaceFlinger 并不是立刻就开始合成,而是等自己那一路 vsync-sf 到了才干活。这样做的好处是给 App 的 buffer 一点排队时间,让合成时能拿到最新的一帧;坏处是会增加一两帧的显示延迟,这个权衡在后面的章节还会展开。

再来算一笔账:在 60Hz 刷新率的设备上,每一帧的预算大约 16.6ms;90Hz 是 11.1ms;120Hz 是 8.3ms。如果你的 App 在 UI 线程上的耗时超过了这个预算,Choreographer 就不会再等到下一个 vsync 才去执行下一帧,而是可能直接跳过一帧,表现为掉帧。这也是为什么大家在看 Profile 时最先盯的就是 doFrame 里有没有慢函数。

实际操作里,我一般优先用adb shell dumpsys gfxinfo <package> framestats拿每帧的 vsync 时间戳和绘制各阶段耗时,这样可以精确判断到底是 UI 线程超时、渲染线程超时还是 SurfaceFlinger 合成超时,而不是靠感觉调到哪算哪。

2.2 measure / layout / draw:UI 线程的三板斧

每收到一个 vsync-app,ViewRootImpl 就会触发一次 performTraversals,它内部按顺序执行三个步骤:measure(测量)、layout(布局)、draw(绘制)。measure 负责根据父 View 给的 MeasureSpec 和子 View 自身的需求,算出每个 View 应该多大;layout 负责确定每个 View 在父容器里的位置;draw 负责把 View 的内容“记录”下来。

这里要注意一个关键点:现在的 draw 已经不是真正意义上的“画”了,尤其是在开启硬件加速之后。View.draw 会把绘制操作(画背景、画 Bitmap、画文字、画阴影等)写进一个叫 RenderNode 的显示列表(DisplayList),而不是马上调用 OpenGL 接口。这个设计带来的最大好处是:UI 线程只需要负责“记录”绘制指令,耗时的 GPU 操作被放到后面的渲染线程去执行,UI 线程可以尽快回到空闲状态。

用一段简化代码来理解 performTraversals 的整体顺序:

// 简化示意:ViewRootImpl 在 vsync 回调里执行的 performTraversals private void performTraversals() { // 1. measure:根据父容器约束测量每个 View 的尺寸 child.measure(widthMeasureSpec, heightMeasureSpec); // 2. layout:根据测量结果确定每个 View 的位置 child.layout(0, 0, child.getMeasuredWidth(), child.getMeasuredHeight()); // 3. draw:把绘制指令记录到 RenderNode(DisplayList) child.draw(canvas); }

很多刚接触源码的同学会问:为什么 measure 和 layout 那么重要?原因是这两步决定了 View 的尺寸和位置,任何一步出错都会导致后续 draw 的内容跑偏,还会引发 requestLayout 的连锁反应。我在实际项目里见过不少性能问题就出在 measure 被频繁触发上,比如在 onLayout 里改子 View 的宽高、在 onDraw 里调用 requestLayout、或者 ListView 复用时没处理好高度缓存,这些都会让整棵树反复测量,UI 线程每帧都会超预算。

2.3 DisplayList 与 RenderThread:真正干活的渲染线程

自 Android 5.0 引入 RenderThread(渲染线程)之后,App 侧渲染就变成了一个分工明确的流水线:UI 线程记录 DisplayList,RenderThread 执行渲染。RenderThread 会读取 UI 线程建立的 RenderNode 树,把它们转换成 GPU 指令,调用 EGL 或 Vulkan 接口绘制到底层 Surface 对应的 Buffer 上。

这里的“执行”又可以细分成几步:先遍历 DisplayList,做纹理上传(upload)、几何数据处理、绘制指令提交;然后调用 GPU 执行;最后通过 eglSwapBuffers 或者在 Vulkan 场景下 vkQueueSubmit 把渲染结果提交到 BufferQueue。在这一系列动作里,最容易被开发者感知到的耗时点是纹理上传和绘制指令数量。纹理上传通常发生在图片资源刚被解码、GPU 还没有缓存时,首次上屏的图片往往特别费时;绘制指令数量则和 View 层级复杂度、过度绘制直接相关。

RenderThread 的设计从根本上改变了 App 的渲染模型。以前没有 RenderThread 时,UI 线程要等着真正的渲染操作跑完才能继续做记录,UI 一动就卡。现在 UI 线程在 doFrame 里只是“生产”指令,RenderThread 异步消费,UI 线程和 RenderThread 之间通过同步屏障和帧回调来协作。这也是为什么即使 UI 线程不算很忙,你还是可能在 systrace 里看到渲染线程长期占用 CPU/GPU,因为真正吃性能的活都在这条线程上。

2.4 这个环节最容易出现的坑

App 侧最常见的问题可以总结成两类:一类是 UI 线程超预算,另一类是 RenderThread 超预算。UI 线程超预算通常来自过度布局、复杂 measure、频繁 requestLayout、主线程上有 IO 或锁等待;RenderThread 超预算则来自过度绘制、超大 Bitmap 纹理上传、大量的阴影/模糊效果、DisplayList 频繁失效重建。

有一个很好用的观察手段:打开开发者选项里的“Profile HWUI rendering”或者命令行工具的 HWUI 数据,看 Draw、Prepare、Process、Execute 四个阶段。Draw 和 Prepare 在 UI 线程执行,Process 和 Execute 在 RenderThread 执行。如果你看到的瓶颈集中在 Draw 或 Prepare,说明 UI 线程逻辑太重;如果集中在 Process 和 Execute,就说明该去优化 View 层级、减少过度绘制、或者考虑用更高效的绘制方式,比如直接用自定义 View 代替多个嵌套布局。

个人经验:排查这类问题,别一上来就怀疑是 SurfaceFlinger 卡顿。绝大多数“卡”都发生在 App 侧,先把 gfxinfo 和 systrace 的 App 区间看完,确认 UI/渲染线程没有超帧预算,再往系统侧怀疑,效率会高很多。

3. 链路第二棒:Buffer 流转与 SurfaceFlinger 合成

App 侧把一帧渲染完成后,会通过 BufferQueue 把 Buffer 交给 SurfaceFlinger 消费。这段是很多人看显示链路时最容易断片的地方,我必须单独拿出来详细讲。BufferQueue 的核心角色是生产者和消费者之间的缓冲区仓库,而 SurfaceFlinger 是消费者,App 是生产者。两者之间的协作规则决定了整套系统的帧率、延迟和卡顿表现。

3.1 Surface、BufferQueue 与 dequeue/queue 循环

先明确几个名词:Surface 是 App 侧拿到的生产者接口,它封装了对 BufferQueue 的操作;BufferQueue 是系统侧的核心数据结构,维护了一个 Buffer 池;SecureBuffer/GraphicBuffer 是真正承载像素内存的对象,由 Gralloc 分配,内存通常位于硬件适配的连续内存区域,并可能映射到 App 进程和 SurfaceFlinger 进程。

正常情况下,App 每一帧渲染的循环是这样的:

  • App 通过 Surface 的 dequeueBuffer 向 BufferQueue 申请一个空闲 Buffer;
  • 拿到 Buffer 后,RenderThread 通过 EGL/Vulkan 把绘制内容渲染进这个 Buffer;
  • 渲染完成后,App 调用 queueBuffer 把 Buffer 交还给 BufferQueue,并带上一个 Fence 时间戳,表示“这帧什么时候可以被消费”;
  • SurfaceFlinger 收到“新 Buffer 来了”的异步回调后,在 vsync-sf 时刻取出 Buffer 参与合成。

这里“Fence”是一个很重要但经常被忽略的概念。Fence 是同步原语,本质上是一个文件描述符,用来标记 GPU 和显示硬件之间的完成状态。比如 App 在 queueBuffer 时带上 fence,SurfaceFlinger 并不一定要等到 GPU 完全用完这个 Buffer 才能处理,而是可以等合适时机再去等待这个 fence。用好 Fence 能够避免无谓的 CPU/GPU 等待,但调试时看到 trace 里等待某个 fence 时间过长,通常也意味着资源竞争或者合成排队。

3.2 双缓冲与三缓冲:流畅和延迟怎么换

BufferQueue 里维护的 Buffer 数量不是固定为 2,在 Android 的标准实现里,默认允许的 Buffer 数量是可以配置的,常见的是 3 个(双缓冲和三缓冲的说法更多是概念模型)。双缓冲模型很好理解:一个 front buffer 在屏幕上显示,一个 back buffer 用来让 App 绘制。问题在于,如果 App 绘制速度比屏幕刷新慢半拍,back buffer 还在画,屏幕就已经需要下一帧了,这时系统只能要么继续重复显示旧帧(产生卡顿),要么等待(产生延迟)。

三缓冲在双缓冲基础上多提供了一个“备用缓冲”,让 App 不必等 front buffer 释放,可以先画到第三个 Buffer 里。这样一来,App 的绘制节奏可以跟屏幕刷新节奏在一定范围内解耦,掉帧少很多,代价是会增加一帧左右的显示延迟。很多“流畅但延迟”的体验问题,根源就在这里。

我把两者的差异整理成表格,方便对照:

模式缓冲区数量优点缺点适合场景
单缓冲1延迟最低极易撕裂基本被淘汰
双缓冲2延迟较低绘制超出预算时易卡顿多数普通场景
三缓冲3抗掉帧能力强延迟略有增加、内存占用大游戏/复杂界面

在 SurfaceFlinger 的实际实现里,BufferQueue 是通过 maxBufferCount 来控制缓冲数量上限的,同时还会受到 “异步模式” 的影响。App 侧可以通过 Surface.setFrameRate 或适配系统策略来改变生产节奏,但不要再误以为“三缓冲就是塞三个 Buffer 那么简单”,它牵扯到何时允许 dequeue、何时 release、何时 block 生产者线程等一堆状态转换。

3.3 SurfaceFlinger 合成:从 Layer 到最终画面

SurfaceFlinger 手里维护的是一堆 Layer,每个 Layer 代表一个可见窗口。它做合成时主要有这么几步:先根据窗口层级关系排序 Layer,然后逐个检查每个 Layer 的 dirty region(脏区域),合并计算出哪些区域需要真实合成;再决定每一块区域是交给 GPU 做客户端合成(Client Composition),还是交给显示硬件做设备合成(Device Composition);最后把合成结果交给 HWC 映射到物理显示设备。

Layer 还有一个容易被忽略的属性叫 BufferStateLayer,指的是这个 Layer 的内容来自一个 BufferQueue。窗口的位置、大小、圆角其实不直接改 Layer 的 Buffer,而是通过 WindowManager 提交给 SurfaceFlinger 的 Transaction 来修改。Transaction 这个词很重要:它表示“你对 Layer 属性的一组改动”,SurfaceFlinger 在一个事务点统一应用这些改动,避免合成过程中因为属性不一致而出现画面撕裂或错位。

从实际调优的角度看,你并不需要掌握 SurfaceFlinger 每一条代码路径,但必须理解“Layer 数量过多会影响合成性能”这件事。每多一个 Layer,SurfaceFlinger 在合成阶段就要多考虑一次遮挡、透明度和合成方式。于是业界才会有“减少窗口层级”“尽量用单 Window 实现悬浮效果”“避免频繁 addView 创建新的系统窗口”之类的建议。

3.4 HWC 硬件合成:能硬则硬,不能硬才软

HWC(Hardware Composer)是显示链路里非常关键的一个 HAL 层,它把合成任务从 GPU 手里接过来一部分,让显示硬件自带的叠加器(Overlay)直接完成合成。为什么不让 GPU 完成所有合成?因为 GPU 合成要把多个 Buffer 混成一整张图,再整体输出到显示面板,这很耗带宽;而硬件叠加器可以允许屏幕上的多个区域分别来自不同的 Buffer,互不混叠,带宽消耗低很多。

SurfaceFlinger 在合成前会问 HWC:“这 N 个 Layer 你能直接叠出来吗?”HWC 根据硬件能力决定接不接受。如果可行,就走 Device Composition;如果不可行,SurfaceFlinger 就用 GPU 先把这些 Layer 合成一张图,再把这张图交给硬件显示,这时叫 Client Composition。还有一种混合模式,就是一部分 Layer 由设备合成,另一部分由客户端合成后再一起送给设备。

HWC 2.x 之后,这套决策逻辑可以通过 setLayer 的 Composition Type 字段来明确表达,系统在运行时会动态调整。因为这层能力跟具体手机芯片关系很大,不同 SoC 的叠加器层数、支持格式、旋转能力都不一样,所以同一个 App 在不同机型上的合成路径也会有差异。理解 HWC 的目的不是让你去改 HAL,而是让你知道:系统侧的合成开销并不固定,跟 Layer 数量、布局关系、Buffer 格式强相关,排查“显示卡顿/功耗异常”时一定要想到这一层。

4. 链路最后一棒:显示硬件与帧的节奏

Buffer 被 SurfaceFlinger 合成完后,最终会交给显示控制器(Display Controller)和显示面板。这一棒容易被开发者忽视,但很多诡异问题恰好出在这里:比如画面偶尔撕裂、屏幕亮度闪烁、玩游戏的触摸延迟偏高等。要理解这些问题,先得看懂面板的刷新行为和我们平时说的“帧率”到底有什么关系。

4.1 面板刷新率与 VSYNC 的再理解

显示面板不是“一次性把整帧画到屏幕上”的,大多数 LCD/OLED 面板都是从左到右、从上到下一行一行扫描刷新。在 60Hz 刷新率下,面板每秒完成 60 次全屏刷新,每次刷新之间,显示控制器从当前显示的 framebuffer 中读取数据并逐行输出。如果哪一次刷新时 framebuffer 里的内容还没准备好,或者被替换到了一半,画面就会出现“撕裂”——屏幕上下两部分来自不同帧。

VSYNC 最初就是为了规避撕裂而生的信号:显示控制器每次开始刷新新一帧时,会发出一个硬件信号,告诉系统“现在到了同步点,你要换 Buffer 就在这个时刻换”。后来 Android 把这个硬件信号扩展成了虚拟 vsync,并拆成 app 和 sf 两路,形成我们现在看到的同步框架。也就是说,vsync 既是刷新的节拍器,也是数据流的阀门。

现在很多中高端手机支持动态刷新率(比如 1Hz 到 120Hz 之间跳变),面板刷新率不是恒定的。这在省电上很有用,但也给链路带来了额外的复杂性:如果 App 以 120Hz 绘制,而面板在待机时降到 60Hz,SurfaceFlinger 和 HWC 需要做帧率匹配,不然会出现帧重复或丢帧。Android 12 之后系统引入的“帧率切换”机制,要求 App、WindowManager、SurfaceFlinger、HWC 一起协同,普通开发者能做到的,就是别随意假设屏幕刷新率固定,用代码尽量遵循系统的 frame rate hint。

4.2 一帧从 App 到屏幕到底要多少时间

我们把前面几个环节的时间都合起来算一笔账。假设一台 60Hz 手机,帧预算 16.6ms。App 在 vsync-app 触发后开始 doFrame,假设花 8ms 完成 UI 线程记录和 RenderThread 渲染,queueBuffer 成功;这时可能已经过了大半个帧周期。SurfaceFlinger 等到下一个 vsync-sf 才会合成,假设合成 3ms,再交给 HWC,HWC 又等下一个硬件扫描周期才真正把画面扫上屏幕。

所以严格来说,从“App 开始处理一帧”到“用户在屏幕上看到这帧”的延迟,通常在 1.5 到 3 个帧周期之间,也就是 25ms 到 50ms。这个数字对于普通滑动操作已足够流畅,但对触摸到画面反馈要求极高的场景,厂商会做“触摸延迟优化”和“显示延迟优化”,本质上是缩短各环节的排队时间,甚至牺牲一点帧一致性来换取更早显示。理解这一点后,你就不会再问“为什么我明明只写了 5ms 的绘制,总感觉有半秒延迟”这种问题了。

4.3 撕裂、掉帧、延迟到底是怎么来的

我把三类核心问题一次说清。撕裂:面板刷新期间 framebuffer 被改写,导致一屏内容来自两个不同帧。单缓冲最容易撕裂,双缓冲配合 vsync 基本能避免,但如果 BufferQueue 和 HWC 的 fence 同步做得不好,偶尔还是会闪一下画面,尤其是在 SurfaceFlinger 主线程卡顿时。

掉帧:一帧的某个环节超过了窗口期,导致该显示的 Buffer 没赶上刷新节奏。掉帧不一定被用户看见,但会表现为动画不平滑。Android 的 Choreographer 会在 doFrame 里记录 Jank 统计,systrace 上也可以明确看到“Missed VSYNC”标记。它既可能发生在 App 的绘制环节,也可能发生在 SurfaceFlinger 的合成环节,所以排查时不要把所有掉帧都归咎于应用。

延迟:完全准时但每一帧都有固定排队,用户操作到画面反馈之间隔了几帧。这不是毛病,而是流水线设计必然付出的代价。系统厂商会通过减少 buffer 数量、调整 vsync 相位、用 low latency 模式等方式来减小延迟,但代价往往是更容易出现掉帧。你要明白“低延迟”和“高流畅”本质上是两个优化方向,很多时候互相冲突,这才是显示链路里最核心的性能权衡。

5. 验证链路:我平时用的调试三板斧

画图也好,讲原理也好,最终都要落到“怎么定位问题”上。我自己在显示链路排查中,最常用的命令就三个:dumpsys SurfaceFlinger、dumpsys gfxinfo、systrace/Perfetto。这里把每一步怎么用、怎么看结果写出来,方便你直接照着操作。

5.1 dumpsys SurfaceFlinger 看图层状态

先看 SurfaceFlinger 当前有哪些 Layer,以及它们的状态:

adb shell dumpsys SurfaceFlinger --list

这条命令会列出所有 Layer 的 name 和类型,能帮你判断某个窗口到底有没有被创建、有没有真正参与合成。比如你要确认一个悬浮窗是否真的上屏,就可以在这里找它的名字。想看得更细,可以不加参数直接 dump 全部信息:

adb shell dumpsys SurfaceFlinger

输出内容很长,重点看几个字段:z-order 决定图层上下关系;visible region 决定哪些区域实际可见;active buffer 决定当前正在显示的 Buffer;frame number 能告诉你 SurfaceFlinger 处理到第几帧了。如果你的 App 明明在跑动画,但某个图层一直不变,就可以先怀疑这个 Layer 是否卡在 waiting for buffer 状态。

5.2 gfxinfo 和 frame timeline 定位 App 侧卡顿

App 侧的帧数据用 gfxinfo 就能拿到,不需要插桩:

# 先重置统计,清理历史帧数据 adb shell dumpsys gfxinfo <package> reset # 跑你的测试场景 # 再抓取帧数据 adb shell dumpsys gfxinfo <package> framestats

framestats 输出里每一行代表一帧,包含很多时间戳字段,我这里挑几个最常用的:HWC 之前的 totalDuration 可以直接看这一帧 App 侧花了多久;drawDuration 是 UI 线程记录绘制指令的耗时;executeDuration 是 RenderThread 执行 GPU 渲染的耗时。如果 executeDuration 长期高于阈值,再看看 uploadDuration,纹理上传往往就在这里现出原形。

更直观的可能是adb shell dumpsys gfxinfo <package>之后的 “Janky frames” 统计,它会把超过预算的帧数和比例直接算出来。不过我建议别只盯着百分比,要去看掉帧分布在哪一段,不然“平均数被拉低”很容易误判成“不卡”。

5.3 systrace / Perfetto 串起完整时间线

前两个命令都是“事后统计”,要精确看到每一帧在 App、BufferQueue、SurfaceFlinger、HWC 之间怎么流转,还是得上 trace。老牌工具是 systrace,新工具是 Perfetto,都是记录带时间戳的跨进程事件。抓取方式:

# 使用 Perfetto 抓取 10 秒应用进程和系统进程 trace adb shell perfetto -o /data/misc/perfetto-traces/trace.perfetto-trace \ -t 10s \ sched freq idle \ gfx view binder_driver hal app \ am wm sf \ android.surfaceflinger \ android.hal.graphics

打开 trace 后,几个关键 marker 我的阅读习惯是先找 App 的 Choreographer#doFrame 区间,再找 RenderThread 的 DrawFrame 区间,再看 queueBuffer 和 onFrameAvailable 的相隔时间,最后看 SurfaceFlinger 的合成区间。一条清晰的时间线会显示这些事件按节奏排列,如果某个区间拉得非常宽,就说明卡点在那里。

注意:抓 trace 时尽量复现问题,并多用“连续滑动/连续动画”来让显示链路处于高负载状态。很多显示问题只在设备刚开机、CPU 频率未拉满、或后台进程多的时候才会暴露,一次简短的点击抓不到有效样本。

5.4 常见显示问题速查表

我把平时遇到的问题整理成一个速查表,每次出问题先对号入座:

现象可能原因优先排查手段
滑动掉帧明显UI 线程 doFrame 超时 / 过度布局gfxinfo framestats 看 drawDuration
GPU 渲染阶段卡顿纹理上传、过度绘制systrace 看 RenderThread、开发者选项「调试GPU过度绘制」
图层不显示Layer 被隐藏 / 可见区域异常dumpsys SurfaceFlinger --list 确认 Layer 存在性
画面撕裂vsync 同步失效、单缓冲路径被触发查 HWC dump、确认使用双缓冲以上
触摸延迟偏高缓冲数量过多 / 帧率切换策略慢是否有三缓冲、frame rate hint 是否生效
合成阶段掉帧Layer 数量太多 / HWC 合成失败转 GPUdumpsys SurfaceFlinger 看 composition type 比例

表里的每一项展开都得再写一篇长文,这里先给你一个方向;以后在系列后续的文章里,我会挑掉帧和合成路径单独细讲。

6. 看完图之后,说几个我踩过的坑

我当初画这张“Android 显示完整链路”的大图时,踩过最大的坑就是以为只要记住了名词顺序就万事大吉,结果真遇到问题还是不知道怎么定位。后来发现,光知道 SurfaceFlinger 在干什么没用,还得知道每一段对应的 trace 标记和 dumpsys 字段长什么样,才能把“图上的理论链路”和“手机里实际的执行链路”一一对应上。所以我现在给别人讲显示链路,一定会强调:每个环节都要配一个可观测手段,否则这张图就是一张装饰画。

另外有个我自己常犯的误区:把掉帧全怪到 App 头上。有次我排查一个桌面卡顿问题,gfxinfo 显示系统桌面每帧的 draw 和 execute 都很快,但用户就是觉得掉帧。后来抓了 systrace 才发现卡在 SurfaceFlinger 合成阶段的 Buffer 等待上,根源是另一个系统窗口频繁刷新导致合成压力大。也就是说,链路一定是从头看到尾的,不能只看 App 这一段就把结论拍了。

如果你刚接触这个话题,建议用我上面给的命令先把自家 App 的帧数据跑一遍,再抓一条 systrace 对照着图看十分钟,大概就能把框架立住。下一篇我准备挑“从 queueBuffer 到 onFrameAvailable 的缓冲区流转”这一段做更深入的拆解,把那块黑色区域彻底讲透。到时候你会发现,显示链路最有趣的部分,往往不是那些表层 API,而是这些背地里默默排练的队友之间到底怎么配合。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询