cubegao

Flutter 绘制原理与 Layer 树解析

2022-01-19

一、从聊天列表的 Profile 卡顿说起

企业 IM 的聊天页面有一个常见的 Profile 交互:点击头像弹出名片卡片,卡片带有毛玻璃背景和圆角阴影。QA 反馈说这个弹出动画掉帧明显,Timeline 显示 Paint 阶段耗时高达 12ms——单帧预算 16.67ms 被这个动画吃掉了大半。

排查后发现,毛玻璃效果(BackdropFilter)在每一帧都触发整棵子树的重新录制(Picture Recording),即使在动画仅改变 Opacity 的情况下,底层 Skia Picture 也被重新生成。

这个问题的本质是:Paint 阶段产出的 Layer Tree 结构不合理,导致缓存失效。

本文将深入 Flutter 的绘制引擎,解析 Layer Tree 的各类节点、缓存机制和合成策略,并结合企业 IM 的真实场景说明如何设计高效的可绘制结构。

二、Paint 与 Composite 的分离:为什么要两层

Flutter 的「绘制」其实分两个阶段:Paint 阶段(生成 Layer Tree)和 Composite 阶段(合成 Layer Tree 为一帧)。

这个分离设计的核心收益是:Layer Tree 可以增量更新。 当只有某个小区域变化时,只需重新录制该区域对应的 Layer,其他 Layer 直接复用。Composite 阶段把新旧 Layer 按 z-order 合并提交即可。

如果没有这个分层设计,每次绘制都需要全屏重新生成 Picture,这在海量消息的聊天列表中是不可接受的。

三、Layer 类型体系与各自职责

Flutter 的 Layer Tree 由多种 Layer 节点组成,每种对应一类绘制指令。

3.1 PictureLayer:最基础的绘制单元

PictureLayer 封装了一个 Picture 对象——Skia(或 Impeller)的录制备忘录。Picture 记录了一系列 Ops(操作指令),如画矩形、画文字、画路径、合成变换等:

1
2
3
4
5
6
7
8
9
class PictureLayer extends Layer {
Picture? picture;
// Offset 指定这个 Picture 在画布上的偏移位置
Offset offset;

void addToScene(SceneBuilder builder) {
builder.addPicture(offset, picture);
}
}

每个 RepaintBoundary 内部的 RenderObject 会把自己的绘制指令录制到一个共享的 Picture 中,最终包装为一个 PictureLayer。不具备 isRepaintBoundary 的普通 RenderObject,其绘制指令被录制到父节点最近的 RepaintBoundary 的 Picture 中。

3.2 TransformLayer:变换与 3D 效果

TransformLayer 封装了一个 Matrix4 变换矩阵,Composite 阶段通过 GPU 的顶点变换完成旋转、缩放、平移、透视等操作:

1
2
3
4
5
6
7
8
9
class TransformLayer extends OffsetLayer {
Matrix4 transform;

void addToScene(SceneBuilder builder) {
engineLayer = builder.pushTransform(transform);
addChildrenToScene(builder);
builder.pop();
}
}

在 IM 的消息转发选择面板中,选中态消息气泡有轻微缩放动画(1.0 ~ 1.05 倍)。用 Transform.scale 包裹气泡,对应的是一个 TransformLayer。关键是这个 Layer 内部的 PictureLayer 可以被缓存——气泡内容不变,只有外层 TransformLayer 的矩阵在变,Composite 阶段只需要 GPU 重算顶点位置,不需要重新录制 Picture。

3.3 OpacityLayer:透明度合成

OpacityLayer 控制子 Layer 的整体透明度:

1
2
3
4
5
6
7
8
9
class OpacityLayer extends ContainerLayer {
int alpha;

void addToScene(SceneBuilder builder) {
engineLayer = builder.pushOpacity(alpha, ...);
addChildrenToScene(builder);
builder.pop();
}
}

在卡片弹出动画中,背景遮罩从透明(alpha: 0)渐变到半透明(alpha: 128),用 OpacityLayer 包裹即可——Composite 阶段通过 GPU 的 Alpha Blending 完成,不触发子 Layer 的重新录制。

但如果 OpacityLayer 内部不是独立 Layer 子树(即没有 RepaintBoundary),Flutter 会降级为「每帧重新录制子 Picture + 应用 alpha」,这就是前面 Profile 卡片问题的根源。

3.4 ClipRectLayer 与 ClipRRectLayer:裁剪

ClipRectLayerClipRRectLayer(圆角裁剪)在 GPU 合成阶段通过 Stencil Buffer 或 Scissor Test 裁剪超出边界的像素:

1
2
3
4
5
6
7
8
9
class ClipRectLayer extends ContainerLayer {
Rect clipRect;

void addToScene(SceneBuilder builder) {
engineLayer = builder.pushClipRect(clipRect);
addChildrenToScene(builder);
builder.pop();
}
}

聊天消息列表的圆角头像就用 ClipRRect 实现。这个操作的性能取决于 GPU 是否触发离屏渲染——在 Skia 上,ClipRRect 不会触发离屏渲染(使用 Scissor + 圆角 Mask 的硬件加速路径),性能开销极小。

3.5 其他 Layer 类型

