02 · Performance: Rebuilds, Jank & DevTools¶
Users don't notice milliseconds; they notice dropped frames — a scroll that stutters, an animation that hitches. A frame has about 16.7 ms at 60 Hz and 8.3 ms at 120 Hz, split between the UI thread (build, layout, paint recording) and the raster thread (turning layers into pixels). This lesson covers how to measure first, then the handful of fixes that solve most real problems, each with numbers from tests rather than folklore. One of the experiments contradicts a piece of common advice.
Rule 1: measure in profile mode, on a device¶
- Debug mode is JIT-compiled with assertions and extra checks; it can be many times slower and is useless for judging performance.
- Profile mode is AOT-compiled like release, but keeps enough instrumentation for DevTools.
- Emulators and simulators don't have your users' GPUs or thermals. Use a real, preferably mid-range, device.
Then open Flutter DevTools (the URL is printed by flutter run, or use your IDE):
- Performance view: a frame chart where each bar is a frame, split into UI and raster time; slow frames are highlighted.
Click one to see a timeline of what ran — which
buildmethods, layouts, paints. - "Track widget builds" shows which widgets rebuilt in a frame and how often.
- "Highlight repaints" / the performance overlay (
showPerformanceOverlay: trueonMaterialApp) show what repaints and the two thread graphs live on the device. - CPU profiler for Dart hot spots; Memory view for leaks and allocation spikes.
What this course could run
Profile mode needs a device plus a working mobile or desktop toolchain; this course's machine had no device attached and couldn't build for Android, iOS or macOS (see Level 3 · 09), so the DevTools workflow above is described, not demonstrated. The experiments below measure work done — build counts and widget-test timings — which is deterministic and reproducible anywhere, and is what usually explains the slow frames DevTools shows.
The experiments¶
import 'package:flutter/material.dart';
final builds = <String, int>{};
void count(String name) => builds[name] = (builds[name] ?? 0) + 1;
class Row_ extends StatelessWidget {
const Row_(this.i, {super.key});
final int i;
@override
Widget build(BuildContext context) {
count('row');
return SizedBox(height: 48, child: Text('Item $i'));
}
}
/// 1. Eager vs lazy lists.
Widget eagerList(int n) => ListView(children: [for (var i = 0; i < n; i++) Row_(i)]);
Widget lazyList(int n) => ListView.builder(itemCount: n, itemExtent: 48, itemBuilder: (_, i) => Row_(i));
/// 2. Where setState lives decides how much rebuilds.
class Expensive extends StatelessWidget {
const Expensive({super.key});
@override
Widget build(BuildContext context) {
count('expensive');
return const Text('a big, static subtree');
}
}
class CounterPageBad extends StatefulWidget {
const CounterPageBad({super.key});
@override
State<CounterPageBad> createState() => _CounterPageBadState();
}
class _CounterPageBadState extends State<CounterPageBad> {
int n = 0;
@override
Widget build(BuildContext context) {
count('page');
return Column(children: [
// ignore: prefer_const_constructors
Expensive(), // not const: a new instance every build, so it rebuilds every time
Text('$n'),
TextButton(onPressed: () => setState(() => n++), child: const Text('+')),
]);
}
}
class CounterPageGood extends StatelessWidget {
const CounterPageGood({super.key});
@override
Widget build(BuildContext context) {
count('page');
return const Column(children: [
Expensive(), // const: identical instance, skipped on rebuild
_Counter(), // the state lives as low as possible
]);
}
}
class _Counter extends StatefulWidget {
const _Counter();
@override
State<_Counter> createState() => _CounterState();
}
class _CounterState extends State<_Counter> {
int n = 0;
@override
Widget build(BuildContext context) {
count('counter');
return Column(children: [Text('$n'), TextButton(onPressed: () => setState(() => n++), child: const Text('+'))]);
}
}
/// 3. Fading: rebuild-per-frame vs render-object-level animation.
class FadeWithSetState extends StatefulWidget {
const FadeWithSetState({super.key});
@override
State<FadeWithSetState> createState() => _FadeWithSetStateState();
}
class _FadeWithSetStateState extends State<FadeWithSetState> with SingleTickerProviderStateMixin {
late final c = AnimationController(vsync: this, duration: const Duration(seconds: 1))
..addListener(() => setState(() {}))
..forward();
@override
void dispose() {
c.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
count('fade-setState');
return Opacity(opacity: c.value, child: const Expensive());
}
}
class FadeWithTransition extends StatefulWidget {
const FadeWithTransition({super.key});
@override
State<FadeWithTransition> createState() => _FadeWithTransitionState();
}
class _FadeWithTransitionState extends State<FadeWithTransition> with SingleTickerProviderStateMixin {
late final c = AnimationController(vsync: this, duration: const Duration(seconds: 1))..forward();
@override
void dispose() {
c.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
count('fade-transition');
return FadeTransition(opacity: c, child: const Expensive());
}
}
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:l2/m/perf_demo.dart';
void main() {
setUp(builds.clear);
testWidgets('1. eager vs lazy list of 100,000', (tester) async {
const n = 100000;
Future<int> time(Widget Function() make) async {
await tester.pumpWidget(const SizedBox());
final sw = Stopwatch()..start();
await tester.pumpWidget(MaterialApp(home: make()));
return sw.elapsedMilliseconds;
}
// Warm up both paths once so JIT compilation doesn't distort the comparison.
await time(() => eagerList(n));
await time(() => lazyList(n));
for (var round = 1; round <= 3; round++) {
builds.clear();
final eager = await time(() => eagerList(n));
final eagerBuilt = builds['row'];
builds.clear();
final lazy = await time(() => lazyList(n));
print('round $round: ListView(children) $eager ms, built $eagerBuilt rows | '
'ListView.builder $lazy ms, built ${builds['row']} rows');
}
builds.clear();
await tester.drag(find.byType(ListView), const Offset(0, -2000));
await tester.pump();
print('builder, after scrolling 2000px: built ${builds['row']} more rows');
});
testWidgets('2. where setState lives', (tester) async {
await tester.pumpWidget(const MaterialApp(home: CounterPageBad()));
builds.clear();
for (var i = 0; i < 5; i++) {
await tester.tap(find.text('+'));
await tester.pump();
}
print('bad (5 taps): $builds');
await tester.pumpWidget(const MaterialApp(home: CounterPageGood()));
builds.clear();
for (var i = 0; i < 5; i++) {
await tester.tap(find.text('+'));
await tester.pump();
}
print('good (5 taps): $builds');
});
testWidgets('3. animating opacity for one second at 60 fps', (tester) async {
await tester.pumpWidget(const MaterialApp(home: FadeWithSetState()));
for (var i = 0; i < 60; i++) {
await tester.pump(const Duration(milliseconds: 16));
}
print('setState + Opacity: $builds');
builds.clear();
await tester.pumpWidget(const MaterialApp(home: FadeWithTransition()));
for (var i = 0; i < 60; i++) {
await tester.pump(const Duration(milliseconds: 16));
}
print('FadeTransition: $builds');
});
}
$ flutter test test/m/perf_test.dart
round 1: ListView(children) 14 ms, built 18 rows | ListView.builder 11 ms, built 18 rows
round 2: ListView(children) 15 ms, built 18 rows | ListView.builder 11 ms, built 18 rows
round 3: ListView(children) 16 ms, built 18 rows | ListView.builder 9 ms, built 18 rows
builder, after scrolling 2000px: built 24 more rows
bad (5 taps): {page: 5, expensive: 5}
good (5 taps): {counter: 5}
setState + Opacity: {fade-setState: 61, expensive: 1}
FadeTransition: {fade-transition: 1, expensive: 1}
00:00 +3: All tests passed!
1. Lists: the myth and the real cost¶
A common claim is that ListView(children: [...]) builds every item. The counts say otherwise (as
Level 1 · 07 first showed): with 100,000 items, both
forms built only the 18 rows visible on the 800×600 test screen (scrolling 2000 px then built 24 more). Both use a lazy
sliver underneath, so elements, layout and paint happen only for visible children.
The real difference is that ListView(children:) requires all 100,000 widget objects to exist up front — the
for loop allocates every one, every time that list is rebuilt. In this test that cost 4–7 ms per build over the builder
version (measured after a warm-up round, because in an earlier version of this test without one, whichever list was built
first paid for one-time compilation and looked an order of magnitude slower).
Cheap here, because Row_ is tiny. If constructing each item means formatting dates, parsing, or building nested widget
trees, that up-front cost is multiplied by the item count. So the advice stands, for a more precise reason: use
ListView.builder (or SliverList.builder) for long or data-driven lists, and give it an itemExtent or
prototypeItem when rows have a fixed height, so the scroll view can compute positions without laying out rows to measure
them.
2. Where setState lives¶
In the "bad" page the counter's state is in the page, so every tap rebuilt the page and Expensive (5 and 5) — because
Expensive() wasn't const, each page build created a new instance, and a different instance at the same position must be
rebuilt. The "good" version moved the state into a small _Counter and made the static parts const: five taps, five
rebuilds of the counter, nothing else. Two independent fixes, both worth applying:
- Push state down to the smallest widget that needs it (or use selective subscriptions:
select,Consumer,BlocBuilderwithbuildWhen). - Use
constfor static subtrees. Theprefer_const_constructorslint finds them for you (flutter_lintsenables it).
3. Animations without rebuilds¶
Driving opacity with setState in a controller listener rebuilt the widget 61 times in one second (once per frame,
plus the first). FadeTransition built it once; the animation updated the render object's opacity directly each frame.
Expensive was built only once in both cases because it was a const child — without const (or the child parameter
of AnimatedBuilder), it too would have rebuilt 61 times. Also note: Opacity with values between 0 and 1 can require an
offscreen layer (saveLayer) for some content, which is raster-thread work; FadeTransition and AnimatedOpacity are the
efficient choices.
The usual suspects, in rough order of frequency¶
- Too much rebuilding — state too high, missing
const,watchwhereselectwould do. - Expensive work in
build— sorting, filtering, parsing,DateFormat(...)construction. Compute once and cache, or move it into your state layer. - Non-lazy lists and grids for large data sets;
shrinkWrap: trueon long lists inside other scrollables (it forces laying out every item to measure the total height). - Intrinsic layout (
IntrinsicHeight, someTableconfigurations) in repeated rows. - Raster-thread costs:
saveLayer(fromOpacity,ShaderMask,ColorFilter, some clips), large blurs (BackdropFilter), huge images decoded at full resolution — usecacheWidth/cacheHeightonImageto decode at display size. - CPU work on the UI isolate — move it to an isolate (Level 3 · 06).
- First-run shader compilation jank on some backends: the Impeller renderer (default on iOS and on modern Android) was designed to precompile its shaders and largely removes this class of jank; where the older Skia backend is still in use, jank on first appearance of an effect can still occur.
Regression protection¶
Performance fixes rot. Keep them with tests like the ones above — asserting build counts (expect(builds['expensive'],
1)) is crude but catches "someone removed the const" regressions in CI. For frame timing on real devices, an integration
test can wrap a scroll in traceAction and fail if the 90th-percentile frame build time crosses a budget.
How It Actually Works¶
Every one of these fixes reduces one of the dirty sets from lesson 01. setState marks one
element dirty, but its build produces new widgets for its whole subtree, and every child whose widget isn't identical to
last frame's (and that isn't a const instance) must be updated — which for StatelessWidgets means running build again,
recursively. const canonicalizes instances at compile time, so identical(old, new) is true and updateChild returns
immediately. FadeTransition skips the build phase entirely: the RenderAnimatedOpacity listens to the animation and only
calls markNeedsPaint, and because opacity is applied as an OpacityLayer at composite time, even the child's painting can
be reused. Lazy slivers ask their delegate for children only as layout reaches each index, and itemExtent lets
RenderSliverFixedExtentList compute which indices are visible with arithmetic instead of laying children out.
Common mistakes¶
- Optimizing in debug mode.
- Guessing instead of opening the performance view.
- Adding
RepaintBoundaryeverywhere — each one costs memory and compositing. - Premature
computefor tiny work, orconstmania that obscures code without measurable gain. - Ignoring the raster thread when the UI thread looks fine.
Exercise¶
- Add
printcounters to the Level 2 reading-list app's widgets and tick a rating. What rebuilt that didn't need to? Fix it and add a regression assertion. - Make
Row_expensive (format aDateTimeand build a nestedRowwith an icon) and re-run experiment 1. How does the gap between the two list types change? - Replace
FadeTransitionwithAnimatedBuilder+Opacitywith thechildparameter. Count builds of the builder and ofExpensive. - On a real device, run the app with
--profile, open DevTools, scroll a long list, and find the slowest frame. What was in it?