把 Paint 理解成「已经把像素画上屏幕」,会把 GPU 账单记到错误的阶段。paint 记下的不是最终像素,是 Layer 树:哪些绘制指令进同一张 Picture,哪些必须独立成层、离屏、再混合。合成(Compositing)决定 pass 的数量。很多「GPU 忙」其实是 saveLayer / clip / opacity / blur 把一帧拆成了多次离屏绘制。
读完你应该能独立回答三个问题:为什么静态图标堆叠不一定慢、全屏模糊一定慢;Layer 和 RenderObject 为什么不是一一对应;多挂一层换来的是动画局部性还是显存与带宽。
1. 先画边界:Layer 是 UI 交给 Raster 的计划
Layer 树在 UI 线程上由 PaintingContext 搭好,经 SceneBuilder 变成 ui.Scene,移交所有权给 Raster Runner。Raster 遍历 Layer,调 Impeller/Skia 出图。此后 UI 可以开始第 N+1 帧,不得再改第 N 帧那棵已交出的树。
不变量:
- 不是每个 RenderObject 都有 Layer。 只有需要独立合成属性的节点才
push;其余绘制进当前PictureLayer的 display list。 - Picture 一经结束就不可变。 再画只能换一张新 Picture,或只更新祖先 Layer 的属性(offset、opacity、transform)。
- Raster 不解引用 Dart Widget。 跨线程的是 C++ 侧
engineLayer和像素资源。
这条边界一旦画清,「多一个 Widget」和「多一次离屏」就不再被当成同一件事。
2. PaintingContext:当前层是一支画笔
PaintingContext 持有当前 ContainerLayer 和一只 Canvas(底层是 recorder)。普通 drawRect / drawImage 都落进当前 Picture。需要独立合成时:
void paint(PaintingContext context, Offset offset) {
context.pushOpacity(offset, alpha, (PaintingContext ctx, Offset o) {
ctx.paintChild(child, o);
});
}
pushOpacity / pushClipRect / pushTransform / pushClipPath 会:
- 结束当前 Picture(如果有)。
- 挂一个新的
ContainerLayer子节点。 - 在新 context 里继续 paint。
- 弹出,父层可以再开一张新 Picture 画后续内容。
所以一次「中间插入 clip」会把本可以合在一起的绘制拆成三截:clip 前的 Picture、clip 层、clip 后的 Picture。拆得越多,Raster 侧切换越多,retained rendering 的粒度也越碎。
3. Layer 类型决定 GPU 在做什么
常见层不是装饰语法,是不同的 GPU 工作:
| Layer | 典型来源 | GPU 含义 |
|---|---|---|
PictureLayer | 普通 paint | 回放 DisplayList |
OffsetLayer | RepaintBoundary、对齐 | 平移;常作为缓存根 |
TransformLayer | Transform | 矩阵;3D 时可能要独立合成 |
ClipRectLayer / ClipRRectLayer | ClipRect 等 | scissor 或离屏 |
ClipPathLayer | ClipPath | 几乎必然更贵,常走 stencil/离屏 |
OpacityLayer | Opacity | 整层 alpha,通常离屏再混合 |
ImageFilterLayer | 模糊、滤镜 | 离屏 + 采样 |
BackdropFilterLayer | 毛玻璃 | 读回已画内容,打断流水线 |
TextureLayer | 相机、视频 | 外部纹理,不进 Picture |
PlatformViewLayer | WebView、地图 | 在 Flutter 的独占合成上开洞 |
BackdropFilter 是架构上最贵的常用层:它要求「下面已经画完」,Raster 不能随便把后续绘制提前,也不能只更新这一层而不管背景。毛玻璃动画等于每帧读回主缓冲。
4. 从 Layer 树到 Scene:retained 的真正含义
整棵树最后 addToScene:
void addToScene(SceneBuilder builder) {
engineLayer = builder.pushOffset(offset, oldLayer: engineLayer as OffsetEngineLayer?);
// 子层递归
builder.pop();
}
oldLayer: engineLayer 是 retained rendering 的钩子:若这一层的属性没变、子 Picture 没变,Engine 可以复用上一帧已经栅格化或已经建好的 GPU 资源,而不是整树重录。markNeedsAddToScene 沿树向上走,作用类似 markNeedsPaint,但对象是 Layer,不是 RenderObject。
因此:动画如果只改 OpacityLayer 的 alpha,理论上只需更新一个属性,子 Picture 保持不动。动画如果改的是 Picture 内部的 path(例如每帧 drawPath 新形状),retained 帮不上,必须重录。
Picture 的 recorder 有一个容易忽略的细节:一旦 endRecording,指令序列冻结。同一 Canvas 引用在下一帧已经是另一张 recorder。你把 Paint 对象缓存下来是好的;把「上一帧的 Canvas」缓存下来是野指针式的错误。PaintingContext.canvas 只在当前 paint 调用内有效。
clip 的选择会改变层怎么切。Clip.hardEdge 的矩形常常能变成 scissor,不离屏;Clip.antiAliasWithSaveLayer 明确要求离屏,圆角阴影、子项画出边界时才会「干净」,账单是一整张与 clip bounds 一样大的中间纹理。能用 ClipRect 就不要 ClipPath;能用 borderRadius + 不 clip 的装饰,就不要给整列表加 ClipRRect。
addToScene 还有一条静默路径:若 Layer 被标记为「本帧不需要添加到 Scene」(例如完全在屏幕外且引擎支持 culling),Raster 可以跳过。依赖「看不见就不画」来省 GPU 时,要确认你的层有正确的 bounds,否则 culling 失效,全屏模糊在折叠的 bottom sheet 里仍然每帧跑。
Transform 在 2D 平移/缩放且对齐像素时,常常只是 OffsetLayer / TransformLayer 改矩阵,子 Picture 不动。一旦带透视或 3D,合成器要按深度排序,层不能再随便和兄弟合进同一 Picture。Transform.rotate 每帧转一个大卡片,若卡片内部是静态的,应让内部成为 boundary,只让 TransformLayer 的矩阵变。内部每帧重录,Transform 层就只是多付一次矩阵乘。
PhysicalModel / Material 的 elevation 阴影在实现上经常是多一层 blur 或预烘焙阴影图。elevation 动画等于阴影层每帧变参数,可能触发离屏。能用静态 BoxShadow 且不动画的,不要用 elevation 动画整张卡片。这是合成账单,不是 Material 主题问题。
Scene 提交之后,UI 侧的 Layer 树还在,供下一帧做 oldLayer 复用。debugDumpLayerTree() 看到的就是这棵还活着的 Dart 树。它和 GPU 上此刻的 command buffer 不是同一快照:你 dump 的时候 Raster 可能已经在画它的上一版。排障时不要拿这一刻的 dump 去对上一帧的 GPU 捕获,时间错开一拍就会误判「层没更新」。
PerformanceOverlayLayer、AnnotatedRegionLayer 这类辅助层几乎不占填充率,但会打断 Picture 的合并:overlay 插在中间,前后必须拆成两张 Picture。Debug 开关打开时帧变慢,有时不是检查逻辑本身,是层被拆碎、retained 粒度变差。测稳态性能时关掉 overlay,再用 Timeline 的数字对比。
5. 合成与 RepaintBoundary 不是同一个开关
RepaintBoundary 会创建一个 OffsetLayer,并把子树画进可缓存的 buffer。它隔离的是 重绘(谁要重记 Picture),附带一张可选位图缓存。
合成(needsCompositing)隔离的是 混合方式(谁必须离屏、谁必须按序)。一个节点可以需要合成但不需要位图缓存(临时 Opacity),也可以是 repaint boundary 但子树每帧都在重画(缓存命中率为零)。
flushCompositingBits 在 Layout 之后、Paint 之前重算 needsCompositing。子树从「不透明矩形」变成「半透明」,bits 翻了,Paint 才会真正 pushOpacity。否则框架可以继续把绘制合进父 Picture,少一次离屏。
6. saveLayer:显式离屏,账单最清楚
Canvas.saveLayer 是程序员直接下的离屏命令:先把后续绘制打到一张临时纹理,再按 Paint(blend、filter、alpha)压回。它不一定对应一个长期存在的 Layer 对象,但 GPU 成本同类。
典型来源:
Paint.maskFilter/imageFilter画进当前 Picture- 复杂 blend
- 部分 clip + opacity 组合,框架自己降级成 saveLayer
临时纹理尺寸跟 bounds 走。bounds 写 null 或写得太大,就是全屏离屏。动画中每帧 saveLayer,等于每帧分配/复用一张与脏区一样大的中间缓冲,带宽按像素线性涨。
7. 线程与内存:层是哪块堆上的对象
- Dart 堆:
Layer实例、Picture句柄。树在 UI Isolate。 - 原生堆:DisplayList、Engine 的
Layer镜像、engineLayer。 - GPU 显存:每张缓存纹理、MSAA、离屏 FBO。
RepaintBoundary的缓存、saveLayer的中间纹理都在这儿。
跨线程移交必须冻结 Picture。UI 在下一帧重录 Picture 时,Raster 仍可能在读上一张——所以 Picture 不可变,否则要加锁,帧预算扛不住。
泄漏排查要问在哪块堆:DevTools 能看见 Dart 侧 Layer 是否还被 LayerHandle 握着;显存要看纹理数量和尺寸。只盯 Widget 数量,看不见一张 1080p 离屏。
LayerHandle 存在的原因:RenderObject detach 后 Layer 可能仍被 Engine 用到这一帧结束。没有句柄,Dart 过早 GC,Raster 解引用野指针。自定义 RenderObject 自己持 Layer,必须用 LayerHandle,不要裸字段。
8. Platform View:合成器上最贵的例外
当必须嵌 WebView、地图、相机预览,Engine 不再独占所有像素。Platform View 通过虚拟 Display、overlay 或 Hybrid Composition 嵌进来。Layer 树上会出现「洞」或一张外部纹理。
这破坏了「一条 Raster 线程画完所有像素」的假设:多一次系统合成、多一次同步点、Android 上还有厂商 Surface 的差异。表现常是「只有某机型掉帧」「滚动时 WebView 周围闪」。优化手段是少嵌、改用纹理(自己画)、或把平台视图从动画热路径拿开,而不是减 Dart build。
9. 设计取舍(为什么是现在这样)
- 默认合进同一 Picture:静态页面填充率低。代价是任意脏点可能重录一大段 display list。
- 需要时才 push Layer:动画可以只更新一层属性。代价是每层显存和一次合成。
- Picture 不可变 + 跨线程移交:UI/Raster 流水线无锁。代价是改一个点就要换一张 Picture,或把该点提前拆成独立层。
- 允许 saveLayer / Backdrop 这种逃逸口:设计师要的模糊做得到。代价是逃逸口没有自动 boundary,一用就是带宽。
这些取舍决定了什么优化有意义。把一个静态图标拆成二十个 Widget,只要最后还在同一 Picture 里,GPU 几乎无感;给整页加一个 Opacity,再少的 Widget 也是全屏离屏。
10. 怎么把原理用到排障上
GPU 条高时,按层提问,不要先换状态库:
- 是重录 Picture 还是离屏 pass 多? Performance overlay 的棋盘格(raster cache / layer)在闪,说明缓存层每帧失效。
- 有没有全屏 Opacity / Backdrop / 模糊? 先拿掉或缩到最小矩形。
- clip 是 Rect 还是 Path? Path clip 往往多一次离屏或 stencil。
- Platform View 是否在滚动/动画热路径上? 这是例外路径,Dart 火焰图看不见。
- 纹理尺寸是否等于屏幕而不是控件?
saveLayerbounds、图片解码尺寸、boundary 缓存,三笔显存。
能回答这五问,Layer 树就是一张「哪些像素可以一起画」的计划,而不是 DevTools 里一堆看不懂的层名。
11. 小结
Layer 树是合成计划:Picture 记账、独立层负责变换/裁剪/透明/滤镜,Scene 移交给 Raster。合成次数和离屏尺寸,不是 Widget 数量,才是 GPU 侧的账单。下一篇专门把 RepaintBoundary 从这棵树上拆出来——它是重绘洪水的堤坝,也是一张可选的、容易用错的位图缓存。