2026/10/7 14:46:18

一张图看懂Android显示完整链路:从VSYNC到屏幕刷新

一张图看懂Android显示完整链路:从VSYNC到屏幕刷新 干 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、HWCHardware Composer以及 Display 设备。应用侧负责“生产”帧系统侧负责“消费”和“合成”帧两边不直接互相调用而是通过一块块 GraphicBuffer 来完成交接这个解耦设计是整个显示链路能够稳定的根基。应用侧里面也有一个容易混淆的概念Window 和 Surface 的关系。每个 Activity 在 WindowManager 里注册一个 WindowWindow 内部持有一个 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一份发给 SurfaceFlingervsync-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.6ms90Hz 是 11.1ms120Hz 是 8.3ms。如果你的 App 在 UI 线程上的耗时超过了这个预算Choreographer 就不会再等到下一个 vsync 才去执行下一帧而是可能直接跳过一帧表现为掉帧。这也是为什么大家在看 Profile 时最先盯的就是 doFrame 里有没有慢函数。实际操作里我一般优先用adb shell dumpsys gfxinfo package framestats拿每帧的 vsync 时间戳和绘制各阶段耗时这样可以精确判断到底是 UI 线程超时、渲染线程超时还是 SurfaceFlinger 合成超时而不是靠感觉调到哪算哪。2.2 measure / layout / drawUI 线程的三板斧每收到一个 vsync-appViewRootImpl 就会触发一次 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把绘制指令记录到 RenderNodeDisplayList 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 线程记录 DisplayListRenderThread 执行渲染。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 时带上 fenceSurfaceFlinger 并不一定要等到 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 数量过多会影响合成性能”这件事。每多一个 LayerSurfaceFlinger 在合成阶段就要多考虑一次遮挡、透明度和合成方式。于是业界才会有“减少窗口层级”“尽量用单 Window 实现悬浮效果”“避免频繁 addView 创建新的系统窗口”之类的建议。3.4 HWC 硬件合成能硬则硬不能硬才软HWCHardware 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 绘制而面板在待机时降到 60HzSurfaceFlinger 和 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再交给 HWCHWC 又等下一个硬件扫描周期才真正把画面扫上屏幕。所以严格来说从“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 决定当前正在显示的 Bufferframe 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 framestatsframestats 输出里每一行代表一帧包含很多时间戳字段我这里挑几个最常用的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 看 drawDurationGPU 渲染阶段卡顿纹理上传、过度绘制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而是这些背地里默默排练的队友之间到底怎么配合。