Pipeline di rendering in Flutter: profilare e ottimizzare con RepaintBoundary e Impeller

Foto di Adhitya Sibikumar su Unsplash

Best practiceAvanzato45 min Flutter 3.x

Pipeline di rendering in Flutter: profilare e ottimizzare con RepaintBoundary e Impeller

Perché la pipeline conta

Quando un'app Flutter "scatta", il problema raramente è il widget sbagliato: è il lavoro svolto per frame nelle fasi di build, layout, paint e raster.

In questo tutorial avanzato vedremo come:

  • leggere le fasi della pipeline (build → layout → paint → composite → raster);
  • misurare i frame con SchedulerBinding e i DevTools;
  • isolare i repaint costosi con RepaintBoundary;
  • ridurre il lavoro di raster con shader e ombre più economiche;
  • validare i risultati su Impeller.

Prerequisiti: Flutter 3.x, familiarità con CustomPainter, DevTools e build in profile mode su device fisico.

  1. 1

    Preparare una scena volutamente costosa

    Per misurare serve un caso realistico. Creiamo una schermata con un CustomPainter animato affiancato a una lista statica: senza accorgimenti, l'animazione forzerà il repaint dell'intero sottoalbero.

    Esegui sempre in profile mode su device fisico:

    flutter run --profile -d <device-id>
    

    Il debug mode include assert e controlli che falsano i tempi di build e raster.

    class HeavyScene extends StatefulWidget {
      const HeavyScene({super.key});
      @override
      State<HeavyScene> createState() => _HeavySceneState();
    }
    
    class _HeavySceneState extends State<HeavyScene>
        with SingleTickerProviderStateMixin {
      late final AnimationController _c = AnimationController(
        vsync: this,
        duration: const Duration(seconds: 3),
      )..repeat();
    
      @override
      void dispose() {
        _c.dispose();
        super.dispose();
      }
    
      @override
      Widget build(BuildContext context) {
        return Column(
          children: [
            SizedBox(
              height: 220,
              child: AnimatedBuilder(
                animation: _c,
                builder: (_, __) => CustomPaint(
                  painter: WavePainter(_c.value),
                  size: Size.infinite,
                ),
              ),
            ),
            Expanded(
              child: ListView.builder(
                itemCount: 200,
                itemBuilder: (_, i) => ListTile(title: Text('Riga $i')),
              ),
            ),
          ],
        );
      }
    }

    Risultato atteso

    L'app gira in profile mode e mostra un'animazione continua sopra una lista scrollabile.

  2. 2

    Misurare i frame: UI thread vs raster thread

    Un frame ha due budget: il lavoro Dart/UI (build, layout, paint) e il lavoro raster (esecuzione della display list sulla GPU). A 60 fps hai ~16,6 ms totali.

    Aggiungi un timings callback per loggare i frame fuori budget: è il modo più rapido per capire quale thread è il collo di bottiglia prima ancora di aprire i DevTools.

    void attachFrameMonitor() {
      SchedulerBinding.instance.addTimingsCallback((List<FrameTiming> timings) {
        for (final t in timings) {
          final ui = t.buildDuration.inMicroseconds / 1000.0;
          final raster = t.rasterDuration.inMicroseconds / 1000.0;
          final total = t.totalSpan.inMicroseconds / 1000.0;
          if (total > 16.6) {
            debugPrint('JANK  ui=${ui.toStringAsFixed(1)}ms '
                'raster=${raster.toStringAsFixed(1)}ms '
                'total=${total.toStringAsFixed(1)}ms');
          }
        }
      });
    }
    
    // in main(), dopo WidgetsFlutterBinding.ensureInitialized()

    Risultato atteso

    In console compaiono righe JANK che distinguono chiaramente il tempo UI da quello raster.

  3. 3

    Capire chi si ripinta con i flag di debug

    Prima di ottimizzare bisogna vedere i repaint. Flutter offre overlay di debug attivabili a runtime (solo in debug mode):

    • debugRepaintRainbowEnabled: colora i bordi dei layer a ogni repaint;
    • debugProfileBuildsEnabled / debugProfilePaintsEnabled: aggiungono eventi al timeline;
    • debugPrintMarkNeedsPaintStacks: stampa lo stack di chi invalida il paint.

    Se il bordo attorno alla lista cambia colore mentre scorre solo l'animazione, stai ripingendo troppo.

    import 'package:flutter/rendering.dart';
    
    void enableRenderDebug() {
      assert(() {
        debugRepaintRainbowEnabled = true;
        debugProfileBuildsEnabled = true;
        debugProfilePaintsEnabled = true;
        return true;
      }());
    }

    Risultato atteso

    L'overlay arcobaleno evidenzia i sottoalberi che si ripingono a ogni frame, inclusa la lista che non dovrebbe farlo.

  4. 4

    Isolare i repaint con RepaintBoundary

    RepaintBoundary crea un layer separato: il sottoalbero viene rasterizzato in una propria superficie e un suo repaint non propaga il lavoro ai fratelli.

    Regole pratiche:

    • inserisci un boundary attorno a ciò che si anima frequentemente (painter, progress, ticker);
    • oppure attorno a contenuti statici e costosi che non devono essere ridipinti;
    • non abusarne: ogni layer costa memoria GPU e una composizione in più.

    Nei CustomPaint ricordati anche di shouldRepaint corretto e di isComplex/willChange per guidare la cache del raster.

    class WavePainter extends CustomPainter {
      WavePainter(this.t);
      final double t;
    
      @override
      void paint(Canvas canvas, Size size) {
        final paint = Paint()..color = const Color(0xFF2962FF);
        final path = Path()..moveTo(0, size.height);
        for (double x = 0; x <= size.width; x += 4) {
          final y = size.height / 2 +
              30 * math.sin((x / size.width * 2 * math.pi) + t * 2 * math.pi);
          path.lineTo(x, y);
        }
        path..lineTo(size.width, size.height)..close();
        canvas.drawPath(path, paint);
      }
    
      @override
      bool shouldRepaint(WavePainter old) => old.t != t;
    }
    
    // Uso: animazione isolata + lista isolata
    Column(
      children: [
        RepaintBoundary(
          child: SizedBox(
            height: 220,
            child: AnimatedBuilder(
              animation: _c,
              builder: (_, __) => CustomPaint(
                painter: WavePainter(_c.value),
                isComplex: true,
                willChange: true,
                size: Size.infinite,
              ),
            ),
          ),
        ),
        const Expanded(child: RepaintBoundary(child: StaticList())),
      ],
    )

    Risultato atteso

    Con il rainbow attivo solo l'area dell'animazione lampeggia; il tempo UI per frame cala in modo misurabile.

  5. 5

    Ridurre il costo del raster: ombre, opacità e saveLayer

    Se il collo di bottiglia è il raster thread, il problema è nella display list, non nei widget. Le cause più comuni:

    • Opacity animata su sottoalberi grandi → usa AnimatedOpacity solo su foglie, o FadeTransition con RepaintBoundary;
    • ClipPath + BackdropFilter sovrapposti → ogni filtro forza un saveLayer;
    • ombre complesse: BoxShadow con blur alto su molte card.

    Un buon compromesso è pre-renderizzare contenuti statici in immagine con RepaintBoundary.toImage() quando il contenuto cambia di rado.

    // Male: opacità animata su un sottoalbero ampio
    Opacity(opacity: _c.value, child: const ExpensiveSubtree());
    
    // Meglio: transizione su layer dedicato
    RepaintBoundary(
      child: FadeTransition(
        opacity: _c,
        child: const RepaintBoundary(child: ExpensiveSubtree()),
      ),
    );
    
    // Snapshot di contenuti statici costosi
    Future<ui.Image> snapshot(GlobalKey key, double dpr) async {
      final boundary =
          key.currentContext!.findRenderObject()! as RenderRepaintBoundary;
      return boundary.toImage(pixelRatio: dpr);
    }

    Risultato atteso

    Il valore rasterDuration nei log scende sotto il budget anche durante le transizioni.

  6. 6

    Validare su Impeller e misurare in CI

    Impeller è il motore di rendering predefinito su iOS e Android recenti: precompila gli shader, eliminando il jank del primo frame tipico di Skia.

    Verifiche utili:

    # forzare il motore per confronto (Android)
    flutter run --profile --enable-impeller
    flutter run --profile --no-enable-impeller
    

    Per rendere l'ottimizzazione non regressiva, raccogli le metriche con integration_test e traceAction, esportando il summary in CI.

    // integration_test/perf_test.dart
    void main() {
      final binding = IntegrationTestWidgetsFlutterBinding.ensureInitialized();
    
      testWidgets('scroll senza jank', (tester) async {
        await tester.pumpWidget(const MyApp());
        await tester.pumpAndSettle();
    
        await binding.traceAction(() async {
          for (var i = 0; i < 5; i++) {
            await tester.fling(
                find.byType(ListView), const Offset(0, -400), 3000);
            await tester.pumpAndSettle();
          }
        }, reportKey: 'scrolling_summary');
      });
    }

    Risultato atteso

    Il summary riporta average_frame_build_time_millis e average_frame_rasterizer_time_millis, confrontabili tra build per bloccare le regressioni.

  7. 7

    Checklist finale e anti-pattern da evitare

    Prima di chiudere il lavoro, verifica:

    1. Misura sempre in profile mode su device reale, mai in debug o simulatore.
    2. Un boundary per motivo, non per abitudine: controlla in DevTools > Layers se i layer sono cresciuti troppo.
    3. const ovunque possibile: riduce il lavoro di build prima ancora del paint.
    4. shouldRepaint e shouldRebuildSemantics corretti nei painter custom.
    5. Evita MediaQuery.of(context) in alto nell'albero: invalida build inutili a ogni rotazione o apertura tastiera.
    6. Shader warm-up: con Impeller non serve più il bundle di shader di Skia, rimuovi vecchi --bundle-sksl-path dalle pipeline.

    Anti-pattern ricorrenti: Opacity al posto di colori con alpha, ListView senza itemExtent o prototypeItem su righe omogenee, immagini di rete non ridimensionate (cacheWidth).

    // Liste omogenee: evita il layout ripetuto di ogni figlio
    ListView.builder(
      prototypeItem: const ListTile(title: Text('prototipo')),
      itemCount: items.length,
      itemBuilder: (_, i) => ListTile(title: Text(items[i])),
    );
    
    // Immagini: decodifica alla dimensione reale di schermo
    Image.network(url, cacheWidth: 320);

    Risultato atteso

    L'app mantiene frame stabili sotto i 16,6 ms durante scroll e animazioni, con metriche riproducibili in CI.

CondividiXLinkedInFacebookWhatsApp

Commenti (0)

Ancora nessun commento. Inizia tu!