Why understand the rendering pipeline?
- Performance work demands a mental model: which thread is doing what, where time is spent, and how rendering decisions affect memory and jank.
- Advanced UI and data visualizations often need custom painting and explicit control of layers.
Overview of the pipeline
- Widgets (immutable configuration)
- Build methods create a widget tree every frame as needed.
- Elements (stateful instantiation)
- Bridge between widgets and render objects; manage lifecycle, update children.
- RenderObjects (layout, paint, hit testing)
- Stateful tree on the UI thread; computes layout and issues painting commands.
- Layers (compositing)
- Painted content becomes a layer tree (e.g., PictureLayer, TransformLayer); raster thread composites via Skia.
Threads
- UI thread (Dart): builds widgets, runs layout & paint (producing a layer tree).
- Raster thread (C++/Skia): consumes the layer tree, renders to GPU surfaces.
- Platform threads: I/O, GPU driver, async tasks.
Compositing and layers
- Each composited layer becomes a separate surface that the raster thread blends.
- More layers ≠ always bad; it’s about balancing overdraw and recomposition cost.
- Common layers: OpacityLayer, TransformLayer, ClipRectLayer, PictureLayer, ImageFilterLayer.
When to add a layer?
- Reuse: cache expensive picture content (
RepaintBoundary) when subtrees don’t change frequently. - Transform/clip/filter: isolate effects without repainting parents.
- Avoid unnecessary
RepaintBoundarys; they add memory and compositing cost.
Repaint, layout, semantics
- Layout invalidation: size changes bubble up/down; avoid thrashing by constraining subtree layout.
- Paint invalidation: markNeedsPaint triggers repaint of that RenderObject subtree.
- Semantics tree: accessibility; keep it in sync without adding excessive depth.
CustomPainter fundamentals
- CustomPainter gives precise control over painting via a Canvas:
- Efficient for charts, maps, gauges, custom decorations.
- Use
shouldRepaintwisely to avoid repainting when inputs unchanged.
Example: static vs dynamic painters
class StaticGridPainter extends CustomPainter {
final Color color;
const StaticGridPainter(this.color);
@override
void paint(Canvas canvas, Size size) {
final p = Paint()
..color = color
..style = PaintingStyle.stroke
..strokeWidth = 1;
for (double x = 0; x < size.width; x += 8) {
canvas.drawLine(Offset(x, 0), Offset(x, size.height), p);
}
for (double y = 0; y < size.height; y += 8) {
canvas.drawLine(Offset(0, y), Offset(size.width, y), p);
}
}
@override
bool shouldRepaint(covariant StaticGridPainter old) => old.color != color;
}
- Static inputs → minimal repaints (true only when color changes).
Example: animated painter (avoid extra allocations)
class SparklinePainter extends CustomPainter {
final List<double> values; // normalized 0..1
final Color color;
final double stroke;
SparklinePainter(this.values, this.color, this.stroke);
@override
void paint(Canvas canvas, Size size) {
final p = Paint()
..color = color
..style = PaintingStyle.stroke
..strokeWidth = stroke
..isAntiAlias = true;
final path = Path();
if (values.isNotEmpty) {
final dx = size.width / (values.length - 1).clamp(1, 1e9).toDouble();
path.moveTo(0, size.height * (1 - values[0]));
for (int i = 1; i < values.length; i++) {
path.lineTo(dx * i, size.height * (1 - values[i]));
}
}
canvas.drawPath(path, p);
}
@override
bool shouldRepaint(covariant SparklinePainter old) =>
!identical(values, old.values) || color != old.color || stroke != old.stroke;
}
- Prefer reusing
valuesbuffers; avoid per-frame allocations (List.generate) inpaint.
RepaintBoundary and caching
- Wrap expensive painters in a
RepaintBoundaryto isolate repaints:- Only that subtree will be repainted if nothing else changes.
- Use
RepaintBoundarysparingly; it increases memory and compositing overhead.
Pictures and PictureRecorder
- Capture static vector content once:
- Use
ui.PictureRecorderto pre-render content and then draw thePicturein subsequent frames. - Great for static backgrounds or heavy vector assets.
- Use
Hit testing for custom render
- CustomPainter supports hit testing by overriding
hitTestviaCustomPainter.semanticsBuilderandisComplexhints. - For advanced interactivity, consider direct
RenderBoxsubclassing to handlehitTestSelfandhandleEvent.
Clipping, shadows, and filters
- Clipping creates extra layers; prefer rounded clip only where necessary.
- Shadows are expensive; batch shadows and consider pre-rendered assets for complex elevation stacks.
- Image filters (blur) can force offscreen rendering—profile and use sparingly.
Transformations
- Hardware-accelerated transforms are cheap if they avoid re-rasterization; but transformed children may need bigger clip regions.
- Animate transforms rather than repainting complex paths when appropriate.
Measuring performance
- DevTools Performance: profile UI/Raster times; record frames during interactions.
- Check “Widget rebuild stats” (Flutter inspector) to find noisy rebuilds.
- Turn on Performance Overlay to see frame budget and rasterization spikes.
- Profile in profile/release modes, not debug.
Common pitfalls
- Unbounded constraints in layout → layout passes explode; guard with SizedBox/constraints.
- Animate painting when transform would suffice → unnecessary repaint work.
- Overuse of
CustomPaintfor things the framework already handles efficiently. - Misuse of
setStatecausing broad rebuilds; lift state or use fine-grained controllers.
Layering patterns for complex UIs
- Background layer: static assets/pictures (cached).
- Midground layer: frequently updated but bounded areas (charts, cursors).
- Foreground layer: UI chrome and hit targets (hover/click).
- Isolate expensive painters into their own
RepaintBoundary.
Checklist for high-performance custom rendering
-
shouldRepaintis accurate (no repaint on unchanged inputs) - No per-frame allocations in
paint(reuse Paint, Path, buffers) - Heavy static content cached via
Picture - RepaintBoundary used where isolation pays off
- Minimal clipping and shadows; profile if used
- Transform where possible instead of repainting
- Profile on target devices in profile/release modes
Conclusion
Understanding how widgets, render objects, and layers fit together unlocks the full power of Flutter’s rendering engine. With judicious use of CustomPainter, caching, and compositing, you can deliver visually rich UIs that stay within frame budgets even on lower-end devices.