Flutter Performance Guide: Fixing Jank and Keeping 60fps

未分類

Flutter performance problems usually show up as “jank”: dropped frames, stutters during scrolling, slow screen transitions, and UI that feels heavy. The good news is that most jank comes from a small set of repeatable causes—unnecessary rebuilds, expensive paints, heavy work on the UI thread, and unoptimized lists/images. This guide walks through how to find the bottleneck and fix it with practical patterns.

Audience: IntermediateTested on: Flutter 3.x, Dart 3.x, Android 14 / iOS 17

What “jank” is in Flutter

Flutter renders frames on a tight schedule (typically 60fps or 120fps depending on device). If your app can’t build/layout/paint a frame in time, Flutter drops frames and you feel stutter.

  • UI thread (Dart): build/layout (widgets)
  • Raster thread (engine): paint/compositing (rendering)

When you optimize, you’re usually reducing work in one of these phases.

Step 1: Measure before you “optimize”

Guessing wastes time. Start with profiling tools:

  • Flutter DevTools → Performance (frame chart), CPU profiler, memory.
  • Performance overlay to see frame times at a glance.
  • Rebuild tracking to find widgets rebuilding too often.

Enable the performance overlay

// main.dart
MaterialApp(
  showPerformanceOverlay: true, // quick visual check
  home: const MyHomePage(),
)

If the top graph (UI) spikes, you’re doing too much work in build/layout. If the bottom graph (raster) spikes, you have heavy painting (shadows, blur, large images, etc.).

Step 2: Kill unnecessary rebuilds

The #1 cause of jank is rebuilding too much. Common reasons:

  • Calling setState() at a high level when only a small part changed
  • Passing new object instances every build (e.g., new lists, new callbacks)
  • Not using const where possible
  • Widgets rebuilding due to large inherited dependencies

Use const aggressively

// Good: const widgets are cheap and stable
const Text('Hello');
const SizedBox(height: 12);

Split widgets to reduce rebuild scope

Instead of rebuilding a whole screen, isolate changing parts:

class BigScreen extends StatefulWidget {
  const BigScreen({super.key});

  @override
  State<BigScreen> createState() => _BigScreenState();
}

class _BigScreenState extends State<BigScreen> {
  int counter = 0;

  @override
  Widget build(BuildContext context) {
    return Column(
      children: [
        const HeavyHeader(),        // stays stable
        CounterPanel(               // only this changes
          counter: counter,
          onTap: () => setState(() => counter++),
        ),
        const HeavyList(),          // stays stable
      ],
    );
  }
}

Avoid creating new lists/maps in build

This forces unnecessary work and can trigger rebuild cascades:

// Bad: new list every build
final items = [1, 2, 3];

// Better: keep stable data outside build or memoize it

Step 3: Fix expensive paints with RepaintBoundary

If the raster thread spikes, your UI is expensive to paint. Common culprits:

  • Large shadows and blur effects
  • Opacity over large areas
  • Clipping and complex shapes
  • Animating big parts of the tree

Use RepaintBoundary to isolate repaints

Wrap parts that change frequently so Flutter doesn’t repaint everything:

RepaintBoundary(
  child: AnimatedWidgetThatChangesOften(),
)

Don’t spam it everywhere—use it where DevTools shows frequent repaints.

Avoid excessive blur and shadows

Large blur radii and heavy shadows are expensive. Prefer smaller shadows or flat designs for large scrolling lists.

Step 4: Make lists scroll smoothly (ListView/Slivers)

Lists are where users feel performance most. Follow these rules:

  • Use builder constructors: ListView.builder, SliverList
  • Avoid building offscreen widgets
  • Give items stable keys when needed
  • Avoid heavy work inside item builders

Use ListView.builder correctly

ListView.builder(
  itemCount: items.length,
  itemBuilder: (context, index) {
    final item = items[index];
    return ListTile(
      title: Text(item.title),
    );
  },
)

Use Slivers for complex scrolling UIs

Slivers are ideal for collapsible headers and mixed layouts:

CustomScrollView(
  slivers: [
    const SliverAppBar(
      pinned: true,
      expandedHeight: 160,
      flexibleSpace: FlexibleSpaceBar(title: Text('Performance')),
    ),
    SliverList(
      delegate: SliverChildBuilderDelegate(
        (context, index) => ListTile(title: Text('Item $index')),
        childCount: 1000,
      ),
    ),
  ],
)

Prefetch and cache item thumbnails

For image-heavy lists, use cached network images and keep image sizes appropriate (see next section).

Step 5: Optimize images (big win)

Images cause both memory pressure and jank if not sized correctly.

  • Resize images to the display size. Don’t decode a 4000px image for a 200px thumbnail.
  • Use cacheWidth/cacheHeight for Image when you know the render size.
  • Use a caching strategy for network images.

Use cacheWidth/cacheHeight for decoded size

Image.network(
  url,
  width: 120,
  height: 120,
  fit: BoxFit.cover,
  cacheWidth: 240,  // roughly 2x for high DPI screens
  cacheHeight: 240,
)

This reduces decode cost and memory usage dramatically on scroll.

Step 6: Move heavy work off the UI thread

Parsing large JSON, encryption, CSV processing, and expensive computations can block frames. If users interact during these operations, you’ll see jank.

  • Use compute() for simple background parsing
  • Use Isolates for heavier or repeated background work

Example: compute for JSON parsing

import 'dart:convert';
import 'package:flutter/foundation.dart';

List<MyModel> parseModels(String body) {
  final decoded = jsonDecode(body) as List;
  return decoded.map((e) => MyModel.fromJson(e)).toList();
}

Future<List<MyModel>> loadModels(String body) async {
  return compute(parseModels, body);
}

Step 7: Reduce layout work

Layout can become expensive when:

  • You nest multiple scroll views
  • You use unconstrained widgets that trigger multiple layout passes
  • You rebuild large grids with complex constraints

Prefer simpler layouts and avoid “shrinkWrap + neverScrollableScrollPhysics” on big lists unless you absolutely need it.

Production checklist

  • ✅ Profile on a real device (mid-range Android is a great baseline)
  • ✅ Use --profile builds when measuring performance
  • ✅ Fix rebuild scope (split widgets, add const, reduce setState scope)
  • ✅ Address raster spikes (shadows/blur/opacity, RepaintBoundary)
  • ✅ Optimize lists (builder, slivers, stable keys)
  • ✅ Optimize images (correct sizes, cacheWidth/cacheHeight)
  • ✅ Offload heavy work (compute/isolate)

FAQ

Q: Why does everything feel smooth in debug but janky in release (or vice versa)?

A: Debug builds add overhead and change timings. Always measure in profile mode for realistic results. Release is fastest but harder to introspect.

Q: Should I add RepaintBoundary everywhere?

A: No. Use it where DevTools shows frequent repaints or where animations cause large areas to repaint. Overuse can increase layer count and memory usage.

Q: What’s the fastest win for scrolling lists?

A: Image sizing/caching. Oversized images are one of the biggest real-world causes of janky scroll.

Conclusion

Flutter jank is usually not mysterious—it’s measurable. Start with profiling, then remove unnecessary rebuilds, reduce expensive paints, optimize lists and images, and move heavy work off the UI thread. Once your app scrolls and transitions smoothly on a mid-range device, it will feel premium everywhere.

Updated: 2025-11-12

Comment

Copied title and URL