当每帧有成千上万个点、粒子、K 线时,先被吃光的往往不是 GPU,是 树的基数:每个粒子一个 Widget,就有一个 Element、一个 RenderObject,Diff、visit、hitTest、layout 全是 O(N)。自定义 RenderBox 的原理是把 layout/paint 收回到一个节点,数据仍是 N 条,树是 1 个节点。这不是「更底层所以更快」的神秘学,是在遵守前面几篇已经立住的不变量:一次 layout、paint 只记账、脏标记分河。
读完你应该能独立回答三个问题:什么时候该写 RenderObject 而不是 CustomPaint、数据变了该 markNeedsPaint 还是 markNeedsLayout、一万次 drawCircle 和一次 drawRawAtlas 为什么能差出一个数量级。
1. 先画边界:降基数,而不是重写引擎
自定义 RenderObject 仍然活在 UI Runner 上,仍然走 performLayout / paint / hitTest,仍然把指令记进 Layer。它 不 让你碰 Metal,也 不 让你绕过 Vsync。
该写它的信号:
- N 很大,且 N 个对象没有各自的 State / 无障碍节点需求。
- 布局是领域算法(时间轴、K 线视窗、力导向),用
Stack+Positioned表达会先 Diff 死。 - 需要在 layout 里读约束、在 paint 里用同一套几何,还要自己做命中测试。
不该写它的信号:组合现有布局就够;只是想画一个静态图标(CustomPainter 足够);其实瓶颈在 saveLayer 和大图。
CustomPaint 是「一个 RenderCustomPaint + 你的 painter」。它已经是单节点。若你不需要自定义 hitTest/layout 协议,先用它。升到自己的 RenderBox,是因为你要管 parentData、孩子、semantics、layer,或要把数据更新从 Widget rebuild 里彻底拿开。
2. 最小合法对象:约束、尺寸、记账
class RenderParticles extends RenderBox {
List<Particle> particles = const [];
set particles(List<Particle> value) {
if (identical(_particles, value)) return;
_particles = value;
markNeedsPaint();
}
List<Particle> _particles = const [];
@override
void performLayout() {
size = constraints.biggest;
}
@override
void paint(PaintingContext context, Offset offset) {
final canvas = context.canvas;
canvas.save();
canvas.translate(offset.dx, offset.dy);
_paintAll(canvas);
canvas.restore();
}
@override
bool hitTestSelf(Offset position) => size.contains(position);
}
sizedByParent 语义在这里用 constraints.biggest 表达:尺寸只由约束决定,粒子再多也不撑开父。粒子变了只 markNeedsPaint,不要 markNeedsLayout,除非算法改了 size。错标 layout,父链不是 boundary 时整页重排,单节点的优势被你自己抹掉。
Widget 侧用 LeafRenderObjectWidget 把字段打进 RenderObject,在 updateRenderObject 里赋值。赋值函数里比较旧值,决定 mark 哪条河。这就是自定义节点的 Diff:O(字段),不是 O(粒子)。
3. 生命周期:attach、owner、LayerHandle
RenderObject 只有 attach 之后才有 PipelineOwner。在构造函数里 markNeedsPaint 没有 owner,标记会被记下,挂上树再刷。在 detach 之后异步回包里 mark,是生命周期错误。
自己持有 Layer(例如要把子树推进一个 OffsetLayer 做局部缓存)必须用 LayerHandle<T>,在 detach / dispose 里丢掉。裸 Layer? 字段会和 Raster 抢对象寿命:Dart 侧 GC 了,Raster 还在 addToScene。
dispose 路径上停 Ticker、释放 ui.Image、Picture。粒子系统若自己录了一张静态背景 Picture,那是原生资源,不是 List 里的 double。
4. 合批:GPU 对调用次数不友好
Impeller/Skia 对「一次提交很多顶点」友好,对「一万次 drawCircle」不友好。每条 draw* 都是 DisplayList 上的一条命令,翻译成 Entity 也是一份 Contents。N 大时,CPU 侧录制先爆,GPU 还没开始填像素。
热路径优先:
drawRawPoints/drawRawAtlas/drawVertices:数据放Float32List,每帧原地改,不new List<Offset>。- 静态背景录进
Picture,动画层只画变化的那一层。需要时把背景放进自己的 repaint boundary。 Paint、Path做成字段复用。Path每帧reset()再填,比 new Path 少晋升。
final _rstTransforms = Float32List(n * 4);
final _rects = Float32List(n * 4);
final _colors = Int32List(n);
canvas.drawRawAtlas(atlas, _rstTransforms, _rects, _colors,
BlendMode.modulate, null, _paint);
合批改变的是 Paint 阶段的录制成本和 Raster 的绑定次数。它不改变 Layout 协议,也不让你在 paint 里改 size。
5. 手势与命中:空间索引,不要为粒子生成节点
hitTestSelf 为 true,这个盒子吃下命中测试。细到某个粒子,自己做空间索引(网格、四叉树、按 x 排序后二分),在 hitTest / handleEvent 里查。
为每个可点粒子 inflate 一个 RenderObject,等于放弃自定义节点的全部意义。命中测试默认深度优先、子到父。你没有 child,就要在 hitTestSelf 里给出「这个点有没有东西」。
和滚动父的冲突走手势竞技场,不是在 paint 里判断。画布需要「按下即赢、挡住父滚动」时,用 EagerGestureRecognizer 或自己的 RenderPointerListener 语义,而不是 Listener 和 Scrollable 各听各的。
无障碍:N 个粒子不要 N 个 semantics 节点。聚合成分组、或只暴露当前选中项。语义树也在 UI 线程刷,基数同样能吃掉帧。
6. 数据从哪进树:Ticker 优于每帧 setState
粒子位置若在 State.build 里每帧算一遍再作为参数传下去,Element 仍要 updateRenderObject。便宜,但有一份 Widget 配置和一次 dirty build。
更干净的模型:RenderParticles 自己持 Ticker(通过 TickerProvider 从 Widget 注入),在 tick 里改内部数组并 markNeedsPaint。Widget 树这一帧可以完全不脏。这把 Animate 直接接到 RenderObject,跳过 Build,符合「动画改值不改树」。
void _onTick(Duration elapsed) {
_integrate(elapsed);
markNeedsPaint();
}
需要把选中项反映到 Widget 层(给外面一个 callback)时,才向上通知。不要为了 DevTools 好看每帧 setState。
7. 和孩子共存:parentData 是你的协议
不是所有自定义节点都是叶子。时间轴可能有「可拖标记」作为 child。这时用 MultiChildRenderObjectWidget,自己定义 ParentData:每个孩子除了 offset,还可以有 time、lane。
performLayout 里给孩子传紧约束(标记大小已知)或内容约束,然后按领域坐标写 parentData.offset。孩子自己可以是 boundary。你仍然是一次遍历:先定自己的 size(通常 constraints.biggest),再 layout 孩子,禁止根据孩子 size 改自己再 layout 孩子第二遍,除非你非常清楚自己在做受控的二次测量。
paint 里 context.paintChild(child, child.parentData.offset + offset)。漏了 paintChild,孩子的 Layer 不会进树,看起来像「没画」,其实是合成计划没挂上。
8. 调试与不变量:先让 debug 替你骂
自定义布局必须满足约束:size 出区间,debug 会在 debugAssertDoesMeetConstraints 炸。实现 computeDryLayout 与 performLayout 一致,否则父的 dry 查询会撒谎。
@override
Size computeDryLayout(BoxConstraints constraints) => constraints.biggest;
@override
bool get sizedByParent => true;
@override
void performResize() {
size = constraints.biggest;
}
debugDumpRenderTree、debugDumpLayerTree、Performance overlay 的棋盘格,是这套对象的标准探头。粒子系统掉帧时先看是 UI 条高(录制了太多 draw 调用、每帧分配)还是 GPU 条高(真的像素太多、有离屏)。前者合批和复用缓冲;后者减模糊、减 overlap、减分辨率。
溢出不是视觉问题,是约束问题。debugPaintSizeEnabled 下出现黄黑条,说明你在 performLayout 里让孩子拿到了比你自己 size 更大的空间,或 paint 时用 offset 把内容画到盒子外。clip 掉只是把命中测试和像素再次分家。正确做法是给孩子 constraints.constrain 过的盒子,或明确 clipBehavior 并接受命中测试也被 clip。
需要局部缓存时,让这个 RenderBox isRepaintBoundary => true,而不是在外面再包 Widget。指针动画只 markNeedsPaint;坐标轴作为静态 Picture 字段,数据源变了才重录坐标轴。不要把整张 K 线每帧 saveLayer 做阴影,阴影预烘焙进 atlas。
语义上覆盖 describeSemanticsConfiguration,把当前可见区间、选中蜡烛的数值暴露成少量节点。N 根 K 线 N 个 semantics,无障碍服务遍历会在 UI 线程再吃掉一帧。这和降 Widget 基数是同一原则在另一棵树上。
updateRenderObject 是数据进入节点的闸门。比较要按值或按世代号,不要只比引用——调用方每帧 List.from 一份新列表,引用永远不同,你会每帧 mark,合批的意义还在,脏范围的意义没了。世代号(数据源每变一次 +1)让「没变」真正变成 no-op:
@override
void updateRenderObject(BuildContext context, RenderKline renderObject) {
renderObject.generation = generation;
renderObject.viewport = viewport;
}
generation 变才重读点;viewport 变只改窗口,可能只 mark paint。把「数据变了」和「相机变了」分成两个字段,是自定义节点相对 CustomPainter.shouldRepaint 能做细的地方。
孩子会变化时,不要在 paint 里根据数据 insert/remove RenderObject。那是 Element 的活。自定义多孩子节点的孩子集合应由 Widget 树声明,RenderObjectElement 去 diff。你在 paint 里改孩子链表,等于在错误的阶段改树,和在 paint 里改 size 是同一类协议破坏。
9. 设计取舍(为什么是现在这样)
- 默认一切皆 Widget 树:可组合、可无障碍、可热重载。代价是基数大时 CPU 死在树上。
- 允许叶子 RenderObject 直接 Canvas:逃逸口明确。代价是你要自己遵守布局协议、生命周期和合批。
- CustomPaint 作为更浅的逃逸口:多数绘制不必碰 parentData。代价是 hitTest/layout 定制能力有限,数据更新仍常走 Widget。
- 合批 API 暴露为 Canvas 原语而不是粒子框架:Engine 保持封闭图元。代价是领域层要自己管
Float32List布局。
这些取舍决定了什么优化有意义。把粒子做成 3000 个 Positioned,再换状态库,树还在;合成一个 RenderObject,N 变成数据,Diff 变成字段比较。反过来,三个按钮不要写 RenderObject,那是在用大炮打组合问题。
10. 怎么把原理用到排障上
「K 线滚动掉帧」按基数和阶段提问:
- Timeline 上是 build/layout 还是 paint/raster? 树太大,前两者高;draw 太多,paint 高;离屏和填充,raster 高。
- 有没有每帧 markNeedsLayout? 视窗平移通常只改 paint 或一层 transform。
- 有没有每帧 new 大 List / Path? 和 GC 篇同一件事,会在自定义 paint 里更狠。
- 命中测试是否 O(N) 扫全部粒子? 触摸时掉帧,查索引,别加 Boundary。
- 是否其实 CustomPaint 就够,却多写了一层错误协议? 二次 layout、paint 改 size、漏 paintChild,都是自伤。
能回答这五问,自定义 RenderObject 就是「一棵子树收成一个节点」的工程,而不是另起一套渲染器。
11. 小结
自定义 RenderObject 把 N 个领域对象变成 1 个布局绘制节点 + N 条指令。遵守约束向下、脏标记分河、Picture 只记账、合批提交,帧预算才回到像素本身。它接上本专栏从 Engine、管线、Impeller、Layout、Layer、Boundary、线程到 GC、Element 的整条链:树要薄,阶段要纯,跨线程的东西要冻结。再往后,才是手势竞技场怎样在这一个节点上和滚动父抢同一根手指。