rebuildcop
Turn unnecessary Flutter widget rebuilds into failing flutter test runs,
so a screen that starts rebuilding too much is caught in CI instead of as jank
on a mid-range phone.
Why
- Over-rebuilds are invisible in tests and code review; they show up later as jank on real devices.
- DevTools finds them, but only when someone opens it and looks. Nothing stops the fix from regressing next month.
- Frame-timing tests need a device and are flaky. A rebuild count is deterministic: same code, same number, on a laptop or a slow CI runner.
Install
flutter pub add --dev rebuildcop
Keep it in dev_dependencies and import it only from tests. It depends on
flutter_test, and a package imported from lib/ is compiled into release
builds. Windows refuse to open outside flutter test. Leave the
depend_on_referenced_packages lint on (flutter_lints enables it); it
flags a lib/ import of a dev dependency:
# analysis_options.yaml
include: package:flutter_lints/flutter.yaml
linter:
rules:
depend_on_referenced_packages: true
The lint stays quiet once the package is moved to dependencies, so add one
test to the app's suite as well. It fails, naming the file and line, if the
package is listed under dependencies or imported from lib/:
// test/rebuild_budget_guard_test.dart
import 'package:flutter_test/flutter_test.dart';
import 'package:rebuildcop/guard.dart';
void main() =>
test('rebuild budgets stay out of the app', expectRebuildBudgetIsTestOnly);
dart run rebuildcop:check runs the same check without
flutter test, for an early CI step.
Coming from imaji_rebuild_budget
This package was renamed from imaji_rebuild_budget. Replace the dev
dependency with rebuildcop, change package:imaji_rebuild_budget/ imports
to package:rebuildcop/, and run dart run rebuildcop:check. Since 0.3.0
the window class is RebuildWindow and print mode is PRINT_REBUILDS. See
the
CHANGELOG.
Quick start
Open a window, run one interaction, close the window, assert:
import 'package:flutter_test/flutter_test.dart';
import 'package:rebuildcop/rebuildcop.dart';
void main() {
testWidgets('scrolling the feed never rebuilds the tab bar', (tester) async {
await tester.pumpWidget(const MyApp());
final window = RebuildWindow.open(label: 'scroll the feed');
await tester.drag(find.byType(ListView), const Offset(0, -600));
await tester.pumpAndSettle();
window.close();
expect(window, interactionHappened(scrollOffsetMoved: find.byType(ListView)));
expect(window, rebuiltAtMost(0, of: TabBar));
});
}
Run flutter test. To pick a budget, measure first: run with
--dart-define=PRINT_REBUILDS=true and every window prints its per-type
counts.
Example output
A clock that ticks with setState at the root of a screen rebuilds the whole
product list once a second. Three ticks, budget zero:
Expected: ProductTile rebuilt at most 0 times in the window
Actual: RebuildWindow:<RebuildWindow("three clock ticks", closed, 42 rebuilds, 0 mounts)>
Which: rebuilt 15 times, 15 over budget.
42 rebuilds across 5 widget types, busiest first:
18 Text
15 ProductTile budget 0, over by 15
3 ClockText
3 ProductList
3 TickingShopSlow
The busiest types point at the cause: the screen itself rebuilt three times and took the list with it.
Features
- Headless: plain
flutter teston any CI runner. No device, emulator,integration_testortraceAction. - No probes in production code: it listens to Flutter's own
debugOnRebuildDirtyWidgethook, the one DevTools' "Track widget rebuilds" uses. - Budgets by type, private name or subtree:
rebuiltAtMost,subtreeRebuiltAtMost, andmountedAtMostfor remounts that loseState. - A zero must prove something happened:
interactionHappenedfails a window whose scroll never moved or whose fake was never called. - Baseline files:
matchesRebuildBaselinekeeps budgets in JSON, so every increase is a reviewed one-line diff. - Failures say what rebuilt, busiest types first.
Known gap: render-object widgets (Padding, ColoredBox, ...) are mostly not
counted, a framework limitation; budget the widget that builds them.
Details in the FAQ.
Documentation
- API:
RebuildWindowand every matcher - Measuring budgets (print mode) and baseline files
- Examples: setState, keys, Riverpod, Provider, Bloc
- FAQ, known gaps, supported versions and comparison
- pub.dev page and API docs
- CHANGELOG, issues and CONTRIBUTING
License
MIT © saimskywalker. See LICENSE.
Libraries
- guard
- A test that keeps
rebuildcopout of the app. - rebuildcop
- Rebuild budgets for Flutter widget tests.