Layer 类型 用途 典型场景
ColorFilterLayer 颜色滤镜(如灰度、反色) 消息列表下拉加载指示器
ImageFilterLayer 图像滤镜(高斯模糊、形态变换) 头像点击弹窗的毛玻璃背景
BackdropFilterLayer 对背景图层应用滤镜 毛玻璃模糊效果
ShaderMaskLayer 着色器遮罩 渐变文字效果
PhysicalModelLayer 物理阴影 卡片阴影
TextureLayer 外部纹理(视频/相机预览) 视频会议画面

四、RepaintBoundary:Layer 缓存的开关

4.1 RepaintBoundary 的工作原理

RepaintBoundary 是控制 Layer 缓存粒度的核心机制。当 RenderRepaintBoundaryisRepaintBoundary 属性为 true 时:

  1. 它的绘制结果被录制到一个独立的 PictureLayer 中。
  2. 后续帧中,只要 RepaintBoundary 内部没有标记为脏,Flutter 直接复用缓存的 Picture,完全跳过录制过程。
  3. 缓存的管理由 Layer._engineLayer 持有——Engine 层保存了渲染后的 GPU 纹理,Composite 时直接引用。

关键代码路径:

1
2
3
4
5
6
7
8
9
void paint(PaintingContext context, Offset offset) {
if (isRepaintBoundary) {
// 独立的 PictureLayer,支持缓存
context.pushLayer(OffsetLayer(), _paintWithContext, offset);
} else {
// 合并到父节点的 Picture 中
_paintWithContext(context, offset);
}
}

4.2 缓存的代价:显存与复杂度

RepaintBoundary 不是免费的。每个 RepaintBoundary 对应的 PictureLayer 在 Engine 层都会分配一张 GPU 纹理。如果聊天列表中 200 条消息每条都加 RepaintBoundary,200 张纹理同时存在,GPU 显存压力会迅速增大。

在企业 IM 的实践中,我们的策略是:

  • 纯文本消息:不加 RepaintBoundary。文本重绘成本低(2-3ms 可以重绘数百条),缓存不是必需的。
  • 富媒体消息(图片、视频缩略图、文件下载进度条):加 RepaintBoundary。这些消息的图片解码完成后会触发单条重绘,缓存能避免牵连周围消息。
  • 静态装饰元素(标题栏、分割线、背景):加 RepaintBoundary。这些元素几乎不变化,缓存收益极高。

这个策略在 GPU 显存和重绘性能之间取得了平衡。DevTools 的 debugRepaintRainbowEnabled 开关打开后,可以看到活跃的 Layer 边界——只有富媒体消息区域和标题栏是独立的彩色块,文本消息共享同一个大 Layer。

五、业务场景实战

5.1 聊天消息列表滚动中的增量绘制

聊天页面滚动时,新消息从底部滑入、旧消息从顶部滑出。ListViewSliverList 配合 Viewport 实现了按需构建,但绘制阶段的优化需要额外关注:

  • 滑入视口的新消息 Widget 需要首次绘制(冷启动绘制)。
  • 已可见的消息在滚动过程中位置变化,但内容不变——如果消息有 RepaintBoundary,它的 Picture 可以直接复用,仅更新 PictureLayer.offset

这就是为什么在滚动性能优化的策略中,给每条消息加 RepaintBoundary 不是最优解,但给「可能独立重绘」的消息加,是正确的选择。

5.2 会议白板的半屏绘制

OA 会议模块中,白板是屏幕下半部分的绘制区域。上半部分是参会者画面(TextureLayer),下半部分是白板(PictureLayer)。

当白板涂鸦发生时,只有白板对应的 PictureLayer 需要重录,上半部分的参会者画面 TextureLayer 完全不动。这就是 Layer Tree 分离设计的收益——把不同变化频率的区域映射到不同的 Layer,重绘范围被精确限制。

实现上,会议页面的 Layer Tree 结构大致如下:

1
2
3
4
5
6
7
TransformLayer (根)
├── ContainerLayer
│ └── TextureLayer (摄像头画面,更新频率 30fps)
├── RepaintBoundary → PictureLayer (白板区域,仅在涂鸦时更新)
├── RepaintBoundary → PictureLayer (工具栏,几乎不动)
└── OpacityLayer (水印层,alpha = 0.15)
└── PictureLayer (水印文字)

每层独立更新,互不干扰。Composite 阶段把四层叠加为一帧,合成耗时不到 1ms。

六、总结

Flutter 的 Layer Tree 设计,本质上是一种绘制指令的持久化 + 增量更新策略。核心机制有三层:

  1. PictureLayer 缓存:通过 RepaintBoundary 将稳定的绘制结果缓存为 GPU 纹理,避免重复录制。
  2. Layer 类型分离:变换、透明度、裁剪等后处理操作下沉到 Composite 阶段的 GPU 合成,不触发 Picture 重新录制。
  3. 按需失效:脏标记机制保证了只有真正变化的 Layer 才会更新——未变化的 Layer 直接复用缓存,Composite 阶段仅更新偏移量。

在企业 IM 的复杂场景中,Layer Tree 的设计决定了绘制性能的上限。正确的做法是理解每种 Layer 的缓存策略和失效条件,用最小的 RepaintBoundary 数量覆盖最高频的重绘热点。

扫描二维码,分享此文章