Three trees and the rendering pipeline

Flutter · Expert · 18 min

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. BuildContext is the element.
  • RenderObjects — do layout, painting and hit testing. Only RenderObjectWidgets (like Padding, 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.

Check yourself

  1. In the three-tree model, which object is a BuildContext?

Sign in to answer, see the explanations, and get this lesson back as a quick review later.

Official docs: docs.flutter.dev/resources/architectural-overview