← Back to posts Cover image for Mastering Flutter Rebuilds and Custom Rendering for Smooth 60 FPS UI

Mastering Flutter Rebuilds and Custom Rendering for Smooth 60 FPS UI

· 7 min read
Weekly Digest

The Flutter news you actually need

No spam, ever. Unsubscribe in one click.

Chris
By Chris

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:

  1. Keep Widgets Small and Focused: Break down large widgets into smaller, more manageable ones. This limits the scope of rebuilds.

  2. Push State Down: Ensure that state changes only trigger rebuilds in the widgets that actually need the updated data.

  3. Use const Widgets: If a widget (and all its children) never changes after creation, mark it const. 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,
    ),
    // ...
  4. 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:

  1. shouldRepaint is Your Best Friend: This method is critical. It tells Flutter whether your painter actually needs to redraw. If your underlying data hasn’t changed, return false to skip the paint call.

    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;
      }
    }
  2. Offload Heavy Calculations: Don’t perform complex calculations (e.g., path generation, heavy trigonometric functions) inside paint if they don’t change frequently. Calculate them once in your State or didUpdateWidget and pass the results to the painter.

  3. Efficient Canvas Usage:

    • For drawing many similar points, consider canvas.drawRawPoints.
    • For complex shapes, Path objects can be pre-computed and then drawn with canvas.drawPath.
    • Avoid creating new Paint objects or other drawing resources inside paint if they can be reused.

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:

  1. 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 compute for simpler isolate usage.
  2. Cache Aggressively: If you process an image or data once, store the result. Don’t re-process it unless absolutely necessary.
  3. 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.
  4. 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

Cover image for Flutter for High-Performance Desktop: Is it Ready for CAD, Image Processing, and Complex GUIs?

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.

Cover image for Debugging Flutter Web Navigation: Solving the Deep Link Refresh Bug

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.

Cover image for Mastering Internationalization in Flutter: Centralized Strings for Scalable 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.