Expert debugging and performance work requires knowing exactly what happens between setState and pixels.
Flutter maintains three trees:
- Widgets — immutable configuration, rebuilt freely.
- Elements — the persistent instances that hold the tree structure, State, and inherited-widget dependencies.
BuildContextis the element. - RenderObjects — do layout, painting and hit testing. Only
RenderObjectWidgets (likePadding,Opacity,RichText) create them; most widgets you write are compositions that produce none.
A frame, driven by the engine's vsync signal through SchedulerBinding:
1. Animate — tickers run and controllers update values. 2. Build — dirty elements (marked by setState, inherited changes) rebuild, top-down; elements are updated or replaced as described by keys and types. 3. Layout — render objects marked needsLayout lay out: constraints down, sizes up. Relayout boundaries stop propagation when a parent's size can't depend on the child. 4. Paint — render objects record drawing commands into layers. RepaintBoundary gives a subtree its own layer so it can repaint independently. 5. Composite — the layer tree becomes a scene sent to the engine. 6. Rasterize — on the raster thread, Impeller (the default renderer on iOS and Android) turns the scene into GPU commands. Impeller precompiles shaders at build time, removing the first-run shader-compilation jank of the old Skia backend.
The UI thread (Dart) and raster thread run in parallel, each with a budget of about 16 ms at 60 Hz (8 ms at 120 Hz). Semantics (accessibility) is a parallel tree built from render objects.
Example
import 'package:flutter/rendering.dart';
import 'package:flutter/widgets.dart';
// A widget that creates its own RenderObject: the bottom layer of the three trees.
class ColoredSquare extends LeafRenderObjectWidget {
const ColoredSquare({super.key, required this.color});
final Color color;
@override
RenderObject createRenderObject(BuildContext context) => _RenderSquare(color);
@override
void updateRenderObject(BuildContext context, _RenderSquare renderObject) {
renderObject.color = color; // element reuses the render object
}
}
class _RenderSquare extends RenderBox {
_RenderSquare(this._color);
Color _color;
set color(Color c) { if (c != _color) { _color = c; markNeedsPaint(); } } // paint only
@override
void performLayout() => size = constraints.constrain(const Size.square(48)); // layout
@override
void paint(PaintingContext context, Offset offset) =>
context.canvas.drawRect(offset & size, Paint()..color = _color);
}Remember
- Widgets configure, elements persist, render objects lay out and paint
- Frame = animate, build, layout, paint, composite, rasterize
- Impeller precompiles shaders, avoiding first-run jank
Watch out for
- Assuming a rebuild always means a repaint — unchanged render objects skip layout and paint.