做Android性能优化绕不开“显示链路”这四个字。很多同学排查卡顿时只盯着应用侧那点掉帧日志但一帧画面从App代码变成屏幕上的真实像素中间其实要经过CPU、GPU、BufferQueue、SurfaceFlinger、HWC、显示驱动等多道工序。这套链路我前后啃了将近三年踩过的坑比看过的源码还多。这篇文章是第一篇先把整条链路的骨架用一张全局图讲清楚后续再逐层深入。不管你是刚接触Android系统的新人还是已经做过一些应用层优化的开发者把这张图装进脑子里以后再聊掉帧、卡顿、画面撕裂你至少知道问题出在哪个环节、该找哪个工具。1. 整条链路的地图从App到屏幕到底发生了什么1.1 全局视角六个关键节点一次看全先把最重要的结论放在前面。Android的显示链路可以用一句话概括应用进程生产图像系统进程合成图像硬件设备输出图像。这句话拆开看涉及六个核心节点App线程主线程RenderThread负责执行View的measure、layout、draw以及渲染任务的提交。CPU与GPUCPU把视图层级转换成DisplayList再交给GPU完成栅格化生成纹理。BufferQueue生产者和消费者之间的“传送带”App把画好的图像缓冲丢进队列。SurfaceFlinger系统核心合成器统一接收所有App的Layer按Z序进行合成。HWCHardware Composer硬件合成器让部分图层直接由显示控制器合成减轻GPU负担。Display屏幕最终通过DSI/HDMI/eDP等接口把画面刷新到物理面板上。整条链路在时间上是流水线式衔接的。App绘制一帧的同时SurfaceFlinger可能正在合成上一帧而屏幕正在显示上上一帧。这三级流水是为了充分利用硬件但也正因为并行帧与帧之间的时序关系非常微妙很多掉帧问题都是在这条流水线上“接力”出的错。你需要知道一个非常重要的物理事实每个环节都有自己的时间预算。以60Hz刷新率为例一帧的总预算大约是16.6ms但App绘制通常只有约8ms左右可用因为Vsync-SF和Vsync-App两级调度会错开后面我会详细讲。1.2 为什么要设计这么多中间层有同学可能会问为什么不能让App直接往显存写数据非要搞一个SurfaceFlinger出来这个问题当年我也困惑了很久。直接写的最大问题是多窗口场景下画面会互相覆盖谁来决定谁在最上面另外每个App都直接操作硬件一旦崩溃整个屏幕可能都跟着花掉。所以Android采用了一套“生产者-消费者-中间管理”三层架构App只认识自己的Surface它认为自己在独占屏幕画完就扔给BufferQueue。SurfaceFlinger认识所有App的Surface它拿着每个窗口的Layer按Z序、透明度、裁剪区域计算出最终合成结果。HWC屏蔽显示硬件差异不同厂商的屏幕面板、显示控制器、GPU架构都被抽象成统一的合成接口。这种设计本质上和操作系统虚拟内存的思路一致给每个进程一个“独占”的假象由内核统一调度物理资源。只是这里虚拟化的对象变成了画面缓冲调度者变成了SurfaceFlinger。理解这一层你再看Android的分层渲染、多屏扩展、画中画、折叠屏适配都会有恍然大悟的感觉——所有显示特性都是在这六个节点上做文章。2. 应用侧的绘制CPU预处理与GPU栅格化2.1 CPU的测量与绘制从View树到DisplayList应用侧的生产流程起点是主线程的performTraversals。它会依次执行measure从上到下遍历View树计算每个View的尺寸这个阶段决定了控件的“盒子”有多大。layout确定每个View在父容器中的具体位置。draw生成DisplayList渲染指令注意这一步不直接画像素只是记录“在哪里画什么”。DisplayList相当于一份“绘画脚本”它把离散的View操作编译成一组连续的绘制命令包括画矩形、贴图、走贝塞尔曲线、设置着色器等。这份脚本会被序列化交给RenderThread由RenderThread通过GPU驱动完成真正的栅格化。这里有个很关键的优化点DisplayList支持缓存与复用。只要View没有触发invalidate下一帧可以直接沿用上一次的DisplayList跳过整个measure和layout。这也是为什么View.setVisibility、requestLayout、invalidate三者的开销天差地别的原因——invalidate只是重新记录脚本而requestLayout会从measure开始全量重来。2.2 RenderThread与GPU栅格化主线程不用再画像素Android 5.0引入RenderThread后应用侧渲染从“主线程包办”变成了“双线程协作”。主线程只负责UI逻辑和生成DisplayList真正调用OpenGL/Vulkan接口绘制纹理的操作全部移到了RenderThread上。这么做的一大好处是主线程可以提前处理下一帧的用户输入和布局变化而RenderThread同时还在绘制当前帧两者并行能有效压缩App侧的帧耗时。用一个生活的类比以前一个人既要点菜又得炒菜现在主线程只负责“点菜”RenderThread专门“炒菜”后厨开工不耽误前面点单。GPU栅格化的产物是纹理一个后台Buffer存放着已经画好的像素矩阵。这里需要特别注意颜色格式的问题。大多数设备主流的Buffer格式是RGBA_8888也就是每个像素用4字节表示但部分低端设备上可能落到RGB_565每个像素只有2字节颜色精度差很多深色渐变场景下容易出现色带。2.3 应用侧最常见的三类性能瓶颈结合我自己的排查经验应用侧的性能问题绝大部分集中在三个地方过度绘制同一像素被多次填充GPU工作量成倍增长。用adb shell dumpsys gfxinfo package能看到Overdraw统计蓝色代表一次绘制红色代表四次以上。很多时候只是背景色层层叠加或者自定义View里重复drawBitmap。长任务阻塞主线程一旦主线程被IO、JSON解析、Bitmap解码卡住measure和draw整体推迟DirectDraw时间轻松破30ms掉帧在所难免。DisplayList过大复杂布局会让DisplayList膨胀导致GPU每帧要处理的指令过多。这时候需要考虑扁平化层级、拆分复杂View、用ConstraintLayout替换嵌套LinearLayout等方案。顺带提一个容易被忽视的细节动画的帧间隔。ValueAnimator默认10ms回调一次但系统Vsync是16.6ms所以你其实在做无用功属性动画的值更新频率超过屏幕刷新率并不会让动画更流畅只会白白浪费CPU。建议把动画帧回调或自定义插值器的采样频率对齐到显示刷新率上。3. 跨进程的接力BufferQueue与SurfaceFlinger的合成逻辑3.1 BufferQueue的传送带机制dequeue、queue与acquireApp侧的GPU把纹理画完后下一步是把Buffer通过BufferQueue交给SurfaceFlinger。BufferQueue本质是一个有界队列核心方法就四个dequeueBuffer、queueBuffer、acquireBuffer、releaseBuffer。App端生产者调用dequeue从队列取一个空闲Buffer画完后queue回去。SurfaceFlinger端消费者acquire取出一个装满数据的Buffer做合成合成完毕release归还给队列。理解这组调用后你就能看懂一个经典问题为什么应用渲染慢会导致“丢Buffer”而不是“卡住队列”因为生产者持有Buffer的时间过长队列里的可dequeue数降到0时App线程必须阻塞等待消费者回调释放Buffer。这个等待过程会直接体现在dumpsys gfxinfo里的Frames时间线上表现为一个超长的Sync/Upload或者IssueDraw间隙。Buffer的数量由系统根据Surface类型分配普通应用通常是[2, 4]之间双缓冲最低配是2个——一个正在显示一个正在绘制如果生产速度跟不上就会出现明显的闪烁或撕裂。Android的SurfaceFlinger层内置了PendingFrame机制来避免这种情况但应用侧如果掉帧能用Buffer只会更紧张所以观察Buffer状态是定位低端机卡顿的好切入点。3.2 SurfaceFlinger的合成策略Client合成还是Device合成SurfaceFlinger收到来自所有App的Layer之后必须决定怎么把它们合成一张完整的画面。它的合成方式主要有两种GLES合成Client用GPU把多个Layer画到一个目标Buffer上适合图层多、几何变换复杂的场景。HWC合成Device直接把Layer列表交给显示控制器由硬件完成overlay拼接不需要GPU介入功耗更低且延迟更小。也就是说SurfaceFlinger先尝试把所有Layer都走HWC如果条件不满足比如某个Layer做了旋转、缩放、混合模式太复杂就会挑一部分落到GPU合成再作为单层输出给HWC。这套决策逻辑叫合成策略Composition Strategy是Android 8.0以后版本的重点优化方向之一。HWC 2.x之后厂商可以通过IDevice::validateDisplay来上报自己支持哪些合成方式SurfaceFlinger根据上报结果动态调整。这里就有个厂商调优的经典坑有的GPU驱动对GL_EXT_framebuffer_multisample支持不完整强制走GLES合成会出现明显的画面对角线闪烁反过来如果Layer带圆角裁剪很多低端显示控制器根本不支持必须强制CPU/GPU去做混合否则费用和功耗都飙升。3.3 Layer管理与Transaction为什么动画会掉LayerSurfaceFlinger维护着一个Layer树每个App window对应一个Layer。Layer之间有严格的Z序由WindowManager的窗口顺序决定通常最后一个聚焦的窗口在最顶。而图层之间进行Z序调整、透明度变化、位置移动靠的是Transaction机制。Transaction是一个批处理命令集合。WindowManager会把多个属性变更打包成一个Transaction一次性提交给SurfaceFlinger。这里最容易踩的坑是动画逐帧修改属性如果你在每帧动画回调里都提交一次TransactionSurfaceFlinger就得不断重新计算合成参数和可见区域极容易把SF自身的主线程卡成瓶颈。我见过不少实际案例一个普通的卡片缩放动画用View.animate().scaleX()没问题但有人改成监听onUpdate手动postTransaction结果SF线程CPU占用直接飙升整机交互掉帧。正确的做法是使用视图动画框架或WindowInsetsAnimation系统会帮你合并Transaction非要手写动画的也要尽量缓存动画参数不要每帧new对象。4. 屏幕输出的最后一公里Vsync、刷新率与掉帧测量4.1 Vsync的前世今生软件Vsync到硬件VsyncVsync是显示链路里最核心的时序信号它的作用就像接力赛的发令枪每一枪响各环节才被允许开始处理新的一帧。早期的Android用软件Vsyncvsync由SurfaceFlinger的ThreadedLooper模拟后来改成了由HWC提供硬件Vsync。硬件的好处是精准显示控制器在扫描出下一帧之前会发出上升沿信号确保App和SF都在刷新周期的安全窗口内工作。整体来看每帧时序是这样分的Vsync-SF信号先到达SurfaceFlinger开始合成下一帧。随后Offset一段时间的Vsync-App到达App主线程开始measure/draw。两个Vsync的间隔叫Phase Offset通常是2ms到6ms不等由厂商在SurfaceFlinger配置里设置可以在/system/etc/下的sf配置或dumpsys SurfaceFlinger --timing里看到。理解这个时序你就能明白为什么掉帧不一定是应用慢。如果Phase Offset设得太小App一收到Vsync-SF信号还没来得及提交新BufferSF已经合成完了那你只能在下一帧才能把新内容送上屏视觉上等于白丢了一帧。4.2 HWC与屏幕输出从合成结果到物理像素HWC拿到SurfaceFlinger的合成结果后剩下的工作就是按显示器时序把像素推送到屏幕上。这一层的核心指标是刷新率和扫屏方式。现在市面上的手机大多支持60Hz、90Hz、120Hz多档刷新这背后是显示控制器在切换输出时序。刷新率切换帧率时如果Vsync频率不能平滑过渡屏幕会闪黑或跳帧。Android 11以后加了Display.getAppPreferredFrameRateApp可以主动声明自己期望的帧率系统再结合SurfaceFlinger的全局策略做动态调整。我自己调试折叠屏和可变刷新率设备时遇到过一个问题内屏120Hz切换到外屏60Hz时偶尔外层合成区域会残留一条竖线查到最后是HWC配置里fence的超时时间短了导致外屏没等内屏扫完就切了时序。所以这里提醒大家遇到刷新率切换异常优先查HWC的presentFence超时时间和SF层的RefreshRateOverlay开关而不要一头扎进App代码里找原因。4.3 掉帧是怎么被定义和测量的掉帧这个词大家一直在说但严格的定义是屏幕实际显示了一帧的时间间隔超过了当前刷新率对应的Vsync窗口。比如窗口是16.6ms你实际间隔32ms才出新画面就掉了一帧。Android在Perfetto和dumpsys里记录掉帧的方式很统一gfxinfo的Janky frames衡量App主线程RenderThread是否超预算。SurfaceFlinger的longFrame记录SF合成超时的帧通常由Layer层次变化频繁或GPU合成过重导致。硬件层的FrameTimelineAndroid 12引入的外帧时间线它可以精确到“App生产帧”和“SF合成帧”各自的时间线是目前定位链路问题最直接的指标。实操时我发现很多同学一看到Janky frames就着急改App其实要先打开Perfetto跑一段录屏把App、SF、HWC三条线程的堆栈拉出来看。有时候App侧全部正常掉帧全在SF线程上那就要把注意力转移到减少Layer数、合并SurfaceView、避免频繁启动全屏动画上。5. 排查显示链路问题的工具与方法5.1 分层定位法先判断锅在App、SF还是驱动我习惯把显示问题分成三类App侧主线程执行长任务、过度绘制、大量使用不可复用的Drawable。SF侧Layer过多、Transaction过于频繁、合成长耗时。驱动/硬件侧HWC合成失败、刷新率切换异常、fence超时。定位的第一步永远是看Perfetto trace。抓取命令很简单# 直接抓10秒的系统trace包含sched、gfx、surfaceflinger、hwc等组 perfetto -o /data/local/tmp/trace.perfetto -t 10s \ -c /data/local/tmp/display_full.cfg # 高版本开发者选项里也可以直接选择“系统跟踪”记得勾选“SurfaceFlinger”和“HAL”打开trace后你要重点看三个关键线程的CPU占用和时间线App进程的Choreographer#doFrame和RenderThread。SurfaceFlinger主线程。HWC的presentfence回调对应的驱动线程。如果App的doFrame耗时波动大问题大概率在应用侧如果doFrame正常但SF的合成时间超了要查Layer数量和Transaction频率两边都正常却依然视觉卡顿那就是fence信号或屏幕切换时序的问题可以扔给硬件驱动同事一起查。5.2 三个高性价比的Shell命令随时快速摸底没有条件上Perfetto的环境里你会发现这三个命令极其好用# 1. 查看当前所有Layer和对应的刷新率确认有没有异常Layer叠加 adb shell dumpsys SurfaceFlinger --list # 2. 查看每个Layer的合成类型、大小和fence状态 adb shell dumpsys SurfaceFlinger --latency layer名 # 3. 查看App的帧绘制完整耗时分布 adb shell dumpsys gfxinfo packageName framestats以--latency为例它会输出一个几百行的数据表每一行代表最近一次绘制的帧时间戳。列分别为desiredPresentTime、actualPresentTime、frameReadyTime。这三个时间戳之间的差值就是你定位掉帧的金钥匙actualPresentTime远大于desiredPresentTime说明App生产慢了frameReadyTime迟迟不更新说明渲染线程阻塞在GPU同步上。不过要留意gfxinfo framestats只在App开启硬件加速且没有关闭setFrameStats时有完整数据部分游戏引擎会自己接管绘制管线这时候以SurfaceFlinger侧的latency数据为准。5.3 排查显示链路时最容易忽视的三个坑这几个坑都属于“看起来正常但其实异常”的典型开发者选项里的“模拟二级显示”会影响时序开启后系统会额外多跑一条合成链路所有帧时间都会变长这时候做性能测试毫无意义。后台省电策略导致CPU降频很多国产ROM在感知到屏幕静止、无人触摸后会把CPU锁到低频率。如果你的测试是脚本自动化、没有真实触摸事件测出来的掉帧可能完全不是真实用户场景需要手动模拟触摸热度。HWC2的Layer缓存和实际显示内容不一致某些老驱动在Layer属性没变化时会直接复用上一帧的合成结果这个叫Partial Update优化。如果你通过dumpsys看到Layer没变但画面确实有问题先确认是不是驱动层跳过了更新而不是脑补App的问题。这三个坑里最后一类最容易误导人。尤其是做双屏协同或屏幕录制App时经常有测试反馈“画面卡住不动”最后发现是HWC没有感知到MediaProjection的虚拟显示更新跟App本身毫无关系。我自己画过好几版显示链路图第一版只有六个方块后来加上了Vsync和两个Phase Offset再后来又补上了HWC的设备合成细节。每次往图里加一个节点都是因为线上出现了一个必须靠这个节点才能解释的问题。这篇文章其实只是地图的起点把这些节点讲到每个环节的具体源码和工作机制还需要再写好几篇接着聊。下一步我会先挑SurfaceFlinger的合成策略细讲到时候你会发现地图上每个节点拆开又是一整张自己的地图。