Android RenderThread 与 HWUI 渲染管线深度解析:从 DisplayList 录制到 GPU 光栅化的帧生产全链路

性能优化时我经常遇到一个让人困惑的现象:Systrace 里 RenderThread 的耗时远大于主线程的 draw,但代码里看不出有什么复杂绘制。深入 HWUI 源码后才明白——主线程只是”导演”,真正的渲染工作在另一条流水线上静默运行。

这篇文章梳理从 View 触发 invalidate 到像素写入 Framebuffer 的完整链路,重点放在 RenderThread 的调度模型、DisplayList 的生命周期,以及 Compose 如何复用这套底层机制。

两线程分工:主线程录制,RenderThread 回放

Android 4.0 引入硬件加速后,渲染架构从单线程变成了两阶段流水线:

  • 主线程:执行 View.draw(),不直接调用 OpenGL/Vulkan,而是把绘制命令录制进 DisplayList
  • RenderThread:读取 DisplayList,驱动 GPU 完成光栅化,最后通过 SurfaceFlinger 合成上屏

这个设计的核心价值不是”并行”——两个线程之间存在严格的同步屏障——而是把 GPU 驱动调用从主线程剥离,避免驱动层的不确定延迟阻塞 UI 响应。

两个线程的同步点在 syncFrameState:主线程在这里把 Java 对象状态同步到 native RenderNode,完成后主线程立即释放,RenderThread 独自完成后续的 GPU 指令提交。

DisplayList 的录制机制

每个 View 对应一个 RenderNodeRenderNode 持有一个 DisplayList(由 RecordingCanvas 录制产生)。录制入口是 View.updateDisplayListIfDirty()

// View.java(简化)
RenderNode renderNode = mRenderNode;
if (!renderNode.hasDisplayList()) {
    // 创建录制画布
    RecordingCanvas canvas = renderNode.beginRecording(width, height);
    try {
        draw(canvas); // 触发 onDraw,所有绘制命令写入 DisplayList
    } finally {
        renderNode.endRecording(); // 封装 DisplayList,标记 valid
    }
}

RecordingCanvas 在 native 层对应 SkiaRecordingCanvas,继承自 SkCanvas 但不真正绘制——每次 drawRectdrawBitmap 等调用都被序列化成 DisplayListOp 追加到列表尾部。

这套机制有一个关键特性:子 View 的 DisplayList 以引用方式嵌入父 View,而非展开拷贝。父 View 录制时遇到子 View 只插入一条 DrawRenderNode 指令,子 View 的 DisplayList 保持独立。子 View 局部刷新时只需重录自己的 DisplayList,父节点树结构无需重建。

RenderNode 树同步与 CanvasContext

每帧开始,ThreadedRenderer 触发 syncAndDrawFrame,进入主线程与 RenderThread 的同步阶段:

// CanvasContext.cpp(Android 13 源码简化)
void CanvasContext::prepareTree(TreeInfo& info) {
    mRootRenderNode->prepareTree(info);  // 递归同步整棵 RenderNode 树
    // 此时主线程被 syncFrameState 阻塞,等待这里完成
}

prepareTree 递归遍历整棵 RenderNode 树,执行两类工作:

  1. 属性同步:把 Java 层的 translationXalphaclipBounds 等属性同步到 native 对应字段
  2. DisplayList 标脏传播:某个节点的 DisplayList 被重录后,向上传播 dirty 标记

同步结束后主线程解锁,RenderThread 独占这棵树,开始真正的绘制阶段。

Skia 后端与 Vulkan 光栅化

RenderThread 拿到 RenderNode 树后,交给 SkiaPipeline 驱动 Skia 完成光栅化。Android 9 之前默认用 OpenGL 后端,Android 12 起在多数设备上切换到 Vulkan 后端。

两个后端对上层 HWUI 完全透明——SkiaPipeline 只关心 SkSurfaceSkCanvas,不感知下层是 GL 还是 Vulkan:

// SkiaPipeline.cpp(简化)
void SkiaPipeline::renderFrame(SkCanvas* canvas) {
    // 回放 DisplayList,转换为 Skia 绘制调用
    RenderNodeDrawable root(mRootNode, canvas);
    canvas->drawDrawable(&root);
    // Skia 内部根据后端生成 GL draw call 或 Vulkan command buffer
}

