RenderThread 的异步渲染,UI 线程真的减负了吗
RenderThread 的异步渲染,UI 线程真的减负了吗
从一次掉帧排查说起
去年在优化一个信息流页面的时候,我遇到了一个挺反直觉的现象。列表滑动时 Systrace 显示 UI 线程的 doFrame 耗时只有 6-7ms,远低于 16.6ms 的预算,但用户反馈和 GPU 呈现模式分析都明确显示有掉帧。更奇怪的是,掉帧的帧在 RenderThread 上往往伴随着一个长达 10ms 以上的 "DrawFrame" 区间。
这让我开始怀疑一个被反复提及的说法:RenderThread 把渲染工作异步化之后,UI 线程是不是就真的高枕无忧了?
Android 从 5.0(API 21)引入 RenderThread,到 7.0(API 24)强制所有应用使用硬件加速的 RenderThread 进行 UI 渲染,这个设计的核心思路很清晰——把 OpenGL ES 的 GPU 指令提交从 UI 线程剥离开,让主线程的 Choreographer 回调能更快结束。理论上看,UI 线程做完 Record View drawing commands 就可以交差,真正的 GPU 工作交给 RenderThread 在另一个线程慢慢执行。
但实际抓到的 trace 告诉我,事情没那么简单。
RenderThread 的工作边界到底在哪
要搞清楚减负效果,得先明白 RenderThread 到底承担了什么、没承担什么。我翻了下 Android 源码,特别是 frameworks/base/libs/hwui 目录下的实现,RenderThread 的核心循环在 renderthread/RenderThread.cpp 里,它确实运行在一个独立的线程,通过 DrawFrameTask 接收来自 UI 线程的任务。
但关键在于:RenderThread 执行的只是 "flush drawing commands to GPU",也就是把 UI 线程已经录制好的 DisplayList 翻译成 OpenGL 命令并提交给 GPU。这里有个容易误解的地方——DisplayList 的录制(record)阶段,也就是遍历 View tree、调用每个 View 的 draw(Canvas) 方法,这个工作仍然是在 UI 线程完成的。
// 这是 UI 线程上的典型调用链
Choreographer.doFrame
-> ViewRootImpl.performTraversals
-> ViewRootImpl.performDraw
-> ThreadedRenderer.draw
-> CanvasContext.draw // 这里开始录制 DisplayList
-> RootNode.draw
// 递归遍历所有 View,执行它们的 draw 方法也就是说,如果你的自定义 View 在 onDraw 里做了重活——比如复杂的 Path 运算、大量的 drawText 调用、或者没做缓存的 Bitmap 解码——这些全部压在 UI 线程上。RenderThread 救不了你。
我在那个信息流页面里找到的问题就是这个:一个自定义的圆角图片控件,每次 onDraw 都用 BitmapShader 做实时圆角裁剪,还带了阴影模糊。Systrace 里 UI 线程的 "Draw" 区间确实不长,因为硬件加速下 drawBitmap 很快,但 BitmapShader 的 setup 触发了大量的 GLES20.glUniform* 和纹理状态变更,这些状态被记录进 DisplayList 后,导致 RenderThread 在 flush 时遇到了一个超级重的 GPU command buffer。
同步栅栏:UI 线程被 RenderThread 反拖后腿的机制
更隐蔽的问题在于 UI 线程和 RenderThread 之间的同步机制。Android 的渲染管道有一个 "前缓冲/后缓冲" 的设计,具体实现依赖于 EGLSurface 的双缓冲交换。当 RenderThread 提交完一帧的 GPU 命令后,它需要调用 eglSwapBuffers,这个调用在 SurfaceFlinger 那边会触发一个 fence(EGL_KHR_fence_sync 或后来的 ANDROID_native_fence_sync)。
但这里有个坑:如果 GPU 还没执行完前一帧的命令,eglSwapBuffers 可能会阻塞,而 RenderThread 的阻塞会通过 DrawFrameTask 的 future/promise 机制反向传递到 UI 线程。
我在 Android 10(API 29)的设备上抓到了一个典型的死锁 trace:
UI Thread:
at java.util.concurrent.FutureTask.get (FutureTask.java:190)
at android.view.ThreadedRenderer.nSyncAndDrawFrame (Native method)
at android.view.ThreadedRenderer.draw (ThreadedRenderer.java:1043)
RenderThread:
at android.opengl.GLES20.glFinish (Native method)
at android.view.GLES20Canvas.flushLayerUpdates (GLES20Canvas.java:...)这个 nSyncAndDrawFrame 是 UI 线程在 ThreadedRenderer.draw 之后调用的,它的作用是让 UI 线程等待 RenderThread 完成前一帧的交换。设计者的意图是保证帧的顺序性,避免 UI 线程录制的 DisplayList 覆盖掉 RenderThread 还在使用的缓冲区。但在 GPU 负载高的场景下,这个等待直接把 RenderThread 的卡顿传导回了 UI 线程。
Android 10 之后,Google 引入了 SyncStrategy 的优化,尝试用 EGL_ANDROID_presentation_time 和更细粒度的 fence 来减少这种阻塞,但在低端设备上问题依然明显。我在一台 Snapdragon 660 的测试机上跑同样的页面,Android 10 相比 Android 9 的掉帧率只改善了约 15%,远没有理论上 "异步化" 应该带来的收益。
硬件加速的暗面:DisplayList 的录制开销被低估
很多人包括我自己早期都有一个错觉:开了硬件加速,draw 操作就是 "免费的",反正都推到 GPU 了。但 DisplayList 的录制本身有不可忽视的开销,而且这个开销随 View 复杂度指数增长。
我做过一个刻意构造的测试:在一个全屏的 RecyclerView 里,每个 item 包含 50 个简单的 TextView,分别测试硬件加速开启和关闭(通过 android:hardwareAccelerated="false")的滑动帧率。
// 测试用的简单 item 布局,重复 50 次 TextView
<LinearLayout>
<TextView android:text="A" ... />
<TextView android:text="B" ... />
<!-- 省略,共 50 个 -->
</LinearLayout>结果在 Pixel 3(Android 12)上,硬件加速开启时,快速滑动下 UI 线程的 draw 耗时平均 4.2ms,但 buildDisplayList 耗时 3.8ms,加上 nSyncAndDrawFrame 等待 2-5ms,一帧的总 UI 线程时间经常超过 16ms。而关闭硬件加速后,纯软件渲染的 draw 耗时 8-10ms,但没有 buildDisplayList 和 sync 开销,整体反而更稳定,虽然峰值帧率低,但掉帧的 "顿挫感" 更少。
这个测试当然极端,50 个 TextView 不合理,但它揭示了一个事实:硬件加速不是银弹,DisplayList 的录制、序列化、跨线程传输都有成本。Google 在 Android P(API 28)引入的 RenderNode 重构部分缓解了这个问题,把 DisplayList 的存储从 GLES20DisplayList 换成了更紧凑的 RenderNode + RecordingCanvas,但根本的录制开销还在。
Choreographer 的 "16ms" 谎言
另一个让我重新审视 RenderThread 价值的点,是 Choreographer 的回调时机和 SurfaceFlinger 的 VSYNC-app/VSYNC-sf 相位关系。
Android 4.1 引入的 Choreographer 确实让 UI 线程的动画和绘制能对齐 VSYNC,但 RenderThread 的加入让这个模型变复杂了。理想情况下,UI 线程在 VSYNC-app 到来时开始 doFrame,尽快完成 DisplayList 录制,然后 RenderThread 接力在 VSYNC-sf(通常比 VSYNC-app 晚几毫秒)前完成 GPU 提交。SurfaceFlinger 在 VSYNC-sf 时合成所有 layer,送显示。
但实际的相位关系不是固定的。从 Android 9 开始,Google 引入了 VSYNC-offset 的动态调整,试图根据历史帧时间自适应。我在 AOSP 的 SurfaceFlinger 源码里找到相关逻辑:
// frameworks/native/services/surfaceflinger/Scheduler/
// VSyncPredictor.cpp 的类似实现
if (mLastFrameTime > mVsyncPeriod * 0.8) {
mAppVsyncOffset = std::min(mAppVsyncOffset + 1ms, mVsyncPeriod / 2);
}这个动态调整的初衷是防止 UI 线程和 RenderThread 的工作重叠导致 deadline miss,但实际效果是:如果你的应用某几帧因为 GC 或 binder 调用慢了,VSYNC-offset 会被拉大,后续帧的可用时间窗口反而被压缩。我观察到过一个 case,连续 3 帧的 UI 线程耗时 12ms、14ms、11ms(都没超 16ms),但动态 offset 调整后,第 4 帧的 VSYNC-app 到 VSYNC-sf 间隔只剩 10ms,RenderThread 来不及完成,直接 miss。
这种情况下,UI 线程自己的耗时统计是 "合规" 的,但用户看到的仍然是掉帧。Systrace 里 RenderThread 的 DrawFrame 会显示一个红色的 "deadline miss" 标记,而 UI 线程的 doFrame 是绿色的——这种割裂让排查变得困难。
一个具体的修复案例:TextureView 的陷阱
回到我最初那个信息流页面的问题,最终的修复方案不是优化 RenderThread,而是绕开它的一部分工作。
页面里有一个视频播放区域,用的是 TextureView。TextureView 的特殊之处在于它把自己的内容渲染到一个 SurfaceTexture,这个 SurfaceTexture 作为 OpenGL 的 external texture 被合成到 View hierarchy 里。这意味着每一帧视频更新,都需要:
1. 视频解码线程把帧送到 SurfaceTexture
2. SurfaceTexture 的 onFrameAvailable 回调触发 TextureView 的 invalidate
3. UI 线程的 doFrame 里,TextureView 的 draw 方法绑定 external texture
4. RenderThread 把这个 external texture 的采样命令 flush 到 GPU
问题出在步骤 3:external texture 的绑定在 UI 线程触发了 GLES20.glBindTexture,而 TextureView 的实现为了保证线程安全,会在 draw 里加一个 synchronized(mSurface) 的锁。视频是 30fps,每 33ms 来一帧,如果 UI 线程的 doFrame 刚好和视频帧到达撞车,这个锁竞争可以把 draw 拖到 5ms 以上。
更糟的是,RenderThread 在处理这一帧的 DisplayList 时,遇到 external texture 需要等一个 fence 确保视频解码器写完,这个 fence wait 又可能阻塞 RenderThread 的后续工作。
我的修复是把 TextureView 换成了 SurfaceView + setZOrderMediaOverlay(true)。SurfaceView 有独立的 Surface,不经过 View hierarchy 的 DisplayList,直接由 SurfaceFlinger 合成。这意味着:
invalidateSurfaceView 的 z-order 是独立的,不能和其他 View 做正常的上下层混合这个改动之后,同页面的滑动掉帧率从 12% 降到了 3% 以下。Systrace 里 UI 线程的 doFrame 平均耗时从 9ms 降到 5ms,RenderThread 的 DrawFrame 也从经常的 8-15ms 变成了稳定的 3-5ms。
但这个修复的讽刺之处在于:RenderThread 的 "减负" 效果,是通过减少它要处理的工作量来实现的,而不是让它更高效地处理同样多的工作。
Android 12 的渲染重构:RenderThread 还在进化
Google 显然也意识到了这些问题。Android 12(API 31)引入了比较大的渲染管线重构,核心是把 libhwui 里的 OpenGL ES 后端逐步迁移到 Vulkan,同时引入了 SkiaGraphite 作为新的 GPU 后端。
我在 Android 12 的模拟器和 Pixel 6 上测试了同一个页面,发现 RenderThread 的 DrawFrame 结构变了。以前一个大的 DrawFrame 区间里主要是 glDrawElements 和 eglSwapBuffers,现在变成了多个小的 RenderPass,每个 RenderPass 有更明确的 beginRenderPass/endRenderPass 边界,而且引入了 VulkanCommandBuffer 的显式管理。
// Android 12+ 的典型 RenderThread trace
RenderThread: DrawFrame
- RenderPass: 0
- beginRenderPass (LoadOp: Clear)
- drawRect (x: 0, y: 0, w: 1080, h: 1920)
- endRenderPass (StoreOp: Store)
- RenderPass: 1
- beginRenderPass (LoadOp: Load)
- drawDisplayList (node: 1234)
- endRenderPass
- submitCommandBuffer
- queueSignalSemaphore这个重构的收益是更细粒度的并行和更少的隐式同步。Vulkan 的 semaphore 和 fence 比 OpenGL ES 的 eglSwapBuffers 内隐式同步更可控,理论上可以减少 UI 线程在 nSyncAndDrawFrame 的等待。但实际测试下来,对于普通 2D UI 应用,帧率提升不明显,甚至在小范围测试里 Vulkan 后端的启动时间和内存占用还略高于 OpenGL ES。
Google 在 Android 13 里把 Vulkan 后端设为可选,Android 14 才在部分设备上默认启用。这个渐进策略说明渲染管线的迁移比预期复杂,RenderThread 的异步化优势要完全发挥,还依赖 GPU 驱动和 SurfaceFlinger 的配合。
个人看法:RenderThread 解决的是特定年代的问题
回顾 RenderThread 的设计历史,它诞生于 Android 5.0 时期,当时的核心矛盾是:GPU 能力越来越强,但 OpenGL ES 的调用必须在有 GL context 的线程进行,而 UI 线程被 Java 层的测量、布局、动画回调严重阻塞。RenderThread 的异步化在当时是很大的进步,至少让 eglSwapBuffers 的阻塞不再直接卡死 UI 线程的触摸响应。
但到了今天,这个设计的局限性越来越明显:
第一,DisplayList 录制仍然是 UI 线程的沉重负担,而且随着 Material Design 的复杂阴影、模糊效果、动态色彩(Android 12 的 Monet),录制开销在增长而不是减少。
第二,GPU 的 tile-based deferred rendering(TBR/TBDR)架构让 "尽快提交命令" 的策略不再最优。ARM Mali 和 Qualcomm Adreno 都喜欢大的 render pass,频繁的小绘制和状态切换反而降低效率。RenderThread 的异步化某种程度上鼓励了这种 "小步快跑" 的绘制模式。
第三,高刷新率屏幕(90Hz/120Hz)让帧时间预算缩短到 11ms 甚至 8ms,UI 线程和 RenderThread 的串行/并行关系更紧张。任何一方的波动都更容易突破 deadline。
我不太认同一种社区里的说法,认为 RenderThread 让 Android 的渲染性能 "接近 iOS"。iOS 的 Core Animation 从设计上是真正的双缓冲异步:应用线程把 layer tree 提交给 render server,render server 在另一个进程做真正的 GPU 工作,应用线程完全不参与 DisplayList 录制。Android 的 RenderThread 仍然和应用线程共享进程,共享内存,同步机制也更重。
给实际开发者的建议
基于这些踩坑经历,我现在的优化思路会分几个层面来看:
如果 Systrace 显示 UI 线程 draw 耗时高,优先检查自定义 View 的 onDraw 是否在做实时计算。能用 Picture 或 RenderNode 缓存的 DisplayList 就缓存,Android P 之后的 RenderNode 有 beginRecording/endRecording 可以显式控制。
// 缓存 RenderNode 的典型模式
val renderNode = RenderNode("cached").apply {
setPosition(0, 0, width, height)
beginRecording().let { canvas ->
// 执行复杂的绘制
drawComplexContent(canvas)
endRecording()
}
}
// 后续直接绘制缓存的 node
canvas.drawRenderNode(renderNode)如果 RenderThread 的 DrawFrame 长但 UI 线程短,看是否有大量的小绘制调用、频繁的 texture upload、或者 TextureView/SurfaceView 混用导致的同步开销。Profile GPU Rendering 的 "Draw" 柱状图如果黄色(CPU 等待 GPU)部分高,往往是 GPU 端瓶颈。
如果掉帧是偶发的、和特定操作相关,重点看 nSyncAndDrawFrame 的等待时间,以及是否有 Choreographer 的 doFrame 被延后。Android 11 引入的 FrameMetrics API 可以更方便地抓取这些信息:
window.addOnFrameMetricsAvailableListener({ _, frameMetrics, _ ->
val frameDeadline = frameMetrics.getMetric(FrameMetrics.DEADLINE)
val framePresent = frameMetrics.getMetric(FrameMetrics.PRESENT_TIME)
val jank = framePresent > frameDeadline
val syncDuration = frameMetrics.getMetric(FrameMetrics.SYNC_DURATION)
// syncDuration 包含了 nSyncAndDrawFrame 的等待
}, handler)最后,对于视频、相机预览等连续帧场景,认真考虑 SurfaceView 替代 TextureView 的代价。失去 View hierarchy 的灵活性是真实的成本,但如果你的视频层本来就是全屏底图或者固定浮层,SurfaceView 的独立合成路径能显著降低 RenderThread 和 UI 线程的耦合。
一个未解的疑问
写到这里,我还有一个没完全想明白的问题。Android 的 Jetpack Compose 从设计上是完全基于 RenderNode 和 AndroidComposeView 的,它的整个组合、布局、绘制流程比传统 View system 更紧凑,理论上应该更少受 RenderThread 同步的困扰。但我在 Compose 1.4 的测试里,仍然观察到类似的 nSyncAndDrawFrame 等待模式,而且 Compose 的 Recomposer 和 AndroidUiDispatcher 引入了额外的协程调度层,让 trace 的解读更复杂。
Compose 的 LayoutNode 绘制是否比传统 View 的 DisplayList 录制更高效?Modifier.graphicsLayer 的离屏缓存和 RenderNode 的硬件 layer 是什么关系?这些是我接下来想深挖的方向。如果有人已经在生产环境做过 Compose 和传统 View 的同场景性能对比,特别是 RenderThread 层面的 trace 分析,我很想知道实际数据是什么样的。