Achieving fluid, 120Hz refresh rates in Flutter mobile applications requires understanding how the framework translates high-level widget trees into low-level GPU draw commands.
When complex animations drop frames (known as jank), the issue almost always stems from excessive build operations, expensive layout passes, or lack of raster isolation.
Flutter DevTools Performance Overlay separates UI thread overhead from GPU rasterization time.
The Flutter Rendering Pipeline
To diagnose bottlenecks, understand the three trees:
- Widget Tree: Immutable configuration blueprints instantiated rapidly.
- Element Tree: Long-lived structural graph managing lifecycle and parent-child linkage.
- RenderObject Tree: Heavyweight geometric objects computing layout coordinates, hit-testing, and paint operations.
Unnecessary widget rebuilds force redundant Element comparisons. If layout boundaries are not isolated, a rebuild high in the tree triggers cascade relayouts down hundreds of children.
Key Performance Techniques
| Technique | Cost Solved | Implementation |
|---|---|---|
const Constructors | Eliminates duplicate Element reconciliations | Enforce via linter: prefer_const_constructors |
RepaintBoundary | Prevents parent repaint from redrawing child canvas | Wrap animated charts, spinning loaders, and canvases |
| Granular Selectors | Stops whole-screen rebuild on single value change | ref.watch(provider.select((s) => s.total)) |
| Image Cache Bounds | Avoids OOM crashes from 4K camera decodes | Specify cacheWidth and cacheHeight on ResizeImage |
| Impeller Backend | Eradicates runtime shader compilation jank | Default in Flutter 3.47+ for iOS, macOS, Windows |
Isolating Animations with RepaintBoundary
Consider a continuous rotating loader inside a complex screen. Without boundary isolation, every tick of the animation marks the entire viewport dirty:
import 'package:flutter/material.dart';
class IsolatedLoadingWidget extends StatelessWidget {
const IsolatedLoadingWidget({super.key});
@override
Widget build(BuildContext context) {
return Center(
// RepaintBoundary creates an isolated RenderLayer in the compositor
child: RepaintBoundary(
child: AnimatedRotation(
turns: 1.0,
duration: const Duration(seconds: 2),
child: Container(
width: 48,
height: 48,
decoration: const BoxDecoration(
shape: BoxShape.circle,
gradient: SweepGradient(
colors: [Colors.blue, Colors.purple, Colors.transparent],
),
),
),
),
),
);
}
}Measuring Memory Leaks with DevTools
Memory bloat frequently originates from retained closures and unclosed streams:
class LeakyScreenState extends State<LeakyScreen> {
late final StreamSubscription _subscription;
@override
void initState() {
super.initState();
// Listening to global stream without cancellation on dispose leaks the entire State!
_subscription = eventBus.stream.listen((event) {
if (mounted) setState(() {});
});
}
@override
void dispose() {
// MANDATORY: Cancel all stream subscriptions and dispose AnimationControllers
_subscription.cancel();
super.dispose();
}
}Golden Rule: Always test performance on real physical devices using flutter run --profile. Debug mode runs JIT compilation with assertion overhead and will mislead your frame budgets.