把 RepaintBoundary 理解成「加速魔法」,会在列表和动画里把显存打爆。markNeedsPaint 会沿父链向上走,直到遇到 isRepaintBoundary == true。Boundary 的本质是 重绘洪水的堤坝,附带一张可选的位图缓存。用对了,吵闹的子树出不去、安静的子树不重画;用错了,每帧多一张离屏纹理,带宽按像素线性涨。
读完你应该能独立回答三个问题:一个 loading 转圈为什么能逼整页重画、为什么「给每一项都加 Boundary」会在滚动时更卡、relayout boundary 和 repaint boundary 挡住的为什么不是同一条河。
1. 先画边界:它隔离 paint,不隔离 layout,更不隔离 build
RepaintBoundary 对应的 RenderRepaintBoundary 把 isRepaintBoundary 设为 true,并持有一个 OffsetLayer。它做两件事:
- 挡脏标记:子树
markNeedsPaint走到这里入队,不再向上。 - 可选缓存:子树画进这块 Layer 的 buffer;祖先重绘时若子树没脏,直接合成这张纹理。
它 不 挡 markNeedsBuild,也 不 挡 markNeedsLayout。子树每帧 setState,Build 照跑;子树 size 在变,Layout 照跑,缓存因尺寸变化整张作废。把 Boundary 当「性能饰品」往根上套,三条河里只可能影响一条。
不变量:缓存有价值,当且仅当「纹理能活过下一帧」且「重绘它比重绘外面更便宜」。 两边都吵,或 size 每帧变,命中率为零,还多一次离屏。
2. 脏标记怎样停下来
RenderObject.markNeedsPaint 的控制流是:
void markNeedsPaint() {
if (_needsPaint) return;
_needsPaint = true;
if (isRepaintBoundary) {
owner?.requestVisualUpdate();
owner?._nodesNeedingPaint.add(this);
return;
}
parent?.markNeedsPaint();
}
没有边界,标记走到 RenderView(根永远是 boundary)。flushPaint 从这些根开始 paint,子树里没脏且自身是 boundary 的节点,走「合成旧 Layer」而不是重录 Picture。
RenderView、RenderRepaintBoundary、以及部分需要独立合成的对象会是 boundary。Opacity 需要合成,但不自动等于「有位图缓存的 repaint boundary」。需要合成只说明 Paint 时要 pushLayer;缓存是另一笔显存投资。
3. OffsetLayer 与位图缓存的生命周期
子树第一次 paint,产出 Picture,Raster 可能把它栅格化进一张纹理(实现随 Engine 版本和是否满足缓存条件而变)。之后:
- 子树没脏、祖先在重绘:祖先的
addToScene挂上这个OffsetLayer,像素复用。 - 子树脏:只从该 boundary 重 paint,更新 Layer,祖先仍不必重录自己的 Picture。
- size 变、DPR 变、设备旋转:纹理尺寸不对,必须重建。动画里 height 从 0 长到 400,每一帧一张新纹理,缓存是负优化。
toImage / toImageSync 走同一块 Layer:把子树栅格化成 ui.Image。这是显式读回,发生在 Raster,可能阻塞或排队。用它做截图可以;每帧 toImage 当特效,等于每帧强制离屏 + 读回,比普通 Boundary 更贵。
4. 和 relayout boundary 的正交关系
| Relayout boundary | Repaint boundary | |
|---|---|---|
| 挡住 | markNeedsLayout | markNeedsPaint |
| 典型条件 | 紧约束、parentUsesSize==false、sizedByParent | isRepaintBoundary==true |
| 缓存 | 几何,几乎无显存 | 像素,占显存 |
| 失败模式 | 整页 relayout | 整页重录 Picture,或显存爆炸 |
两者独立。一个节点可以挡住重绘但挡不住布局(RepaintBoundary 包着一段会变高的文本);也可以挡住布局但挡不住重绘(紧约束的全屏背景,里面粒子仍向上标 paint,直到遇到 paint 边界)。
排障时先问「这一帧 flush 的是哪张队列」。用错边界,是在给另一条河修堤。
5. 什么时候该加,什么时候是负优化
适合加的结构,核心是 一侧稳、一侧吵:
- 外面吵、里面稳:滚动的列表上方有一张复杂插画、地图上的静态控件、不随滚动变的图表。外面每帧 paint,里面那张纹理直接合成。
- 里面吵、外面稳:一个局部 loading、一个小仪表盘指针。转圈不出边界,整页 Picture 不用重录。
不要加:
- 本身每帧都在变、且几乎铺满父的区域(全屏粒子、视频、进度条拉满宽)。缓存每帧失效,还多一次 blit。
- 普通文字列表的每一项。项很轻,滚动时项进出本来就要 paint;每项一张纹理,滚动变成纹理搬运。
- 尺寸动画中的节点。缓存键包含 size。
列表项只在「项很重且滚动时项内容不变」(大图 + 复杂装饰、内容静态)时才值得考虑。这是显存换 CPU 的交易,要用棋盘格看是否在闪。
6. 棋盘格、overlay 和「闪」的含义
Performance overlay 的 raster cache checkerboard:缓存层被重绘时闪格。稳定的格子表示「这块是缓存且没在每帧重建」;狂闪表示 Boundary 在工作但命中率为零——它每个 Vsync 都在付离屏成本。
另一格是层边界可视化。层多不一定坏(opacity 动画需要层);层多且每帧都脏,才是账单。
不要凭感觉加完 Boundary 就宣称优化。没有「闪/不闪」和 rasterDuration 前后对比,你不知道自己买的是隔离还是显存。
7. 线程、内存、生命周期
脏标记和 paint 在 UI 线程。纹理分配、栅格化、合成在 Raster。toImage 的像素读回可能在 Raster 完成后把 ui.Image 句柄投递回 UI。UI 不得在 paint 里同步等 GPU;那会把流水线拧成串行,双线程变成单线程。
内存三块:
- Dart:
RenderRepaintBoundary、OffsetLayer、LayerHandle。 - 原生:Engine 层对象。
- 显存:缓存纹理。这是 Boundary 真正可能「泄漏」的地方——不是 Dart GC 不管,是你一直让一个全屏 Boundary 活着,纹理就一直钉在 GPU 上。路由压栈后仍在树里的巨型离屏,和「泄漏」对 RSS 的贡献一样。
生命周期:RenderObject detach 时必须丢掉对 Layer 的拥有(经 LayerHandle)。Boundary 出树,纹理应在帧结束后可释放。自定义 isRepaintBoundary == true 的对象,若自己 new Layer 却不走 Handle,是在和 Raster 抢生命周期。
8. 自己把 RenderObject 标成 boundary
不一定要用 Widget。任何 RenderObject 都可以 isRepaintBoundary => true。自定义图表若内部动画只改指针,把图表节点标成 boundary,比在外面再包一层 Widget 更少一次 Element。
class RenderGauge extends RenderBox {
@override
bool get isRepaintBoundary => true;
@override
void paint(PaintingContext context, Offset offset) {
// 只重画仪表。祖先滚动时走 OffsetLayer 合成。
}
}
你同时要保证:size 尽量稳;祖先不要每帧改你的 offset 以外的合成属性(例如给 boundary 再套一个每帧变的 Opacity,外层仍会离屏)。Boundary 不是盔甲,它只在自己这层挡 paint。
嵌套 Boundary 的账单要算像素,不要算个数。两层各 100×100,显存大约是两张小纹理;一层包住全屏、里面再包 20 个列表项各一张全宽纹理,滚动时是 20 次更新。估算用 宽 × 高 × devicePixelRatio² × 4 字节。1080×240 的项在 3x 设备上约 1080×3 × 240×3 × 4 ≈ 9MB;20 项就是 180MB 量级的最坏值(引擎会有缓存上限和淘汰,但峰值带宽仍按脏项算)。
RenderOpacity、RenderAnimatedOpacity 在 opacity 介于 0 和 1 之间时 needsCompositing == true,会 push OpacityLayer。这不是 isRepaintBoundary。外层半透明动画会把内层 Boundary 的缓存纹理再离屏混合一次:内层稳,外层吵,缓存仍然有用,但外层那张全尺寸离屏每帧都在。正确的结构是把透明度动画收进尽可能小的那一层,而不是给整页 Opacity 再在里面到处贴 Boundary。
isRepaintBoundary 从 false 变成 true(或反过来)会触发 compositing bits 更新,Layer 类型可能从「画进父 Picture」变成「独立 OffsetLayer」。这发生在热切换时(例如按条件包一层 RepaintBoundary),中间会有一帧丢掉旧 Picture。不要在动画中途根据帧号加加减减 Boundary。
和滚动的关系要单独算。ListView 本身会把屏幕外 sliver 从树上摘掉(或保持 cache extent 内)。项上的 Boundary 在项滑出 cache 后随 RenderObject detach,纹理可释放。若你用 cacheExtent 开得很大又给每项 Boundary,等于提前栅格化很多张纹理换滚动顺滑,RSS 和显存一起涨。这是明确的交易:只在「项很重、滑回屏幕时重绘很贵、内存有余量」时做。普通文字项不要做。
RepaintBoundary.wrap 和在 build 里 RepaintBoundary(child: ...) 是同一 RenderObject。真正不同的是你把它放在哪一层:放在「变的东西」外面,隔离才成立;放在「变的东西」上,缓存每帧失效。一个页面标题栏不动、下面列表滚、角落一个转圈——三块三策:标题可 boundary,列表按需,转圈单独小 boundary,而不是给 Scaffold 套一个。
toImageSync 在 UI 线程请求同步读回,极端情况下会等 Raster 完成这一层。它把流水线拧成串行,只适合截图、分享卡片这类低频路径。用它做实时倒影、每帧毛玻璃,是在用最贵的方式模拟 BackdropFilter(而 BackdropFilter 已经够贵)。看到 toImage 出现在 paint 或 Ticker 里,先删。
引擎对 raster cache 有尺寸和条目上限,不是你贴多少 Boundary 就缓存多少。超大层可能被拒绝缓存,只留下「挡脏标记」这一半功能——这仍然有用:子树重绘不必冲到根。所以「棋盘格不出现」不一定是没 Boundary,可能是没资格进位图缓存。这时隔离还在,显存账单没有,不要为了看见格子再强迫扩大/缩小层去骗缓存。
另一个极端是 Boundary 包住一个 PlatformView。平台视图本来就在另一条合成路径上,再包一层 Flutter 位图缓存,等于有的后端要把平台视图先读进纹理。这通常更慢。平台内容的隔离交给 Embedder,不要用 RepaintBoundary 去「优化」WebView。
markNeedsPaint 在 boundary 处入队后,flushPaint 只从这些根 DFS。兄弟 boundary 没脏就不会被 visit 到。这是「里面吵、外面稳」能成立的微观原因:父 Picture 不重录,邻居也不重录。一旦父不是 Picture 而是一层每帧都变的 Opacity,父要重 addToScene,但邻居的像素仍可复用——所以 Opacity 动画里,静态邻居上的 Boundary 仍然值钱,动的那一层上的 Boundary 不值钱。把节点画成「谁在动、谁在旁边」,比看 Widget 树层级更准。
9. 设计取舍(为什么是现在这样)
- 默认不缓存、脏标记冒泡到根:实现简单,静态页少占显存。代价是一个小动画可以重录整页。
- 显式 Boundary + 可选位图:把「隔离」交给作者,因为框架不知道哪一侧稳。代价是误用直接变成显存泄漏式的卡顿。
- 与 compositing bits 分离:需要合成不等于需要缓存。代价是两个概念都要懂,只懂 Widget 名会用错。
toImage复用同一机制:截图不必另开一套栅格化。代价是这条 API 看起来像 Dart 同步能力,其实在向 Raster 要读回。
这些取舍决定了什么优化有意义。给整页加一个 Boundary 几乎无益——根已经是。给每帧全变的子树加,是给 Raster 加活。给「里面稳、外面吵」加,才是在用显存买 CPU。
10. 怎么把原理用到排障上
一个小转圈导致掉帧,按堤坝提问:
- paint 队列里入队的是根还是近处的 boundary? 若是根,转圈没有堤坝。
- 棋盘格在闪吗? 闪则有堤坝但缓存无意义,考虑拿掉或缩小动画区域。
- layout 是否也在每帧跑? 是则你加错了河;去立 relayout boundary 或停止改 size。
- 纹理有多大? 全屏 DPR×宽×高×4 是一笔显存。列表 × N 项再乘一次。
- 有没有每帧
toImage? 那是强制读回,先删掉再谈 Boundary。
能回答这五问,你才能决定加、撤、还是把动画收进更小的 RenderObject。
11. 小结
RepaintBoundary 是重绘隔离,附带一张有生命周期的位图。隔离的前提是「里面稳、外面吵」或「里面吵、外面稳」。两边都吵,就别隔。下一篇把视角拉到线程:Vsync 怎样同时叫醒 UI 和 Raster,以及你在错误的 Runner 上等待会得到哪种卡顿。