Vulkan 后端的优势在于 command buffer 可以多线程并行录制。OpenGL 的状态机模型是全局的,必须串行;Vulkan 的 command buffer 天然隔离,HWUI 可以把不同层的渲染命令分配给多个线程并行录制,再提交到同一个 queue。复杂界面在 Vulkan 后端下帧时间更稳定,原因就在这里。

升级 targetSdk 33 后踩过一个坑:部分老设备强制开启 Vulkan,自定义 View 里用了 Canvas.drawPicture 的地方,这个 API 在 Skia Vulkan 后端有路径差异,触发了额外的 CPU 端 readback,帧耗时反而上升。解决方式是把 drawPicture 改为直接绘制或录制进 RenderNode

Compose 的 RenderNode 映射

Compose 在 Android 上完全复用了 HWUI 的 RenderNode 体系,并没有独立的渲染引擎。

每个 @Composable 经过布局后,对应一个 OwnedLayer(内部持有 RenderNodeLayer),RenderNodeLayer 封装了一个 native RenderNode。Compose 的 invalidate 触发该 RenderNode 的重录,走的是与普通 View 完全相同的 updateDisplayListIfDirtysyncAndDrawFrame 路径。

// AndroidComposeView.kt(Compose 内部,简化)
override fun dispatchDraw(canvas: Canvas) {
    // root 对应整棵 Compose 组件树的 RenderNode
    composeOwner.draw(canvas)
}

区别在录制层:View 的 onDraw 直接操作 Canvas,Compose 通过 DrawScope 抽象了绘制调用,底层 Canvas 实现在 Android 上是 SkiaCanvas,写入的仍然是 HWUI 的 RecordingCanvas

这个统一带来一个重要推论:Compose 的动画重组只要不超出 RenderNode 属性范围(位移、缩放、透明度、裁剪),就可以完全绕过主线程重组,由 RenderThread 上的动画系统直接驱动属性变化。Modifier.graphicsLayer 就是这个机制的直接映射——它创建一个独立 RenderNode,动画值直接写入该节点的 transform 属性,不触发任何 Compose 重组。

// 动画仅在 RenderThread 上运行,不触发重组
Box(
    modifier = Modifier.graphicsLayer {
        translationX = animatedOffset.value  // 直接映射 RenderNode.translationX
        alpha = animatedAlpha.value
    }
)

帧生产全链路梳理

把各阶段串起来,一帧的生产过程如下:

主线程:
  Choreographer vsync 回调
    → View.invalidate 传播
    → updateDisplayListIfDirty(录制 DisplayList)
    → syncFrameState(阻塞,等待 RenderThread 同步完成)
    → 解锁,继续处理下一帧输入

RenderThread:
  prepareTree(同步 RenderNode 属性)
    → SkiaPipeline.renderFrame(回放 DisplayList → Skia 调用)
    → Skia 生成 GL draw call / Vulkan command buffer
    → eglSwapBuffers / vkQueueSubmit
    → SurfaceFlinger 合成上屏

syncFrameState 是这条流水线的关键瓶颈。主线程被阻塞的时长取决于上一帧 prepareTree 的耗时——如果 RenderThread 上一帧还没跑完 prepareTree,主线程就得等。Systrace 里看到主线程有大段 syncFrameState 等待,通常说明 RenderThread 存在积压,要排查的是 GPU 耗时或 DisplayList 过于复杂(大量 saveLayer、大面积模糊等)。

实践建议

减少无效重录invalidate 的范围要精确,避免父容器全量重绘。Compose 中把可变状态下沉到叶子节点,用 remember 隔离重组范围。

善用 graphicsLayer 隔离动画:位移、缩放、透明度类动画统一走 graphicsLayer,让 RenderThread 独立处理。一旦动画逻辑混入业务状态触发重组,这层隔离就失效了。

saveLayer 要节制:每次 saveLayer 都会创建一个离屏 Framebuffer,大面积 saveLayer 叠加是 RenderThread 耗时飙升的常见元凶。用 CompositingStrategy.ModulateAlpha 替代默认的 Offscreen,在部分场景可以规避这个开销。

Vulkan 兼容性要提前测:自定义绘制里有 Canvas.drawPicturePorterDuff 混合模式或 BitmapShader 复杂用法的,务必在 Vulkan 后端设备上验证,行为可能与 GL 后端有细微差异。