01 · The Rendering Pipeline & RenderObjects¶
Everything in Flutter eventually becomes a render object: a node that knows its size, where its children go, how to
paint, and how to respond to hit tests. Widgets are configuration; elements are identity
(Level 3 · 01); render objects do the work. Knowing this layer explains most performance
advice — "use const", "add a RepaintBoundary", "avoid intrinsic sizing" — and lets you build components no stock widget
can. This lesson writes one from scratch and counts what it does.
One frame, five phases¶
When the engine signals vsync, WidgetsBinding.drawFrame runs, in order:
| Phase | Who's involved | What happens |
|---|---|---|
| Animate | tickers | animation controllers advance (Level 3 · 02) |
| Build | dirty elements | build() runs for elements marked by setState & co; widgets diffed against elements |
| Layout | render objects needing layout | constraints go down, sizes come up, parents position children |
| Paint | render objects needing paint | drawing commands recorded into layers |
| Composite + semantics | layer tree, semantics owner | layer tree sent to the engine for rasterization; accessibility tree updated |
Each phase only visits dirty nodes. The whole game of Flutter performance is keeping those dirty sets small.
A custom RenderBox¶
A progress bar whose height depends on thickness (layout) and whose fill depends on value (paint only):
import 'package:flutter/rendering.dart';
import 'package:flutter/widgets.dart';
/// Widget: immutable configuration. Creates and updates the render object.
class ProgressBar extends LeafRenderObjectWidget {
const ProgressBar({super.key, required this.value, this.thickness = 8, this.color = const Color(0xFF3F51B5)});
final double value; // 0..1
final double thickness;
final Color color;
@override
RenderProgressBar createRenderObject(BuildContext context) =>
RenderProgressBar(value: value, thickness: thickness, color: color, textDirection: Directionality.of(context));
@override
void updateRenderObject(BuildContext context, RenderProgressBar renderObject) {
renderObject
..value = value
..thickness = thickness
..color = color
..textDirection = Directionality.of(context);
}
}
/// RenderObject: does layout and paint. Each setter requests only the work it needs.
class RenderProgressBar extends RenderBox {
RenderProgressBar({required double value, required double thickness, required Color color, required TextDirection textDirection})
: _value = value,
_thickness = thickness,
_color = color,
_textDirection = textDirection;
static int layouts = 0;
static int paints = 0;
double _value;
set value(double v) {
if (v == _value) return;
_value = v;
markNeedsPaint(); // the size doesn't depend on value
markNeedsSemanticsUpdate();
}
double _thickness;
set thickness(double t) {
if (t == _thickness) return;
_thickness = t;
markNeedsLayout(); // height depends on thickness
}
Color _color;
set color(Color c) {
if (c == _color) return;
_color = c;
markNeedsPaint();
}
// Semantics labels need a reading direction (and an RTL app should fill from the right — exercise 1).
TextDirection _textDirection;
set textDirection(TextDirection d) {
if (d == _textDirection) return;
_textDirection = d;
markNeedsSemanticsUpdate();
}
@override
void performLayout() {
layouts++;
// Take all the width offered (or a default if unbounded); height is our thickness.
final width = constraints.hasBoundedWidth ? constraints.maxWidth : 200.0;
size = constraints.constrain(Size(width, _thickness));
}
@override
double computeMinIntrinsicHeight(double width) => _thickness;
@override
double computeMaxIntrinsicHeight(double width) => _thickness;
@override
void paint(PaintingContext context, Offset offset) {
paints++;
final canvas = context.canvas;
final track = offset & size;
final radius = Radius.circular(size.height / 2);
canvas.drawRRect(RRect.fromRectAndRadius(track, radius), Paint()..color = _color.withValues(alpha: 0.2));
final filled = Rect.fromLTWH(offset.dx, offset.dy, size.width * _value.clamp(0, 1), size.height);
canvas.drawRRect(RRect.fromRectAndRadius(filled, radius), Paint()..color = _color);
}
@override
bool hitTestSelf(Offset position) => true;
@override
void describeSemanticsConfiguration(SemanticsConfiguration config) {
super.describeSemanticsConfiguration(config);
config
..textDirection = _textDirection
..label = 'Progress'
..value = '${(_value * 100).round()}%';
}
}
The pattern every render-object widget follows:
- A
RenderObjectWidget(LeafRenderObjectWidgetfor no children,SingleChildRenderObjectWidget,MultiChildRenderObjectWidget) withcreateRenderObjectandupdateRenderObject. - A
RenderBoxwhose setters compare and mark the minimum work:valueandcoloronly affect pixels →markNeedsPaint();thicknessaffects size →markNeedsLayout()(which implies paint). performLayoutreadsconstraintsand setssize— never anything else's size, and always within the constraints (constraints.constrain).paintdraws atoffset(the object's position in the current layer — not 0,0).- Intrinsics, hit testing and semantics, so it behaves like a good citizen.
Measuring the phases¶
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:l2/m/render_demo.dart';
void main() {
Widget app(double value, double thickness, {String label = 'x'}) => MaterialApp(
home: Scaffold(
body: Column(children: [
Text(label),
Padding(padding: const EdgeInsets.all(16), child: ProgressBar(value: value, thickness: thickness)),
]),
),
);
testWidgets('which setter triggers which phase', (tester) async {
RenderProgressBar.layouts = 0;
RenderProgressBar.paints = 0;
String counts() => 'layouts=${RenderProgressBar.layouts} paints=${RenderProgressBar.paints}';
await tester.pumpWidget(app(0.25, 8));
print('first frame: ${counts()} size=${tester.getSize(find.byType(ProgressBar))}');
await tester.pumpWidget(app(0.25, 8));
print('identical rebuild: ${counts()}');
await tester.pumpWidget(app(0.75, 8));
print('value 0.25 -> 0.75: ${counts()}');
await tester.pumpWidget(app(0.75, 16));
print('thickness 8 -> 16: ${counts()} size=${tester.getSize(find.byType(ProgressBar))}');
await tester.pumpWidget(app(0.75, 16, label: 'a much longer label'));
print('sibling text changed: ${counts()}');
});
testWidgets('render tree and semantics', (tester) async {
final handle = tester.ensureSemantics();
await tester.pumpWidget(app(0.4, 8));
final ro = tester.renderObject<RenderProgressBar>(find.byType(ProgressBar));
print('constraints: ${ro.constraints}');
print('parent chain: ${[for (RenderObject? p = ro.parent; p != null && p is! RenderView; p = p.parent) p.runtimeType].take(4).toList()}');
final node = tester.getSemantics(find.byType(ProgressBar));
print('semantics: label="${node.label}" value="${node.value}"');
handle.dispose();
});
}
$ flutter test test/m/render_test.dart
first frame: layouts=1 paints=1 size=Size(768.0, 8.0)
identical rebuild: layouts=1 paints=1
value 0.25 -> 0.75: layouts=1 paints=2
thickness 8 -> 16: layouts=2 paints=3 size=Size(768.0, 16.0)
sibling text changed: layouts=2 paints=4
constraints: BoxConstraints(0.0<=w<=768.0, 0.0<=h<=Infinity)
parent chain: [RenderPadding, RenderFlex, RenderCustomMultiChildLayoutBox, _RenderInkFeatures]
semantics: label="Progress" value="40%"
00:00 +2: All tests passed!
- Identical rebuild: zero work. The widget was rebuilt,
updateRenderObjectcalled every setter, but every setter saw an equal value and returned early. That early return is why rebuilding widgets is cheap if render objects are written well. - Value change: paint only. No layout.
- Thickness change: layout + paint, and the new size is 16 high.
- Sibling text changed: paint, no layout. The
Columnre-laid out its children, but asked our bar to lay out with the same constraints it had before, and a render object that isn't dirty and receives identical constraints skipsperformLayout. It still repainted because it shares a layer with the text that changed (the same effect as in Level 3 · 03). - Constraints show what the
Column+Paddingoffered: width up to 768 (800 minus 2×16 padding), height unbounded — which is whyperformLayouttakes its height fromthicknessrather than fromconstraints.maxHeight.
A bug we hit: semantics need a direction¶
The first version set config.label = 'Progress' without a text direction, and the semantics test failed with:
A SemanticsData object with label "Progress" had a null textDirection.
Failed assertion: line 1089 pos 10: 'attributedLabel.string == '' || textDirection != null'
Screen readers need to know the reading direction of any label. Stock widgets get it from the ambient Directionality; a
custom render object has to be given it. The fix — read Directionality.of(context) in createRenderObject/
updateRenderObject and pass it into the semantics configuration — is in the code above. It's a good example of
responsibilities a stock widget handles silently that you take on when you drop down a layer.
Layout rules worth memorizing¶
- Constraints down, sizes up, parent sets position. A child can't choose its position; a parent can't choose its child's size except through constraints.
- Relayout boundaries: when a render object's size can't change its parent's layout — because its constraints are tight,
or it's
sizedByParent, or the parent doesn't use its size — amarkNeedsLayoutstops propagating at it. Otherwise, layout dirtiness walks up the tree. - Intrinsic sizing is expensive:
IntrinsicHeight/IntrinsicWidthask the subtree "how big would you like to be?" before laying it out, which can mean laying out (or measuring) twice — O(n²) when nested. Avoid it in lists. - Paint order is child order; hit testing goes in reverse (topmost first).
When to write a RenderObject¶
Rarely. Try composition first, then CustomPaint (paint-only custom visuals), then CustomMultiChildLayout/Flow
(custom positioning of widget children), then CustomSingleChildLayout. Write a render object when you need custom
layout and custom paint and hit testing together, or when profiling shows a composed widget doing too much work — for
example, a chat bubble that sizes itself to its text's last line, or a text-heavy timeline with thousands of items.
How It Actually Works¶
markNeedsLayout adds the render object (or its relayout boundary) to PipelineOwner._nodesNeedingLayout;
markNeedsPaint adds it (or its nearest repaint boundary) to _nodesNeedingPaint, and both request a frame.
In drawFrame, after build, flushLayout sorts dirty nodes by depth and calls layout(constraints) on each, which calls
performLayout only if the node is dirty or the constraints changed. flushCompositingBits updates which subtrees need
their own compositing layers, flushPaint repaints dirty repaint boundaries into fresh PictureLayers, and
flushSemantics rebuilds changed semantics nodes. Finally renderView.compositeFrame() builds a Scene from the layer
tree and hands it to the engine, whose raster thread rasterizes it with Impeller or Skia on the GPU while the UI thread
starts on the next frame. Two threads is why the performance overlay shows two graphs — UI and raster —
which the next lesson uses.
Common mistakes¶
- Setters that always mark dirty (no equality check) — every rebuild becomes a relayout.
markNeedsLayoutfor paint-only properties.- Setting
sizeoutside the constraints — caught by an assertion in debug mode. - Painting at
Offset.zeroinstead ofoffset— your object draws at the top-left of the layer. - Forgetting semantics and hit testing, producing a component that looks right but is invisible to screen readers or unclickable.
Exercise¶
- Make the bar fill from the right when
textDirectionis RTL. Add a golden test for both directions. - Add an
indeterminatemode driven by anAnimation<double>passed to the render object, which callsmarkNeedsPainton each tick. Count layouts during a second of animation — it should be zero. - Implement
computeDryLayoutso the bar works inside widgets that ask for dry layout (for example, inside someWraporIntrinsicHeightcases). - Wrap the bar in
RepaintBoundaryin the test and confirm "sibling text changed" no longer paints it.