ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Android图形系统详解:从BufferQueue到SurfaceFlinger的渲染与合成流程

Android图形系统详解:从BufferQueue到SurfaceFlinger的渲染与合成流程 1. 整体架构梳理一次屏幕刷新背后的完整接力我一直觉得搞Android开发如果只停留在API层面很多性能问题会显得特别“玄学”——比如为什么有时候列表滑动就是卡一下为什么同一个界面在不同机型上流畅度差那么多。这些问题如果往下追一层追到图形系统层面答案其实非常清晰每一次屏幕刷新都是一场从App到硬件的接力赛任何一个环节掉链子用户看到的就是掉帧或者卡顿。这篇是图形系统系列的第三篇聚焦在“系统篇”也就是抛开具体API调用看渲染和合成这两条线在系统底层是怎么组织起来的。在开始之前我先给一个整体的地图一次屏幕内容更新大致要经过四个阶段——App进程内的UI绘制、RenderThread的渲染指令执行、SurfaceFlinger的合成决策、硬件显示器的最终呈现。这四段各有各的职责也各有各的瓶颈。App主线程负责执行布局和绘制逻辑生成显示列表RenderThread负责把显示列表真正变成GPU指令SurfaceFlinger是系统级的合成器决定哪些Layer需要合成、用什么方式合成而Display Hardware则负责把合成结果扫描输出到屏幕。很多资料喜欢直接讲SurfaceFlinger但我个人经验是如果没有先搞清楚App侧是怎么生产帧的看SurfaceFlinger时会很懵。因为SurfaceFlinger合成的是Buffer而你如果没有理解Buffer是怎么从App侧流转过来的就很难明白为什么系统有时候会选择GPU合成有时候会选择硬件合成更别说去理解那些带“卡顿”字眼的Trace了。这个架构里最核心的一个概念就是BufferQueue。理解Android图形系统BufferQueue是关键中的关键它可以说是生产者-消费者模型在系统层面的一个标准实现。App是生产者SurfaceFlinger是消费者中间的缓冲队列负责协调两者之间的节奏。后面所有关于“三缓冲”、“掉帧”、“VSync”的讨论本质上都是围绕这个队列的调度策略展开的。我把这个流程里最关键的几个角色先列出来后面会逐个拆解WindowManagerServiceWMS管理窗口层级决定App窗口的Z序和可见性。SurfaceApp侧持有的“画板”本质是BufferQueue的生产端接口。SurfaceFlingerSF系统合成器管理所有Layer并负责最终合成。HWCHardware Composer硬件合成器抽象层把合成任务卸载给显示硬件。BufferQueue连接生产者和消费者的缓冲区队列图形数据流转的枢纽。看到这里你会发现Android的图形系统其实是一个分工极其明确的流水线。每个模块各管一段模块之间通过Buffer和同步机制通信。接下来我会按照这条流水线的顺序把每一段的底层原理、关键机制和容易踩的坑都过一遍。2. 核心机制拆解从BufferQueue到VSync的完整链路2.1 BufferQueue的运作模式生产者、消费者与Buffer流转BufferQueue这个名字听起来有点唬人但它做的事情其实很朴素App往队列里生产BufferSurfaceFlinger从队列里消费Buffer消费完的Buffer再还给App循环使用。这就是一个典型的“生产者-消费者”模型只不过在Android里这个模型的调度是和屏幕刷新率强绑定的。具体来说一个BufferQueue的默认容量通常是3个Buffer在启用三缓冲的情况下双缓冲时是2个。App侧用dequeueBuffer申请一块空闲Buffer往里面画内容画完之后用queueBuffer提交给队列。SurfaceFlinger侧则是通过acquireBuffer获取一块已提交的Buffer用于合成合成完成后再用releaseBuffer释放让这块Buffer重新回到App可用的池子里。这个循环里最值得注意的一点是App并不是随时都能拿到空闲Buffer。如果App生产帧的速度比SurfaceFlinger消费帧的速度快队列里的Buffer全部被占满那么dequeueBuffer就会阻塞。这个阻塞的时长在Systrace里通常表现为“waitForBuffer”或者“dequeueBuffer waiting”一旦出现就说明App的生产节奏超过了系统消费能力这时候我们就该考虑是不是需要三缓冲来兜底或者App侧的渲染负载是不是太重了。另一个容易被忽略的点是Buffer的分配方式。在Android 8.0之后系统默认使用BufferQueue的生产者接口分配GraphicBuffer底层的内存一般来自ION分配器或者更早的PMEM。这块内存是要被CPU和GPU同时访问的所以分配时通常会带上“软件读写”和“硬件合成”的标记。如果你在自定义Surface时用了不恰当的usage标志有可能会导致CPU读像素时性能极差因为内存在GPU域和CPU域之间的同步开销会被成倍放大。2.2 VSync与掉帧为什么帧率被钉死在屏幕刷新率上VSync垂直同步是整个图形系统的“心跳”。我们可以把屏幕刷新想象成一列按固定时刻发车的地铁屏幕每16.6ms60Hz情况下扫描一次硬件扫描完一帧就会产生一个VSync信号。这个信号有两个作用一是告诉屏幕可以开始下一帧的扫描输出了二是通知App和SurfaceFlinger“新的一轮帧生产窗口开始了”。在Android 4.1之后系统引入了Project Butter黄油计划核心改动就是让App的UI绘制和SurfaceFlinger的合成动作都跟随着VSync信号来触发而不是“谁准备好了谁就干”。为什么强制这种节奏因为如果没有VSyncApp可能在屏幕扫描到一半的时候往FrameBuffer里写数据屏幕上就会出现撕裂tearing——上半屏是上一帧的内容下半屏是这一帧的新内容。VSync本质上是用“让所有人排队上车”的机制来换取画面的完整性。掉帧jank是怎么发生的很简单如果App在规定的16.6ms内没有完成渲染并提交Buffer那么这一轮VSync到来时SurfaceFlinger手里没有新Buffer可用它就只能继续显示上一帧。于是用户看到的是“画面停滞了一下”也就是我们常说的“掉了一帧”。实际排查中大家最喜欢看的就是Systrace里的帧时间线。一帧从Choreographer回调开始到RenderThread提交再到SF合成完毕每一段都有自己的耗时标志。我习惯先看总耗时是否超过VSync周期如果超过了再逐段定位是主线程布局耗时太长还是渲染线程的Draw耗时太长或者是BufferQueue拿不到Buffer的等待时间太长。这三种情况的优化方向完全不同但如果你不看Systrace只能靠猜效率很低。2.3 双缓冲与三缓冲核心权衡点缓冲策略是整个图形系统里最经典的一个权衡点。双缓冲意味着App和SF之间只有两个Buffer交替使用一个正在被App绘制另一个正在被SF合成显示。理想情况下每帧都能严丝合缝地衔接延迟最低。但现实里只要某一帧App生产超时哪怕只有2ms双缓冲就会立刻丢帧因为App在下一轮VSync没有可用的空闲Buffer必须等SF把当前Buffer释放回来。三缓冲多给了一块Buffer做缓冲余量相当于在地铁每次进站时多预留了一列备用列车。App即使某一帧慢了半拍下一帧还有机会赶上车不会立刻丢帧。代价是输入到显示的延迟会略微增加因为多等了一次排队。在Android中开发者可以通过setFrameRate()、SurfaceFlinger的属性配置或者在部分设备上通过“强制开启三缓冲”的开发者选项来影响这个决策。现实情况是现在大多数中高端机型默认启用了三缓冲因为用户在滑动流畅度上的感知远大于那几毫秒延迟。但你在做自定义渲染引擎或者对延迟极敏感的场景比如游戏触控跟手性时仍然需要注意三缓冲不是万能的如果App侧渲染本身就超过两个VSync周期那再加缓冲也只是把卡顿从“掉帧”变成“整体节奏变慢”体验反而更糟。3. 渲染管线App侧的一帧是如何诞生的3.1 HWUI引擎与RenderThread硬件加速的基石App侧的渲染或者说UI绘制现在基本都跑在HWUI这条渲染管线上。HWUI是Android系统自带的基于Skia的硬件加速渲染引擎它的核心思路是“先记录再绘制”。主线程只负责执行View的measure、layout和draw但这些draw操作并不会立即变成GPU指令而是会被记录成一批绘制指令RenderNode树和DisplayList然后交给RenderThread去做真正的GPU提交。这个设计非常巧妙。引入RenderThread之后即便主线程因为业务逻辑繁忙比如处理点击事件、解析JSON只要它能在某个时刻把DisplayList提交给RenderThreadRenderThread依然可以在下一帧的窗口内完成GPU绘制。换句话说主线程和渲染线程是并行的卡顿的源头就从“主线程卡了”变成了更细的“主线程提交DisplayList慢了”或者“渲染线程执行绘制慢了”。需要注意的是虽然叫“硬件加速”但并不代表所有绘制操作都会被GPU接管。比如一些非常简单的颜色填充HWUI可能会选择软件绘制而某些复杂的Path、Bitmap绘制则必须走GPU。很多时候你去看RenderThread的耗时发现一个opaque的View居然触发了复杂纹理上传那就说明View在某个时刻调用了setWillNotDraw(false)但实际没有内容导致系统没有走“跳过绘制”的快速路径。这种细节在Systrace里并不直接显示需要结合Layout Inspector和UI渲染日志来定位。3.2 Choreographer与帧回调控制帧生产的节拍器Choreographer是App主线程里的“节拍器”它通过监听VSync信号来安排一帧里要做的事情输入事件处理Input、动画计算Animation、视图布局与绘制Traversal。每轮VSync信号到来Choreographer依次触发这三个回调。这也是为什么我们在主线程做耗时操作时会直接表现为掉帧——因为主线程还在忙上一个事件没空响应Choreographer的Traversal回调。关于Choreographer有一个很常见的误区就是“把耗时操作放在其他线程就没事了”。实际上如果该耗时操作需要访问UI的状态或者最终还要通过Handler切回主线程更新界面那它依然会挤占主线程的帧回调时间。所以做性能优化时不仅要看“方法本身耗时多少”还要看“它在主线程上占用的时间窗口是否与VSync周期冲突”。另外Choreographer.FrameCallback是一个非常好用的性能监控手段。你可以自己注册一个FrameCallback统计相邻两次回调的时间差如果间隔接近VSync周期的整数倍就能判断是否发生掉帧。很多APM框架的“帧率统计”功能本质上就是基于这个机制实现的。3.3 绘制指令的提交与影响DisplayList和RenderNode构建完毕后RenderThread要做的核心事情是“同步”——把主线程记录的绘制指令同步到渲染线程生成GPU能理解的硬件缓冲区。这一步在Systrace里通常显示为RenderThread: syncFrameState。在此之后RenderThread会调用OpenGL ES或Vulkan的draw call来执行绘制。draw call的组织方式、纹理上传策略、着色器编译缓存都会直接影响渲染耗时。好消息是这些在标准Android View系统中都被HWUI做了很大程度的优化。坏消息是如果你自己用SurfaceView或者TextureView做自定义渲染这些优化就需要你自己去实现。举个例子TextureView本质上是一个携带SurfaceTexture的View它比SurfaceView多了一层“纹理映射”的过程——每一帧的内容要从摄像头或解码器写入的Buffer中先变成纹理再被HWUI当作普通View内容绘制到屏幕上。这层映射是有额外开销的所以如果你只是做视频播放或相机预览推荐直接使用SurfaceView它能绕过HWUI的绘制管线把Buffer更直接地送到合成器侧。4. 合成机制SurfaceFlinger如何把多图层变成一屏4.1 Layer模型与窗口Z序SurfaceFlinger管理的每一个窗口在系统里对应一个Layer。每个Layer代表一个独立的绘制表面有自己独立的BufferQueue。App侧的Surface、SurfaceView、TextureView最终都会在SurfaceFlinger里对应一个Layer。Layer之间是有层级关系的这个层级由WindowManager根据窗口的Z序来决定。手机上叠了多个窗口状态栏在最上面然后是应用的Dialog、应用主窗口、壁纸在最底下。SurfaceFlinger需要按照Z序把这些Layer合成到一起最终生成一帧完整的画面。这里有一个细节值得展开Layer是“透明叠加”的。如果最上层的窗口在某个区域是透明的那么合成器必须读取下一层的数据来填充该区域。这就引出了“不透明区域”优化——如果一个窗口是它所在区域完全是不透明的合成器就可以跳过它下面那层的合成节省GPU开销。你在做动画或者弹窗时如果某个全屏View设置了半透明背景往往会影响合成性能因为它破坏了“不透明优化”这一条路径。平时我们可能感知不到但在低端机上一个半透明全屏浮层能明显拉高GPU负载。4.2 Client合成与Device合成HWC的作用SurfaceFlinger拿到多个Layer的Buffer后有两种合成方式一是靠GPU自己来做合成Client合成即OpenGL ES合成二是把Layer列表交给硬件合成器让显示硬件Display Hardware直接扫描输出多个LayerDevice合成即HWC合成。HWC是现代Android设备上非常重要的一个硬件抽象层。显示芯片通常内置了多个硬件图层Overlay planes可以把2到3个Layer直接混合后输出到屏幕而不需要GPU介入。这样可以大幅降低GPU负载和功耗因为GPU只需要处理那些“不能由硬件图层覆盖”的部分。SurfaceFlinger有一个决策流程收到VSync后调用HWC的presentDisplay接口HWC会返回它所支持的合成方案。如果发现有些Layer没法由硬件Overlay处理SurfaceFlinger就会把这些Layer标记为需要GPU合成剩下的交给硬件。这种“硬件能干的给硬件干硬件干不了的GPU兜底”的策略才是Android合成系统的真实工作方式。4.3 区域性刷新与损坏区域优化早期Android版本的合成策略是“全屏刷新”——只要有一个窗口的内容变了整个屏幕都重新合成。这会带来多余的GPU计算和功耗。现在的系统引入了“损坏区域”Damage Region机制App在提交Buffer时会附带一个“本次绘制改变了哪些区域”的信息SurfaceFlinger在做合成时只对这些区域做处理。这个优化对实际体验的提升非常明显尤其是键盘弹起、进度条更新、局部动画这类场景。开发者其实也能参与其中只要在View系统里尽量避免“全屏尺寸的invalidate”就能让损坏区域小一点。比如一个只占屏幕5%的进度条如果因为你在onDraw里不小心写了invalidate()整个页面都重绘损坏区域就是整个屏幕合成负载和GPU绘制负载都会被放大。用View.postInvalidateOnAnimation并精确指定重绘区域、或者利用clipRect限制绘制范围都是很实用的优化手段。理解了损坏区域后再去分析一些“低端机卡顿”问题你会有新的思路往往不是单帧绘制太慢而是合成时损坏区域经常被扩大导致GPU的合成负载一直维持在高位。排查时可以用开发者选项里的“显示GPU过度绘制”和“调试GPU过度绘制”来做粗筛更精准的则需要在HWC层抓Trace分析。5. 常见问题与排查技巧实录说了这么多原理下面分享几个我实际排查图形相关问题时用得最多的手段以及经常踩到的坑。5.1 同一帧反复出现dequeueBuffer等待现象滑动页面时Systrace里频繁出现dequeueBuffer: waitForBuffer帧时间呈锯齿状波动。思路先判断是App生产端慢还是消费端慢。如果RenderThread的draw本身没超时那大概率是SF消费不及时要么是合成负载太高要么是HWC处理慢。如果RenderThread的draw超时了那要回看主线程的DisplayList构建、纹理上传等环节。实操建议打开Systrace时抓取sched,gfx,view三类tag时长建议5秒。帧时间线上正常情况下每帧之间应该间隔均匀出现等待时帧之间的空隙会突然拉长。优先看主线程是否有长时间阻断的任务再看RenderThread的syncFrameState和draw是否超时。如果确认是SF消费慢可以尝试降低SurfaceView的分辨率或者检查HWC配置里是否有异常。5.2 半透明效果导致GPU负载暴涨现象低端机上打开一个带模糊毛玻璃效果的全屏浮层后所有界面操作都变得迟滞。原因模糊效果不仅意味着额外的RenderPass还意味着Layer本身从“不透明”变成了“半透明”GPU需要额外处理混合逻辑同时底层Layer的合成也无法走硬件Overlay优化。多重因素叠加GPU负载自然就上去了。实操建议对全屏浮层尽量避免使用setAlpha或半透明背景色做过度频繁的动画。如果确实需要模糊考虑预渲染成静态纹理而不是每一帧实时模糊。在开发者选项里开启“显示GPU过度绘制”如果看到浮层区域出现红色就说明这一块已经在超量绘制了。5.3 为什么SurfaceView和TextureView的性能差那么多现象同一个视频播放场景切到TextureView后帧率骤降GPU占用明显上升。原因前面说过TextureView要把每一帧内容转换成纹理再交给HWUI做合成而SurfaceView本质上是一个独立的Layer可以直接进入SF的合成流程不经过HWUI。多出来的纹理上传和合成步骤就是性能差的根源。实操建议播放视频、相机预览优先用SurfaceView。如果必须用TextureView做动画变换尽量控制视频分辨率并且不要同时对TextureView做频繁的缩放或旋转因为这些变换会触发纹理的重新采样开销极高。5.4 自绘引擎或者自定义View的脏矩形失效现象自绘View只做了小范围变动但整页都跟着重绘帧时间从2ms跳到8ms。原因View系统的invalidate(Rect)虽然能指定脏区域但在硬件加速渲染下最终决定权在RenderNode的损坏区域计算逻辑。如果View在onDraw里调用了会清空DisplayList的操作系统可能需要重建整个RenderNode而不是只更新一部分。实操建议自绘View里尽量用save()/restore()和clipRect()配合把绘制范围限制住。避免在onDraw里频繁创建Paint、Path、Bitmap对象对复用的绘制对象做缓存。做局部刷新时可以先调用invalidate(Rect)指定小区域再检查RenderThread的draw耗时是否变小。5.5 关于Trace的实用技巧有时候看Systrace会发现整帧耗时并不长但画面就是感觉“不跟手”。这时候我通常会去关注input latency也就是从触摸事件到UI真正响应的间隔。这个指标并不完全等于帧耗时还包含了输入事件的分发链路。你可以通过dumpsys input查看输入事件的时间点再结合Choreographer的帧回调时间计算整体的响应延迟。另外现代设备都支持Perfetto它比Systrace更灵活支持SQL查询和自定义计数器。排查图形问题我一般会在Perfetto里直接搜索vsync和doFrame相关的slice快速锁定主线程和渲染线程的时间线。提示抓Trace是个脏活但只有基于Trace的优化才算是“有的放矢”。凭感觉调优往往是在制造新的问题。6. 一点个人经验与扩展方向写到这里其实Android图形系统的核心脉络已经比较清楚了App端通过HWUI把UI绘制变成渲染指令RenderThread执行GPU绘制BufferQueue负责缓冲和同步SurfaceFlinger在VSync的驱动下完成多Layer合成HWC尽可能用硬件Overlay来分担GPU压力最终由显示硬件扫描输出到屏幕。这个流水线里的每一个环节都是可以继续往下钻的比如GPU驱动层的同步机制、显示内存分配器ION/gralloc的细节、Vulkan渲染管线的接入方式等等。我个人在实际排查中最大的体会是图形性能问题很少只有一个原因大多数时候是多个瓶颈叠加出来的。遇到卡顿不要急着改代码先拉一个Trace把“主线程耗时”、“RenderThread耗时”、“SF合成耗时”、“Buffer等待耗时”四段分别量化哪个超标就重点看哪段。这个方法虽然听起来朴素但比任何花哨的优化技巧都管用。最后分享一个小的扩展方向如果你对这套机制特别感兴趣可以从android.view.ThreadedRenderer、android.graphics.HardwareRenderer、SurfaceFlinger这几个类的源码入手配合Systrace一起读理解速度会快很多。尤其是ThreadedRenderer里的drawFrame方法几乎把App侧一帧的完整路径都串起来了搞懂它你对整个Android图形系统的理解会上一个很大的台阶。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进