Mastering Flutter Rebuilds and Custom Rendering for Smooth 60 FPS UI
The Flutter news you actually need
No spam, ever. Unsubscribe in one click.
Flutter’s declarative UI is fantastic, making it easy to build beautiful apps. But sometimes, especially when dealing with complex animations, custom drawing, or game-like interactions, you might notice your UI feeling a little sluggish. That dreaded “jank” means you’re not hitting that buttery-smooth 60 frames per second (FPS). Let’s dive deep into how Flutter renders and how we can optimize it to keep your users happy.
Understanding the Flutter Build Process
At its core, Flutter’s UI is a tree of widgets. When the state of your application changes, Flutter intelligently rebuilds parts of this tree. Every build method you write is a description of your UI at a given moment.
The crucial insight: the build method itself is designed to be incredibly fast and cheap. Flutter’s element tree and render object tree do the heavy lifting of figuring out what actually needs to be painted to the screen. The problem isn’t usually build being called, but rather the work done within or triggered by build that’s expensive.
Common Culprit: Unnecessary Rebuilds
A common performance pitfall is letting large portions of your widget tree rebuild when only a small, isolated piece of data has changed.
Consider this:
class MyComplexScreen extends StatefulWidget {
@override
State<MyComplexScreen> createState() => _MyComplexScreenState();
}
class _MyComplexScreenState extends State<MyComplexScreen> {
int _counter = 0;
void _incrementCounter() {
setState(() {
_counter++;
});
}
@override
Widget build(BuildContext context) {
print('MyComplexScreen built!'); // This will print on every counter increment
return Scaffold(
appBar: AppBar(title: const Text('Performance Demo')),
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
// Imagine this is a very complex widget tree
const Text(
'You have pushed the button this many times:',
),
Text(
'$_counter',
style: Theme.of(context).textTheme.headlineMedium,
),
ElevatedButton(
onPressed: _incrementCounter,
child: const Text('Increment'),
),
],
),
),
);
}
}
Every time _incrementCounter is called, setState rebuilds the entire _MyComplexScreenState widget, including parts that don’t depend on _counter.
The Fixes:
-
Keep Widgets Small and Focused: Break down large widgets into smaller, more manageable ones. This limits the scope of rebuilds.
-
Push State Down: Ensure that state changes only trigger rebuilds in the widgets that actually need the updated data.
-
Use
constWidgets: If a widget (and all its children) never changes after creation, mark itconst. Flutter can then perform aggressive optimizations, skipping rebuilds for these widgets entirely.// ... inside _MyComplexScreenState's build method const Text( // Marking this as const prevents its rebuild 'You have pushed the button this many times:', ), Text( '$_counter', // This cannot be const as it depends on state style: Theme.of(context).textTheme.headlineMedium, ), // ... -
Leverage State Management (e.g., Provider
Consumer/Selector): Tools like Provider allow you to precisely control which widgets react to state changes, rebuilding only the necessary parts.// Example using Provider (assuming MyCounterModel provides _counter) // Consumer only rebuilds its builder method when MyCounterModel changes Consumer<MyCounterModel>( builder: (context, counterModel, child) { return Text( '${counterModel.counter}', style: Theme.of(context).textTheme.headlineMedium, ); }, child: const Text( // This child is passed directly to the builder and won't rebuild 'You have pushed the button this many times:', ), ),
Mastering CustomPainter for Pixel-Perfect Control
When you need to draw directly onto the canvas, CustomPainter is your tool. It’s powerful, but can easily become a bottleneck if not used correctly.
The paint Method - A Performance Hotspot:
The paint method is called whenever your custom painter needs to redraw. This can happen for various reasons: state changes, animations, or even if another widget overlaps it temporarily.
Optimizing CustomPainter:
-
shouldRepaintis Your Best Friend: This method is critical. It tells Flutter whether your painter actually needs to redraw. If your underlying data hasn’t changed, returnfalseto skip thepaintcall.class MyShapePainter extends CustomPainter { final double rotationAngle; final Color shapeColor; MyShapePainter(this.rotationAngle, this.shapeColor); @override void paint(Canvas canvas, Size size) { final paint = Paint()..color = shapeColor; canvas.drawRect(Rect.fromLTWH(0, 0, size.width / 2, size.height / 2), paint); // Imagine complex drawing logic here } @override bool shouldRepaint(covariant MyShapePainter oldDelegate) { // Only repaint if the angle or color has changed return oldDelegate.rotationAngle != rotationAngle || oldDelegate.shapeColor != shapeColor; } } -
Offload Heavy Calculations: Don’t perform complex calculations (e.g., path generation, heavy trigonometric functions) inside
paintif they don’t change frequently. Calculate them once in yourStateordidUpdateWidgetand pass the results to the painter. -
Efficient Canvas Usage:
- For drawing many similar points, consider
canvas.drawRawPoints. - For complex shapes,
Pathobjects can be pre-computed and then drawn withcanvas.drawPath. - Avoid creating new
Paintobjects or other drawing resources insidepaintif they can be reused.
- For drawing many similar points, consider
Smooth Animations and Game Loops
For continuous updates, like game loops or intricate animations, relying solely on setState with Timer.periodic can lead to unstable frame pacing and dropped frames. Flutter’s animation system is built on TickerProvider and AnimationController for a reason: it synchronizes with the platform’s VSync signal, ensuring smooth, consistent frame delivery.
Instead of manual timers, drive your CustomPainter with an AnimationController:
class AnimatedPainterScreen extends StatefulWidget {
const AnimatedPainterScreen({super.key});
@override
State<AnimatedPainterScreen> createState() => _AnimatedPainterScreenState();
}
class _AnimatedPainterScreenState extends State<AnimatedPainterScreen> with SingleTickerProviderStateMixin {
late AnimationController _controller;
late Animation<double> _rotationAnimation;
@override
void initState() {
super.initState();
_controller = AnimationController(
vsync: this,
duration: const Duration(seconds: 5),
)..repeat(); // Repeat indefinitely for a continuous animation
_rotationAnimation = Tween<double>(begin: 0, end: 2 * 3.14159).animate(_controller);
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Animated CustomPainter')),
body: Center(
child: AnimatedBuilder(
animation: _rotationAnimation,
builder: (context, child) {
return CustomPaint(
size: const Size(200, 200),
painter: RotatingShapePainter(_rotationAnimation.value),
);
},
),
),
);
}
}
class RotatingShapePainter extends CustomPainter {
final double currentRotation;
RotatingShapePainter(this.currentRotation);
@override
void paint(Canvas canvas, Size size) {
final paint = Paint()
..color = Colors.blue
..style = PaintingStyle.fill;
canvas.translate(size.width / 2, size.height / 2); // Move origin to center
canvas.rotate(currentRotation); // Apply rotation
canvas.drawRect(Rect.fromLTWH(-50, -50, 100, 100), paint); // Draw centered square
}
@override
bool shouldRepaint(covariant RotatingShapePainter oldDelegate) {
return oldDelegate.currentRotation != currentRotation; // Repaint only if rotation changes
}
}
Here, AnimatedBuilder listens to the AnimationController and rebuilds only the CustomPaint widget when the animation value changes, efficiently driving the RotatingShapePainter.
Troubleshooting Intensive Operations
Sometimes, low FPS comes from truly heavy work, like processing images or complex data structures per frame.
The Problem: Synchronous Heavy Lifting
Operations like decoding large images from raw pixel data (decodeImageFromPixels) or complex mathematical computations can block the UI thread, causing severe frame drops. Doing these per frame is almost always a recipe for disaster.
The Solutions:
- Offload to Isolates: For truly CPU-bound tasks, move the work to a separate Dart Isolate. This runs your code in parallel, completely off the UI thread. Once the processing is done, send the result back to the UI thread. Modern Flutter (since 3.7) also offers
computefor simpler isolate usage. - Cache Aggressively: If you process an image or data once, store the result. Don’t re-process it unless absolutely necessary.
- Pre-process Assets: For static assets that require heavy processing (e.g., generating complex meshes or textures), do it at build time or on first app launch, not during runtime rendering.
- Batch Operations: If you have many small operations, try to batch them into larger, less frequent operations.
Monitoring Your Performance
When troubleshooting, always keep Flutter DevTools handy. The “Performance” tab provides invaluable insights into your UI, GPU, and raster threads, helping you pinpoint exactly where the jank is coming from. The PerformanceOverlay (set showPerformanceOverlay: true in MaterialApp or CupertinoApp) gives you a quick visual indication of raster and UI thread performance directly on your device.
By understanding Flutter’s rendering pipeline and applying these optimization techniques, you’ll be well on your way to building truly smooth, 60 FPS user interfaces, even for the most demanding applications. Happy coding!
This blog is produced with the assistance of AI by a human editor. Learn more
Related Posts
Flutter for High-Performance Desktop: Is it Ready for CAD, Image Processing, and Complex GUIs?
Developers are curious about Flutter's capabilities beyond typical business apps, especially for demanding desktop applications like CAD/CAM or image/video processing. This post will explore Flutter's suitability for high-performance, viewport-based desktop GUIs, discussing Dart's memory model, the 60fps update loop, and real-world examples to gauge its readiness for 'serious' complex software.
Debugging Flutter Web Navigation: Solving the Deep Link Refresh Bug
Flutter web applications often suffer from a frustrating 'deep link refresh bug' where refreshing the browser on a nested route (e.g., /home/details) bounces the user back to the root or an incorrect path. This post will diagnose the common causes of this issue, explain how Flutter's router handles web URLs, and provide practical solutions and best practices for building robust, refresh-proof navigation in your Flutter web apps.
Mastering Internationalization in Flutter: Centralized Strings for Scalable Apps
As Flutter applications grow, managing strings for multiple languages or just keeping text consistent becomes a challenge. This post will guide developers through effective strategies for centralizing strings, implementing robust internationalization (i18n) and localization (l10n), and leveraging tools to streamline the process for small to large-scale projects.