
做Android开发这几年我发现自己身边很多同事对View的绘制流程能聊得头头是道但你要是追一句这个View最终是怎么显示到屏幕上的能完整答上来的人立马少一大半。这不怪大家因为Android显示完整链路确实被官方文档拆得太碎今天讲SurfaceFlinger明天讲Choreographer后天讲HWComposer合在一起就成了一部零散的技术手册。我写这个系列的目的就是把这些碎片拼起来用一张图把整条链路串明白。这篇是第1篇默认你至少写过自定义View、用过onDraw知道有SurfaceView和View.post这回事但从未系统捋过CPU、GPU、BufferQueue、SurfaceFlinger、VSYNC这些概念之间的关系。我会从一次真实掉帧事故切入把链路全景图拆开揉碎讲清楚每一环的职责和数据流转方式。这篇文章不是源码解析是帮助你先在脑子里搭出一个完整的坐标系后续再看任何相关源码或工具输出你都知道自己当前站在哪个位置。1. 一条链路五段路先搞清谁在干活1.1 从掉帧事故引入UI线程卡了为什么GPU也没辙先说个我自己的案例。去年做一款信息流App当时在列表的item里放了一个复杂的自绘控件onDraw里头跑了不少Path测量和Kotlin协程的挂起操作实测快速滑动时帧率直接从60fps掉到40fps。我用Systrace抓到渲染线程的trace惊讶地发现RenderThread那一栏几乎是空的卡顿时间全部堆在UI线程那一块。后来我把重活挪到协程的IO线程把自绘逻辑简化UI线程的trace瞬间变短但掉帧并没有完全消失。再抓trace才看到真正的问题出在列表项里的View.setBackground频繁创建新Drawable导致GPU每帧都要重新上传纹理。这里的关键点是一条显示链路里任何一个环节堵住就算其他环节速度再快帧一样出不来。所以看链路不能只看onDraw得从顶层到屏幕全盘看。1.2 链路中的六个核心角色为了好记我把整条链路拆成六个角色就像一条流水线上不同工位App进程负责业务逻辑和视图树构建主要工作在UI线程和RenderThread上。CPU应用侧执行measure/layout/draw把View树变成DisplayList然后通过RenderNode记录下来。GPU把DisplayList转成实际的像素纹理执行着色、抗锯齿等操作最终生成帧的Buffer。BufferQueueApp和SurfaceFlinger之间共享的帧缓冲区池负责缓冲区的分配、排队和消费。SurfaceFlinger系统服务负责把多个应用的Buffer取过来根据Z轴顺序做合成最终交给显示设备。HWComposerHWC硬件合成器决定合成操作是走GPU还是走显示硬件是SurfaceFlinger的外挂辅助。这六个角色不是简单的上下游关系而是两两配合。比如CPU和GPU之间存在同步与交接BufferQueue是App和SurfaceFlinger之间的转接口SurfaceFlinger和HWC之间有合成决策的协商。接下来我就用一张图把这些关系串起来。2. 一张全景图从View到屏幕像素的完整旅程2.1 我画的这张图长什么样既然标题叫一张图看懂我得先描述一下这张图的结构否则没法往下讲。我建议你把它画成一条横向时间轴从左到右是一帧的处理阶段从上到下是不同执行者。具体分为四段第一段App内绘制从用户手指滑动触发input事件开始到Choreographer收到VSYNC信号UI线程执行Traversalmeasure → layout → draw产出DisplayList发送给RenderThread。第二段RenderThread渲染RenderThread把DisplayList通过drawRenderNode调用到GPU利用OpenGL/Vulkan驱动执行渲染生成一张完整的Buffer然后通过dequeueBuffer和queueBuffer投递到BufferQueue。第三段Systrace里的合成前准备SurfaceFlinger被VSYNC信号唤醒从各App的BufferQueue里取Buffer做Composition决策决定帧是走GPU合成还是HWC合成。第四段显示输出HWC或GPU把合成结果提交到显示设备屏幕在下一个VSYNC信号到来时刷新出来。图的左侧从上到下可以按UI线程 → RenderThread → GPU → BufferQueue → SurfaceFlinger → HWC → Display排列每一条生命线右侧是本环节的时间块。你会发现这张图最关键的信息不是某个环节的实现细节而是每个环节都依赖VSYNC节拍——所有动作都是被统一定时器唤醒的而不是有人一画完就立刻显示。2.2 为什么必须有BufferQueue这个中间仓我第一次接触BufferQueue时非常不理解App画完一帧直接把Buffer交给SurfaceFlinger不行吗为什么要绕一道队列后来栽过跟头才明白这是为了解耦生产者和消费者的节奏。生产者App和消费者SurfaceFlinger的速度不可能永远一样。App可能因为突然的GC卡了一下这一帧晚了几毫秒SurfaceFlinger也可能因为合成多个窗口而变慢。如果没有BufferQueue这个临时仓库两者只要稍微抖动就会互相卡死。BufferQueue里一般有2到3个Buffer来回倒腾生产者用完一个就丢回队列消费者取走一个就拿去合成循环往复。你可以把BufferQueue想成餐厅的传菜口厨师把菜盘放上去服务员端走传菜口始终有一两个空位放新菜。要是没有这个传菜口厨师和服务员就只能一个人做完菜才能叫另一个人来端整个出品速度全被最慢的那个人拖死。2.3 链路时序一帧从诞生到上屏平均多长时间很多人以为60fps意味着每帧16.7ms是在画画上其实不是。在丢掉三重缓冲的条件下一帧的总预算大概是16.7ms但这段时间要分配给UI线程、渲染线程、合成和显示刷新。实际分布大概是环节典型耗时备注Input事件分发0.5~2ms可能跨进程Choreographer回调 Traversal1~6ms取决于View树复杂度RenderThread执行DisplayList1~4ms依赖GPU驱动和画布操作GPU渲染1~6ms纹理上传通常是瓶颈BufferQueue排队等待0~5ms如果超过了预算就该丢帧SurfaceFlinger合成0.5~2ms多个窗口叠加时更长HWC/显示输出0~4ms硬件刷新等待不能忽略我实测过一台中端机正常滚动列表时从input到queueBuffer大约消耗10~13ms再加上合成和显示刷新刚好卡在16.7ms的边缘。如果UI线程和渲染线程加起来超过14ms这一帧必然丢。所以做性能优化时别只盯onDraw要把RenderThread和GPU的时间一起抓。3. 逐站拆解每一环到底干了什么活3.1 应用侧UI线程如何交出施工图而不是成品很多新手会认为App侧的任务是把View画成像素然后交给系统。其实完全不是这样。UI线程在draw阶段做的事情是把View树遍历一遍把每个View的绘制指令记录到一个叫DisplayList的数据结构里。这东西你可以理解成一张施工图上面写着在这个位置画一个圆角矩形在那个区域绘制一段文本这里加载这张位图但并没有真正画出像素。例如你自定义一个折线图控件onDraw里调用canvas.drawLine实际上Canvas并不是画板而是向DisplayList添加指令的记录员。所以UI线程的绘制不依赖GPU只是构建了一个命令列表。这也是为什么View状态发生变化时只要调用invalidate系统只需要重新记录这一块的DisplayList而不用把整张View树全部重画。等到UI线程完成Traversal就会把DisplayList交给RenderThread这个线程属于App进程但独立于UI线程运行。RenderThread通过GPU驱动执行这些指令组合成一个完整的帧Buffer。这里有个重要的点为什么需要单独一个RenderThread因为如果让UI线程自己等GPU执行完那UI线程就废了任何滚动动画都做不了。现实是UI线程只负责下单RenderThread负责跑腿这样UI线程可以马上处理下一次触摸事件两者并行。3.2 缓冲区核心dequeueBuffer和queueBuffer的两次握手当RenderThread执行完渲染后它需要拿到一块可写的图形缓冲区这个操作叫dequeueBuffer。BufferQueue分配缓冲区时会从空闲列表里取出一个GraphicBuffer。注意这块缓冲区并不是随便一块内存而是经过allocator分配、可能由硬件直接访问的内存。拿到后GPU通过eglSwapBuffers在Vulkan里是vkQueuePresent把绘制好的色彩数据填进缓冲区然后调用queueBuffer把这块缓冲区放入待消费队列同时标记为已经填写完毕。对于稳定帧率BufferQueue通常至少持有两个缓冲区这就是双缓冲。如果生产者和消费者都很快两个缓冲足够用但如果生产者偶尔慢那么一次双缓冲就会导致消费者等生产者、App等消费者于是产生掉帧。这时候系统会自动扩展出第三个缓冲也就是三缓冲本质上就是给队列多备一个缓冲允许生产者提前渲染下一帧。很多玩家吐槽说三缓冲增加延迟这句话其实只说对了一半三缓冲确实会让App提前一帧开始渲染但总延迟增加的时间远小于它消除的卡顿时间只有当你的App能稳定跑在标称帧率以上时三缓冲才可能让手感和功耗略微变差。3.3 SurfaceFlinger多窗口合成的大管家SurfaceFlinger是系统中所有窗口的总合成器。每个App窗口比如Activity的DecorView、对话框、状态栏等都会有一个自己的Surface对应BufferQueue里的生产端。SurfaceFlinger作为消费者端会在每个VSYNC信号到来时从所有窗口的BufferQueue中取出最新的一批Buffer根据窗口的Z序、透明度、裁剪区域等参数把它们合成为一帧完整画面。这里有个特别容易误会的地方SurfaceFlinger不一定亲自用GPU做合成。它的工作方式其实是先询问HWComposer底层显示硬件能不能帮我合成这几个图层如果硬件说能那就把Buffer的handle直接发给显示控制器让显示器硬件自己去做合成如果硬件说不能比如图层格式太古怪或数量太多SurfaceFlinger才自己调用GPU合成生成一个最终的Buffer再交给HWC显示。这个协商过程每帧都在进行Systrace里看到的Composition阶段就是干这个。3.4 HWC与Display最后的临门一脚HWComposerHWC是Android系统里一个比较特殊的硬件抽象层它不直接代表某一个屏幕而是代表显示硬件能力。有了HWC原本需要GPU参与的合成工作可以被专门的显示处理器接管比如三星的MFC、高通的Display Controller等。这些硬件模块功耗低、延迟短专门为混合多图层而生。HWC会向SurfaceFlinger上报能力支持多少层、哪些格式可以硬件合成每帧再根据实际图层内容决定采用哪种合成方式。当HWC完成合成后最终的像素数据被送入显示控制器的帧缓冲等待下一个VSYNC信号到来时屏幕内存读取出来刷新。这个下一个VSYNC很关键即便你这一帧提前画完了屏幕也得等到固定节拍才会显示不会提前刷新。所以显示链路天然受VSYNC约束一切优化目标本质上都是在VSYNC节拍内全部完事。4. 掉帧到底掉在哪个环节问题根因分析4.1 四类典型掉帧每一类对应链路不同位置实际开发中掉帧不是某一个环节的专用问题而是不同环节故障的外在表现。我带团队时经常让组员先分类再定位我们把掉帧分成四类CPU饿死型UI线程执行时间过长measure/layout在复杂布局中严重超时导致一帧的Traversal超过16.7ms。常见原因有嵌套权重、巨型BitMap的getPixels、非必要的requestLayout等。GPU瓶颈型DisplayList指令触发GPU渲染很慢比如过度绘制、模糊效果、大量的阴影和滤镜导致RenderThread等待GPU完成的时间超长。典型特征是Systrace里RenderThread明显宽但CPU占用不高。排队饥饿型BufferQueue里消费者SurfaceFlinger处理不过来或者生产者和消费者节奏错位导致App下一帧的dequeueBuffer等待时间很长。这种情况常见于系统同时处理多个动画窗口。系统背压型其他进程抢占系统资源或者屏幕刷新率切换比如120Hz降到60Hz造成整条链路整体放慢。这种掉帧往往不是App自己的代码问题而是系统机制导致的。通过Systrace抓trace时你先看掉帧发生的时间点再看那个时间点上哪条线程出现了长条或等待标记就能把问题归到上面某一类。如果是UI线程去查布局和业务逻辑如果是RenderThread去查渲染指令和着色器如果是SurfaceFlinger侧那就要考虑是不是窗口太多或需要降级处理。4.2 为什么我常说渲染线程偶尔卡一下比每帧稳定多2ms更致命说一个我从实战里总结的体会。很多同学做优化时只看平均帧率觉得从45fps提到55fps就很满意了。但用户不会感觉平均帧率他们只会感知到掉帧瞬间的停顿和滑动时的不连贯。假设你的App平均耗时10ms非常快但偶尔有一帧耗时40ms那用户就会明显觉得卡了一下。因为BufferQueue为了保证不花屏这一帧会在队列里等下一帧也要等视觉上表现为明显的滞涩。所以优化显示链路的思路不是简单抠某一个环节的均值而是让每一个环节的耗时尽量稳定留出余量。比如给列表项固定高度、避免在onDraw里创建对象、把昂贵的阴影用位图替代都是为了让每一帧的GPU耗时更平稳。4.3 定位链路瓶颈的三种实用手段真正做链路定位时我常用的工具和手段就三个Systrace现在叫Perfetto这是了解链路全过程的最强工具。勾选sched、gfx、view、surfaceflinger相关trace然后做滚动操作抓出来的时间轴能精确看到UI线程、RenderThread、SurfaceFlinger每行的耗时和等待状态。我最看重的是doFrame到queueBuffer之间的距离以及相邻两帧的间隔是否均匀。GPU呈现模式分析Profile GPU Rendering手机上开发者选项里的柱状图能直接看到每帧的耗时组成Draw/Prepare/Process/Execute快速判断是UI线程还是渲染线程的问题。注意别混淆柱状图显示的时间不只是onDraw而是整条渲染管线的一部分。dumpsys SurfaceFlinger命令行工具能查看各窗口的BufferQueue状态、帧率、合成方式、缓冲数。如果发现某个Surface长期占用三层缓冲且消费不及时那瓶颈很可能在合成侧。我经常看到一个现象开发者在代码里拼命优化onDraw结果剪头还是掉最后用Systrace一看RenderThread等GPU等了8ms。所以定位一定先看现象再看trace别急着改代码。5. 动手实践从链路图反推优化策略5.1 让布局扁平化本质是让DisplayList更短很多人知道布局要扁平化但不清楚原理。每次View层级过深draw时要生成的DisplayList指令树就会很复杂因为每个ViewGroup都要额外绘制背景、处理裁剪、计算子View的偏移。虽然现代Android对于纯线性的View树做了不少优化但复杂嵌套仍会让RenderThread执行命令时产生更多的矩阵变换和状态切换。我做过一个对比实验一个深度8层的RelativeLayout布局改成ConstraintLayout后帧绘制时间从9ms降到了5ms。这个提升并不是因为ConstraintLayout本身更快而是因为树的深度变浅RenderThread对每条绘制命令的颗粒度变大GPU能用更少的指令完成同样的画面。所以在写布局时每次都问自己这个ViewGroup真的有必要吗多个层级是为了功能还是习惯5.2 纹理上传最容易忽略的GPU杀手根据我提供了链路图你可能已经注意到GPU渲染前需要把Bitmap从CPU内存上传到GPU显存这个操作叫纹理上传。如果每次onDraw都新建一个Bitmap或者频繁更新一个图片控件GPU的纹理区域就会被反复擦写导致RenderThread阻塞。我踩过最典型的一个坑列表项中使用ValueAnimator不断改变控件背景色的alpha我图省事直接调用setColorFilter。结果这个操作触发底层创建新的纹理GPU每帧都上传一张256x256的小图看起来不大但积少成多直接把渲染时间从4ms拖到了12ms。后来我把ColorFilter的创建提到循环外面复用问题立刻解决了。所以只要涉及GPU渲染核心原则是别在onDraw或动画回调里创建Shader、Bitmap、ColorFilter等GPU相关对象。5.3 什么时候需要考虑硬件加速的边界Android的硬件加速默认开启但对很多API有严格限制。比如某些基于Canvas的clipPath在硬件加速下不能兼容所有模式或者在软件渲染下可以支持的复杂DropShadow效果在硬件加速下性能极差。你完全不需要记住所有API的限制列表但你需要明白当你发现某个绘制效果在手机上表现异常卡顿先试试用ViewCompat.setLayerType(LAYER_TYPE_SOFT)强制软件渲染对比一下帧耗时再看该效果是否值得保留。这个操作能很快让你区分代码逻辑问题和渲染特性本身的开销。5.4 使用Profile GPU Rendering监测链路节奏最后分享一个我每天都会看一眼的个人习惯。我开发时每隔半小时就会在真机上滑动几下列表打开开发者选项里的GPU呈现模式分析选择在屏幕上显示为条形图。绿色柱代表每帧的总耗时红色柱表示同步和上传时间蓝色柱表示顶点绘制橘黄色柱表示执行时间。如果你发现绿色柱呈现跳跳墙的形态说明某一帧的耗时突然变高这时候你立即停下来抓Trace而不是继续调业务代码。判断哪一相负责很简单柱子从上到下分色段最下面的是Draw其次是Prepare再往上Process最上面是Execute。如果最下面的Draw段很长问题在UI线程的measure/layout/draw如果Process或Execute很长问题在RenderThread和GPU。这种肉眼监测效率非常高能让我在日常开发中提早半拍发现链路问题。我在实际测试中发现绝大多数卡顿并不是不可解决的玄学而是链路中某一环节奏出了偏差。只要你脑中有了这条全景链路遇到掉帧时先定位是哪个环节慢了再决定用什么手段优化就不会再陷入凭感觉改代码的循环。这篇我用横向和纵向两个维度讲了链路全貌下一篇准备拿一个真实的复杂页面做案例从Systrace开始一步步还原优化全过程把今天这张图上的每个环节都落到实际trace里对应上。