Dropping Below the Widget Layer: Writing a RenderObject From Scratch
Dropping Below the Widget Layer: Writing a RenderObject From Scratch
深入组件层之下:从零编写 RenderObject
A designer on my team once handed me a “simple” gallery: variable-height cards packed like a Pinterest board, tappable, with a staggered fade-in as they landed. I burned two days forcing it out of Wrap, then GridView, then a CustomScrollView with a delegate I bent into a pretzel. Every version was wrong on the last row, janky on scroll, or quietly O(n²). Then I stopped fighting the widget tree and wrote about 120 lines of RenderBox. It laid out correctly on the first try, painted in one pass, and I have not touched it since. That is the pattern I want to talk about. 我团队里的一位设计师曾交给我一个“简单”的画廊需求:像 Pinterest 那样堆叠高度不一的卡片,支持点击,且在加载时有交错的淡入效果。我花了整整两天时间,试图用 Wrap、GridView,甚至是把 delegate 扭曲成麻花状的 CustomScrollView 来实现它。但每一个版本要么在最后一行出错,要么滚动时卡顿,要么就是隐性地陷入了 O(n²) 的复杂度。后来,我放弃了与组件树(widget tree)的纠缠,转而写了大约 120 行的 RenderBox 代码。它在第一次尝试时就正确完成了布局,一次绘制完成,之后我再也没动过它。这就是我想讨论的模式。
Widgets and CustomPaint cover the easy 90% of Flutter’s rendering story, and most of us live there for entire careers without a scratch. But the real leverage — the stuff the built-ins genuinely cannot express — lives one layer down, in the render tree. This post is about when to write a custom RenderObject in Flutter, and exactly how to do it, without breaking the framework’s layout and painting contracts on the way down. Widgets 和 CustomPaint 覆盖了 Flutter 渲染场景中 90% 的简单需求,我们大多数人甚至可以在整个职业生涯中都只待在这一层,而无需触碰底层。但真正的“杠杆”——那些内置组件确实无法表达的东西——存在于下一层,即渲染树(render tree)中。本文将探讨何时在 Flutter 中编写自定义 RenderObject,以及如何在不破坏框架布局和绘制契约的前提下准确地实现它。
The three trees: widget, element, and render
三棵树:Widget、Element 和 Render
Flutter runs three trees in parallel, and understanding the split is the whole game. If you have ever wondered what actually happens between calling build() and seeing pixels, this is it. Flutter 同时运行三棵树,理解这种拆分是掌握 Flutter 的关键。如果你曾好奇在调用 build() 到看到像素点之间到底发生了什么,答案就在这里。
The widget tree is your configuration. Widgets are immutable, cheap, thrown away and rebuilt constantly. Padding, Row, your StatelessWidget — all just descriptions, closer to a blueprint than a building. Widget 树是你的配置层。Widget 是不可变的、廉价的,它们被不断地丢弃和重建。Padding、Row、你的 StatelessWidget——这些都只是描述,更像是蓝图而非建筑本身。
The element tree is the bookkeeping layer. Element instances are long-lived, hold state, decide what to reuse across rebuilds, and connect widgets to their render objects. You rarely touch these directly, but BuildContext is an element under the hood. Element 树是管理层。Element 实例生命周期较长,持有状态,决定在重建时复用什么,并将 Widget 连接到它们的渲染对象。你很少直接接触它们,但 BuildContext 在底层其实就是一个 Element。
The render tree is where the actual work happens: measuring, positioning, painting, and hit-testing. This is RenderObject and its subclasses, chiefly RenderBox (the 2D box protocol) and RenderSliver (the scrollable viewport protocol). 渲染树是实际工作发生的地方:测量、定位、绘制和点击测试。这里是 RenderObject 及其子类的领地,主要是 RenderBox(2D 盒子协议)和 RenderSliver(可滚动视口协议)。
Most developers never leave the widget tree because they don’t have to. Row is a widget wrapping a RenderFlex. Padding wraps a RenderPadding. Opacity wraps a RenderOpacity. Every layout primitive you use is a thin widget over a render object someone at Google already wrote. You compose those primitives and the framework does the rest — that is composition doing its job. 大多数开发者从不离开 Widget 树,因为他们没必要离开。Row 是包装了 RenderFlex 的 Widget。Padding 包装了 RenderPadding。Opacity 包装了 RenderOpacity。你使用的每一个布局原语,都是 Google 工程师已经写好的渲染对象之上的一层薄薄的 Widget。你组合这些原语,框架完成剩下的工作——这就是组合模式的价值所在。
The reason to go lower is that composition has a ceiling. When your layout depends on measuring children against each other, when you need real hit-testing on non-rectangular shapes, when intrinsic sizes matter, or when you are redoing the same expensive layout math every frame because the widget layer left you no cheaper path — that is the render tree calling. Not before. 之所以要深入底层,是因为组合模式有其上限。当你的布局依赖于子组件之间的相互测量,当你需要对非矩形形状进行真实的点击测试,当固有尺寸(intrinsic sizes)很重要,或者当你因为 Widget 层没有提供更廉价的路径而不得不在每一帧重复昂贵的布局计算时——那就是渲染树在召唤你。在此之前,不要轻易尝试。
I have watched engineers reach for a custom render object the way some people reach for a rewrite: as a way to feel productive while avoiding the boring composition that would have shipped yesterday. Resist that. A custom RenderObject is a scalpel, not a hammer. 我见过工程师们使用自定义渲染对象,就像有些人动不动就想重写代码一样:这只是一种让自己感觉“有产出”的方式,实际上是在逃避那些本可以在昨天就交付的枯燥的组合工作。抵制这种诱惑。自定义 RenderObject 是手术刀,而不是锤子。
When CustomPaint stops being enough
何时 CustomPaint 不再够用
CustomPaint is the escape hatch everyone reaches for first, and for good reason. Custom drawing, a CustomPainter, done. I use it constantly for charts, progress rings, signature pads, and decorative flourishes. If your problem is “draw pixels inside a box whose size is already decided,” CustomPaint is the correct tool and you should not write a RenderObject. Don’t over-engineer a solved problem. CustomPaint 是每个人首先想到的“逃生舱”,这很有道理。自定义绘制,一个 CustomPainter,搞定。我经常用它来做图表、进度环、签名板和装饰性元素。如果你的问题是“在一个尺寸已经确定的盒子里绘制像素”,CustomPaint 就是正确的工具,你不应该去写 RenderObject。不要过度设计一个已经解决的问题。
It stops being enough the moment any of these show up: 当出现以下任何情况时,它就不够用了:
- Layout that depends on content. A CustomPainter is handed a Size and paints inside it. It cannot say “I want to be exactly as tall as the tallest thing I contain.” If your widget’s size is a function of what’s inside it, you need performLayout, which painters don’t have. 依赖内容的布局。 CustomPainter 被赋予一个 Size 并在其中绘制。它无法说“我想和包含的最长物体一样高”。如果你的 Widget 尺寸取决于其内部内容,你需要 performLayout,而 Painter 没有这个能力。
- Real children. CustomPaint can take a single child, but that is the ceiling. If you need to lay out and paint an arbitrary list of child widgets — position them, size them, let them handle their own gestures — a painter can’t hold them. Painters draw; they don’t parent. 真实的子组件。 CustomPaint 可以接受一个子组件,但这已是极限。如果你需要布局和绘制任意数量的子组件——定位它们、调整它们的大小、让它们处理自己的手势——Painter 是做不到的。Painter 负责绘制,不负责管理子组件。
- Hit-testing beyond the bounding box. A GestureDetector around a CustomPaint gives you a rectangle. If you drew a hexagonal button or a pie chart and taps in the dead corners shouldn’t count, the painter has no say in hit-testing. 超出边界框的点击测试。 包裹在 CustomPaint 外的 GestureDetector 只能给你一个矩形区域。如果你画了一个六边形按钮或饼图,且死角处的点击不应被计入,Painter 在点击测试中没有发言权。
- Intrinsic sizes. Ask a CustomPaint “how wide do you want to be given unlimited height?” and it shrugs. RenderBox exposes computeMinIntrinsicWidth and its siblings, which IntrinsicHeight, Table, and text baselines actually query. 固有尺寸。 问 CustomPaint “在高度不受限的情况下你想要多宽?”,它会耸耸肩。RenderBox 暴露了 computeMinIntrinsicWidth 及其相关方法,IntrinsicHeight、Table 和文本基线实际上都会查询这些方法。
When two or more of those are true at once, stop stacking widgets. Write the render object. 当上述情况有两个或更多同时发生时,停止堆叠 Widget,去写渲染对象吧。
Anatomy of a RenderBox: the constraints contract
RenderBox 解剖:约束契约
The heart of Flutter layout is one sentence, and it’s worth tattooing somewhere: constraints go down, sizes go up, and the parent sets position. Flutter 布局的核心只有一句话,值得纹在身上:约束向下传递,尺寸向上传递,父组件决定位置。
A parent hands each child a BoxConstraints — min/max width and min/max height. The child must pick a size that satisfies those constraints and report it back. The parent then decides where to place the child. A child never picks its own position, and it never sees its siblings. That decoupling is exactly what makes Flutter layout single-pass and fast: each box is visited once, top-down for constraints and bottom-up for sizes. 父组件给每个子组件传递一个 BoxConstraints(最小/最大宽度和高度)。子组件必须选择一个满足这些约束的尺寸并上报。然后父组件决定将子组件放在哪里。子组件永远不会自己决定位置,也永远看不到它的兄弟组件。这种解耦正是 Flutter 布局单次遍历且高效的原因:每个盒子只被访问一次,自顶向下传递约束,自底向上返回尺寸。
A minimal RenderBox implements a handful of methods. The load-bearing one is performLayout: 一个最小化的 RenderBox 只需要实现少数几个方法。其中最核心的是 performLayout:
class RenderSquare extends RenderBox {
@override
void performLayout() {
// Read the constraints the parent gave us, pick a size, report it.
final double side = constraints.constrainWidth(200);
size = Size(side, side);
}
@override
void paint(PaintingContext context, Offset offset) {
final paint = Paint()..color = const Color(0xFF2962FF);
context.canvas.drawRect(offset & size, paint);
}
}
Two rules trip up everyone the first time: 初学者第一次尝试时,有两条规则最容易出错:
- You must set size inside performLayout, and it must satisfy constraints. Return a size outside the given min/max and the framework asserts in debug and misbehaves in release. constraints.constrain(desiredSize) clamps for you, so lean on it. 你必须在 performLayout 中设置 size,且它必须满足约束。如果返回的尺寸超出了给定的最小/最大范围,框架会在调试模式下报错,并在发布模式下表现异常。使用 constraints.constrain(desiredSize) 可以帮你进行限制,所以请善用它。
- You must not read size from anywhere except during and after layout. Reading size in your… 除了在布局期间和布局之后,你绝不能从任何地方读取 size。在你的……中读取 size 是错误的。