← 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 Optimizing Flutter UI Performance: Best Practices for Date Formatting and Expensive Operations

Optimizing Flutter UI Performance: Best Practices for Date Formatting and Expensive Operations

Developers often face performance bottlenecks when performing expensive operations like date formatting directly within Flutter's `build` method, especially in fast-scrolling lists. This post will delve into common pitfalls, explain why these operations are costly, and provide practical strategies for optimizing UI performance by caching formatters, using `initState`, and leveraging `compute` for background processing without blocking the UI.

Cover image for Optimizing Your Flutter Dev Setup: IDEs, Simulators, and AI Tools for Peak Productivity

Optimizing Your Flutter Dev Setup: IDEs, Simulators, and AI Tools for Peak Productivity

Flutter developers frequently seek to refine their development environments. This post will dive into popular IDE choices like VS Code and Android Studio, discuss best practices for managing iOS and Android simulators (including in-IDE options), and explore the practical integration of AI tools for code generation and problem-solving to boost overall efficiency.

Cover image for Demystifying Flutter Performance: Practical Strategies for Large-Scale Apps

Demystifying Flutter Performance: Practical Strategies for Large-Scale Apps

Flutter's performance is often blamed for issues in complex applications, but the real culprits are usually architectural decisions, inefficient widget rebuilds, and unoptimized resource handling. This post will dive into common performance bottlenecks in large Flutter apps, providing actionable strategies for profiling, optimizing state management, handling images and network requests efficiently, and leveraging CI/CD for continuous performance monitoring.