Pipeline di rendering in Flutter e Impeller: diagnosticare e ottimizzare i frame

Foto di Steve A Johnson su Unsplash

Best practiceAvanzato55 min Flutter 3.x (3.27+ consigliato)

Pipeline di rendering in Flutter e Impeller: diagnosticare e ottimizzare i frame

Quando un'app Flutter "scatta", la causa non è quasi mai il codice Dart in senso generico: è un frame che ha sforato il budget in una delle fasi della pipeline di rendering. Capire come Flutter trasforma un albero di widget in pixel — e come Impeller ha cambiato le regole del gioco rispetto a Skia — è la differenza tra ottimizzare a caso e ottimizzare con il profiler in mano.

In questo tutorial avanzato vedremo:

  • le fasi della pipeline (build → layout → paint → compositing → raster) e i thread coinvolti;
  • come misurare in modo affidabile con FrameTiming e DevTools in modalità profile;
  • come verificare, attivare o disattivare Impeller su Android e iOS;
  • come ridurre il costo del raster eliminando i saveLayer inutili e usando bene i RepaintBoundary;
  • come gestire gli shader personalizzati (FragmentProgram) senza introdurre jank;
  • come trasformare le misure in un benchmark automatico ripetibile in CI.

Prerequisiti: buona dimestichezza con Flutter, widget custom e CustomPainter, uso di base di DevTools. Tutte le misure vanno fatte in --profile su dispositivo fisico: in debug i numeri non hanno alcun valore.

  1. 1

    Mappare la pipeline: dalle fasi ai thread

    Ogni frame Flutter attraversa una sequenza fissa, orchestrata dal SchedulerBinding a ogni segnale di vsync:

    1. Animate / build: si eseguono i ticker delle animazioni e vengono ricostruiti i widget dirty (Element.rebuild).
    2. Layout: il RenderObject risolve i vincoli (performLayout), con il classico modello "constraints down, sizes up".
    3. Paint: ogni render object emette comandi di disegno su un Canvas, producendo un albero di Layer.
    4. Compositing: l'albero di layer viene convertito in una Scene e passata all'engine.
    5. Raster: l'engine (Impeller o Skia) traduce la scena in comandi GPU e li esegue.

    I punti 1–4 girano sul thread UI (dove vive l'isolate Dart principale), il punto 5 sul thread raster. Ci sono inoltre il platform thread (codice nativo, plugin, method channel) e il IO thread (decodifica/upload delle texture delle immagini).

    La conseguenza pratica è fondamentale: un frame lento ha due possibili colpevoli, e la cura è diversa.

    • UI thread lento → troppi build/layout, setState su sottoalberi enormi, JSON parsing sul main isolate, ListView senza builder. Si cura con memoization, const, split dei widget, compute/isolate.
    • Raster thread lento → troppi layer, saveLayer, clip con anti-aliasing, blur, ombre complesse, shader da compilare. Si cura riducendo il lavoro di disegno.

    Prima di scrivere una riga di ottimizzazione, aggiungi un listener ai FrameTiming: ti dice quale dei due tempi sfora.

    import 'dart:developer' as developer;
    import 'package:flutter/scheduler.dart';
    
    /// Registra i frame che sforano il budget, distinguendo UI e raster.
    class FrameWatchdog {
      FrameWatchdog({this.budget = const Duration(milliseconds: 16, microseconds: 600)});
    
      final Duration budget;
      bool _installed = false;
    
      void start() {
        if (_installed) return;
        _installed = true;
        SchedulerBinding.instance.addTimingsCallback(_onTimings);
      }
    
      void stop() {
        SchedulerBinding.instance.removeTimingsCallback(_onTimings);
        _installed = false;
      }
    
      void _onTimings(List<FrameTiming> timings) {
        for (final t in timings) {
          final build = t.buildDuration; // fase animate+build+layout+paint (thread UI)
          final raster = t.rasterDuration; // fase raster (thread GPU)
          final total = t.totalSpan;
          if (total <= budget) continue;
    
          final colpevole = build > raster ? 'UI' : 'RASTER';
          developer.log(
            'JANK [$colpevole] total=${total.inMicroseconds / 1000}ms '
            'ui=${build.inMicroseconds / 1000}ms '
            'raster=${raster.inMicroseconds / 1000}ms',
            name: 'perf',
          );
        }
      }
    }
    
    // main.dart (solo in profile: kProfileMode)
    // void main() {
    //   runApp(const MyApp());
    //   if (kProfileMode) FrameWatchdog().start();
    // }

    Risultato atteso

    Lanciando `flutter run --profile` su dispositivo fisico, nel log compaiono righe `JANK [UI]` o `JANK [RASTER]` con i tempi delle due fasi: sai subito dove intervenire.

  2. 2

    Profilare con DevTools: frame chart, timeline e flag di debug

    Con il watchdog capisci dove; con DevTools capisci perché.

    Setup corretto

    flutter run --profile -d <device-id>
    # per tracciare anche i build dei widget nella timeline
    flutter run --profile --trace-startup --verbose-system-logs
    

    Apri DevTools → tab Performance:

    • il Frame Chart mostra due barre per frame: blu = UI, verde-scuro = raster. La barra che supera la linea del budget è la fase colpevole.
    • attiva Track Widget Builds (Timeline Events) per vedere i Widget Build con i nomi delle classi;
    • la sezione Rebuild Stats ti dice quanti rebuild fa ogni widget in un intervallo: numeri a tre cifre su un widget "statico" sono un campanello d'allarme.

    Flag runtime utilissimi (da attivare a caldo da codice o dalla tab Inspector):

    • debugRepaintRainbowEnabled: colora il bordo dei layer che si ridipingono. Se lo sfondo lampeggia mentre scorri una lista, stai ridipingendo troppo.
    • debugDisableClipLayers / debugDisablePhysicalShapeLayers / debugDisableOpacityLayers: disattivano temporaneamente clip, ombre e opacity. Se il raster time crolla, hai trovato il costo.
    • debugProfileBuildsEnabled e debugProfilePaintsEnabled: emettono eventi di timeline per build e paint.
    • --purge-persistent-cache al lancio: utile quando indaghi la compilazione di shader.

    Misura sempre lo stesso scenario (stesso scroll, stessa animazione) prima e dopo: senza scenario riproducibile non esiste ottimizzazione dimostrabile.

    import 'package:flutter/foundation.dart';
    import 'package:flutter/rendering.dart';
    
    /// Attiva/disattiva i flag di diagnostica a runtime da un pulsante di debug.
    void toggleRenderDiagnostics({
      bool repaintRainbow = false,
      bool disableOpacityLayers = false,
      bool disableClipLayers = false,
      bool profilePaints = false,
    }) {
      assert(() {
        debugRepaintRainbowEnabled = repaintRainbow;
        debugDisableOpacityLayers = disableOpacityLayers;
        debugDisableClipLayers = disableClipLayers;
        debugProfilePaintsEnabled = profilePaints;
        // forza un repaint completo
        RendererBinding.instance.renderViews.forEach((view) {
          view.markNeedsPaint();
        });
        return true;
      }());
    }
    
    /// Checkerboard dei layer offscreen: ogni area a scacchiera = un saveLayer.
    // In MaterialApp:
    // MaterialApp(
    //   checkerboardOffscreenLayers: true,
    //   checkerboardRasterCacheImages: true,
    //   ...
    // )

    Risultato atteso

    Sai leggere il frame chart, isolare la fase costosa e individuare visivamente i layer offscreen e i repaint superflui.

  3. 3

    Impeller: cos'è, come verificarlo e come gestirlo per piattaforma

    Impeller è il motore di rendering che ha sostituito Skia come backend predefinito di Flutter su iOS (dalla 3.10) e su Android (dalla 3.27, con Vulkan e fallback OpenGL sui dispositivi non compatibili). La differenza architetturale chiave:

    • Skia compilava gli shader a runtime, la prima volta che serviva un certo effetto: da qui il famigerato shader compilation jank al primo scroll o alla prima transizione.
    • Impeller usa un set di shader precompilati al build dell'engine, con una pipeline pensata per tessellazione e antialiasing deterministici. Risultato: latenza del primo frame molto più prevedibile, nessun bisogno di warm-up SkSL.

    Come verificare quale backend è in uso

    Nei log di avvio dell'app in profile/debug Android compare una riga tipo Using the Impeller rendering backend (Vulkan) oppure (OpenGLES). Se leggi Impeller is disabled o non vedi nulla, sei su Skia.

    Come forzare la scelta

    • Da CLI: flutter run --enable-impeller / flutter run --no-enable-impeller.
    • Android, in modo persistente: meta-data nel manifest dentro <application>.
    • iOS/macOS: chiave FLTEnableImpeller nell'Info.plist.

    Usa la disattivazione solo come esperimento diagnostico: se un glitch grafico sparisce con --no-enable-impeller, hai un bug di backend da segnalare, non una soluzione da spedire in produzione (su Android il supporto Skia è in via di dismissione).

    Implicazioni pratiche di Impeller

    • --cache-sksl e i file flutter_01.sksl.json non hanno più senso: quel warm-up serviva a Skia.
    • Il costo relativo di alcune operazioni cambia: blur e ombre sono gestiti diversamente, quindi rimisura le vecchie ottimizzazioni ereditate da app Skia.
    • Gli shader custom (FragmentProgram) sono compilati da impellerc in fase di build: la sintassi GLSL supportata è un sottoinsieme (niente loop non limitati, niente sampler dinamici).
    <!-- android/app/src/main/AndroidManifest.xml -->
    <application ...>
    
        <!-- true = forza Impeller, false = torna a Skia (solo per diagnosi) -->
        <meta-data
            android:name="io.flutter.embedding.android.EnableImpeller"
            android:value="true" />
    
        <!-- opzionale: forza il backend Vulkan anche su device in denylist -->
        <meta-data
            android:name="io.flutter.embedding.android.ImpellerBackend"
            android:value="vulkan" />
    
    </application>
    
    <!-- ios/Runner/Info.plist -->
    <!--
    <key>FLTEnableImpeller</key>
    <true/>
    -->

    Risultato atteso

    Nei log di avvio leggi esplicitamente quale backend è attivo e puoi confrontare i tempi di raster dello stesso scenario tra Impeller e Skia.

  4. 4

    Ridurre il costo del raster: saveLayer, clip e RepaintBoundary

    Quando il colpevole è il raster thread, l'obiettivo è ridurre il numero di passaggi offscreen e di layer ridipinti.

    1. Elimina i saveLayer inutili. Ogni saveLayer allocala una texture intermedia: è l'operazione più costosa che puoi chiedere alla GPU. Li generano Opacity, ShaderMask, BackdropFilter, ColorFiltered, ClipPath/ClipRRect con Clip.antiAliasWithSaveLayer, e i Transform con opacità.

    Sostituzioni tipiche:

    • Opacity(opacity: .5, child: Image(...)) → Image(..., opacity: AlwaysStoppedAnimation(.5)) oppure un colore con alpha.
    • Opacity su un Container colorato → colore con withValues(alpha: ...).
    • ClipRRect su un'immagine → DecorationImage in un BoxDecoration con borderRadius.
    • AnimatedOpacity su sottoalberi grandi → FadeTransition (che usa un OpacityLayer ottimizzato) o animazione del colore.

    2. Isola i repaint con RepaintBoundary. Un RepaintBoundary crea un layer separato: se solo il suo contenuto cambia, il resto della scena non viene ridipinto. Va messo intorno a ciò che si anima dentro una scena statica (o viceversa). Attenzione: ogni boundary costa memoria, non spargerli a caso — misura.

    3. Rispetta shouldRepaint. In un CustomPainter, restituire true sempre significa ridipingere ogni frame. Confronta i campi effettivi.

    4. Preferisci un painter unico a decine di widget. Per grafici, griglie, badge sovrapposti: un CustomPaint con un Path unico batcha molto meglio di 200 Container decorati.

    5. Attenzione alle ombre. BoxShadow con blurRadius alto su liste lunghe è uno dei costi raster più sottovalutati: valuta Material con elevation (ombra ottimizzata) o un'immagine pre-renderizzata.

    import 'package:flutter/material.dart';
    
    /// Un badge animato dentro una card statica:
    /// il RepaintBoundary evita di ridipingere tutta la card a ogni frame.
    class PulsingBadgeCard extends StatelessWidget {
      const PulsingBadgeCard({super.key, required this.title, required this.progress});
    
      final String title;
      final Animation<double> progress;
    
      @override
      Widget build(BuildContext context) {
        return Card(
          // ombra ottimizzata dal Material, invece di BoxShadow blurato
          elevation: 2,
          child: Padding(
            padding: const EdgeInsets.all(16),
            child: Row(
              children: [
                Expanded(child: Text(title)),
                RepaintBoundary(
                  child: CustomPaint(
                    size: const Size.square(28),
                    painter: _RingPainter(progress),
                  ),
                ),
              ],
            ),
          ),
        );
      }
    }
    
    class _RingPainter extends CustomPainter {
      _RingPainter(this.progress) : super(repaint: progress);
    
      final Animation<double> progress;
    
      @override
      void paint(Canvas canvas, Size size) {
        // Un solo Path, nessun saveLayer, nessun clip anti-aliased.
        final rect = Offset.zero & size;
        final paint = Paint()
          ..style = PaintingStyle.stroke
          ..strokeWidth = 4
          ..strokeCap = StrokeCap.round
          ..color = const Color(0xFF3F51B5);
        canvas.drawArc(rect.deflate(2), -1.5708, 6.2832 * progress.value, false, paint);
      }
    
      // Il repaint è già guidato da `repaint: progress`: qui confronto solo l'identità.
      @override
      bool shouldRepaint(_RingPainter old) => old.progress != progress;
    }

    Risultato atteso

    Con `checkerboardOffscreenLayers: true` le aree a scacchiera diminuiscono o scompaiono e il `rasterDuration` medio dello scenario cala in modo misurabile.

  5. 5

    Shader personalizzati con FragmentProgram senza introdurre jank

    Con Impeller gli shader del framework sono precompilati, ma i tuoi shader GLSL vengono compilati da impellerc in fase di build e caricati a runtime: il caricamento è asincrono e va fatto prima che serva, mai dentro un build chiamato durante un'animazione.

    1. Dichiara lo shader nel pubspec.yaml:

    flutter:
      shaders:
        - shaders/gradient_wave.frag
    

    2. Scrivi il fragment shader (sottoinsieme GLSL supportato: uniform scalari/vec, sampler2D, nessun ciclo illimitato):

    #version 460 core
    #include <flutter/runtime_effect.glsl>
    
    uniform vec2 uSize;
    uniform float uTime;
    out vec4 fragColor;
    
    void main() {
      vec2 uv = FlutterFragCoord().xy / uSize;
      float wave = 0.5 + 0.5 * sin((uv.x * 6.0) + uTime);
      fragColor = vec4(uv.y * 0.2, wave * 0.6, 0.9, 1.0);
    }
    

    3. Precarica il FragmentProgram in fase di bootstrap (splash o route precedente), poi riusa lo stesso oggetto FragmentShader aggiornandone gli uniform: ricrearlo a ogni frame è un errore classico.

    4. Regole d'oro

    • gli setFloat vanno impostati nell'ordine di dichiarazione degli uniform;
    • non allocare Paint/FragmentShader dentro paint() a ogni frame;
    • limita l'area coperta dallo shader: un fullscreen shader su 120 Hz è il modo più rapido per saturare la GPU;
    • verifica su dispositivi di fascia bassa con backend OpenGL, non solo su flagship Vulkan.
    import 'dart:ui' as ui;
    import 'package:flutter/material.dart';
    
    /// Precarica una volta il programma e lo espone all'app.
    class ShaderCache {
      ShaderCache._();
      static final instance = ShaderCache._();
    
      final Map<String, ui.FragmentProgram> _programs = {};
    
      Future<void> preload(Iterable<String> assets) async {
        for (final asset in assets) {
          _programs[asset] = await ui.FragmentProgram.fromAsset(asset);
        }
      }
    
      ui.FragmentProgram operator [](String asset) {
        final p = _programs[asset];
        assert(p != null, 'Shader $asset non precaricato');
        return p!;
      }
    }
    
    class WavePainter extends CustomPainter {
      WavePainter({required this.time})
          : _shader = ShaderCache.instance['shaders/gradient_wave.frag'].fragmentShader();
    
      final double time;
      final ui.FragmentShader _shader;
      final Paint _paint = Paint();
    
      @override
      void paint(Canvas canvas, Size size) {
        // Ordine identico alla dichiarazione degli uniform nel .frag
        _shader
          ..setFloat(0, size.width)
          ..setFloat(1, size.height)
          ..setFloat(2, time);
        _paint.shader = _shader;
        canvas.drawRect(Offset.zero & size, _paint);
      }
    
      @override
      bool shouldRepaint(WavePainter old) => old.time != time;
    }
    
    // main() {
    //   WidgetsFlutterBinding.ensureInitialized();
    //   ShaderCache.instance
    //       .preload(['shaders/gradient_wave.frag'])
    //       .then((_) => runApp(const MyApp()));
    // }

    Risultato atteso

    Lo shader è disponibile al primo frame che lo usa: nessun buco iniziale nel frame chart e nessuna eccezione di asset non caricato.

  6. 6

    Alleggerire il thread UI: build, layout e lavoro fuori dall'isolate

    Se il watchdog segnala JANK [UI], la GPU non è il problema. Le cause ricorrenti, in ordine di frequenza reale:

    1. Rebuild troppo ampi. Un setState in cima all'albero ricostruisce tutto. Sposta lo stato più in basso, usa ValueListenableBuilder/AnimatedBuilder con child passato a parte (il child non viene ricostruito), e marca const tutto ciò che è costante.

    2. Layout costosi. Evita IntrinsicHeight/IntrinsicWidth nelle liste (forzano un doppio passaggio di layout), non abusare di Expanded annidati dentro Column dentro SingleChildScrollView, e usa itemExtent/prototypeItem nelle ListView a elementi omogenei: consente di saltare il calcolo di layout di ogni riga.

    3. Lavoro sincrono pesante nel frame. Parsing JSON, decompressione, crittografia, ordinamenti su migliaia di elementi: fuori dal thread UI con compute o Isolate.run.

    4. Animazioni guidate da setState. Un AnimationController con setState nel listener ricostruisce l'intero widget 60–120 volte al secondo. Usa AnimatedBuilder mirato o, meglio, un CustomPainter con repaint: l'animazione: così salti build e layout e ridipingi solo il necessario.

    5. Immagini non ridimensionate. Una JPEG 4000×3000 mostrata in 120 px consuma banda su IO thread e memoria GPU: usa cacheWidth/cacheHeight o ResizeImage.

    import 'package:flutter/material.dart';
    
    /// Animazione che NON ricostruisce il sottoalbero: il child è costruito una volta,
    /// l'AnimatedBuilder ricostruisce solo il Transform.
    class EfficientSlideIn extends StatefulWidget {
      const EfficientSlideIn({super.key, required this.child});
      final Widget child;
    
      @override
      State<EfficientSlideIn> createState() => _EfficientSlideInState();
    }
    
    class _EfficientSlideInState extends State<EfficientSlideIn>
        with SingleTickerProviderStateMixin {
      late final AnimationController _c = AnimationController(
        vsync: this,
        duration: const Duration(milliseconds: 350),
      )..forward();
    
      @override
      void dispose() {
        _c.dispose();
        super.dispose();
      }
    
      @override
      Widget build(BuildContext context) {
        return AnimatedBuilder(
          animation: _c,
          // `child` viene costruito una sola volta e riusato in ogni tick
          child: RepaintBoundary(child: widget.child),
          builder: (context, child) => Transform.translate(
            offset: Offset(0, (1 - _c.value) * 24),
            child: Opacity(opacity: _c.value, child: child),
          ),
        );
      }
    }
    
    /// Lista omogenea: itemExtent evita il layout riga per riga.
    class FastList extends StatelessWidget {
      const FastList({super.key, required this.items});
      final List<String> items;
    
      @override
      Widget build(BuildContext context) {
        return ListView.builder(
          itemExtent: 72,
          itemCount: items.length,
          itemBuilder: (context, i) => _Row(label: items[i]),
        );
      }
    }
    
    class _Row extends StatelessWidget {
      const _Row({required this.label});
      final String label;
    
      @override
      Widget build(BuildContext context) => ListTile(title: Text(label));
    }

    Risultato atteso

    Il `buildDuration` dello scenario scende e in Rebuild Stats i widget statici non compaiono più tra i più ricostruiti.

  7. 7

    Rendere le performance non regressive: benchmark automatico e checklist

    Un'ottimizzazione senza test di regressione dura fino al prossimo sprint. Con integration_test puoi tracciare uno scenario, estrarre le metriche dei frame e salvarle come artefatto in CI.

    Setup

    dev_dependencies:
      integration_test:
        sdk: flutter
      flutter_test:
        sdk: flutter
    

    Esecuzione in profile

    flutter test integration_test/scroll_perf_test.dart --profile
    

    Il TimelineSummary produce metriche come average_frame_build_time_millis, 90th_percentile_frame_build_time_millis, average_frame_rasterizer_time_millis e missed_frame_build_budget_count. In CI confronta i percentili con una baseline salvata nel repo e fai fallire la build oltre una soglia (es. +15%): i valori medi mentono, i percentili no.

    Checklist finale di rendering

    • [ ] misure sempre in --profile, su device fisico, scenario riproducibile;
    • [ ] distingui UI vs raster prima di ottimizzare;
    • [ ] backend Impeller verificato nei log; nessun residuo di --cache-sksl;
    • [ ] zero saveLayer non necessari (checkerboardOffscreenLayers pulito);
    • [ ] RepaintBoundary solo dove misurabilmente utile;
    • [ ] shouldRepaint implementato correttamente in ogni CustomPainter;
    • [ ] shader custom precaricati, uniform aggiornati su istanze riusate;
    • [ ] immagini con cacheWidth/cacheHeight; liste con itemExtent dove possibile;
    • [ ] lavoro CPU pesante fuori dal main isolate;
    • [ ] benchmark integration_test in CI con baseline e soglie sui percentili.
    // integration_test/scroll_perf_test.dart
    import 'package:flutter/material.dart';
    import 'package:flutter_test/flutter_test.dart';
    import 'package:integration_test/integration_test.dart';
    import 'package:myapp/main.dart' as app;
    
    void main() {
      final binding = IntegrationTestWidgetsFlutterBinding.ensureInitialized();
    
      testWidgets('scroll del feed entro il frame budget', (tester) async {
        app.main();
        await tester.pumpAndSettle();
    
        await binding.traceAction(
          () async {
            final feed = find.byKey(const ValueKey('feed'));
            for (var i = 0; i < 6; i++) {
              await tester.fling(feed, const Offset(0, -600), 4000);
              await tester.pumpAndSettle();
            }
          },
          reportKey: 'scroll_timeline',
        );
    
        final timeline = await binding.reportData!['scroll_timeline'];
        final summary = TimelineSummary.summarize(Timeline.fromJson(timeline));
    
        // Artefatti per la CI
        await summary.writeTimelineToFile('scroll_perf', pretty: true);
        await summary.writeSummaryToFile('scroll_perf', pretty: true);
    
        // Guardrail: nessun percentile 90 sopra il budget di un frame a 60 Hz
        expect(summary.computePercentileFrameBuildTimeMillis(90), lessThan(16.6));
        expect(summary.computePercentileFrameRasterizerTimeMillis(90), lessThan(16.6));
      });
    }

    Risultato atteso

    In `build/integration_response_data/` trovi `scroll_perf.timeline_summary.json` con i percentili dei tempi di build e raster; la pipeline CI fallisce se lo scenario regredisce oltre la soglia.

CondividiXLinkedInFacebookWhatsApp

Commenti (0)

Ancora nessun commento. Inizia tu!