把 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 boundaryRepaint boundary
挡住markNeedsLayoutmarkNeedsPaint
典型条件紧约束、parentUsesSize==false、sizedByParentisRepaintBoundary==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. 怎么把原理用到排障上

一个小转圈导致掉帧,按堤坝提问:

  1. paint 队列里入队的是根还是近处的 boundary? 若是根,转圈没有堤坝。
  2. 棋盘格在闪吗? 闪则有堤坝但缓存无意义,考虑拿掉或缩小动画区域。
  3. layout 是否也在每帧跑? 是则你加错了河;去立 relayout boundary 或停止改 size。
  4. 纹理有多大? 全屏 DPR×宽×高×4 是一笔显存。列表 × N 项再乘一次。
  5. 有没有每帧 toImage? 那是强制读回,先删掉再谈 Boundary。

能回答这五问,你才能决定加、撤、还是把动画收进更小的 RenderObject。

11. 小结

RepaintBoundary 是重绘隔离,附带一张有生命周期的位图。隔离的前提是「里面稳、外面吵」或「里面吵、外面稳」。两边都吵,就别隔。下一篇把视角拉到线程:Vsync 怎样同时叫醒 UI 和 Raster,以及你在错误的 Runner 上等待会得到哪种卡顿。