Performance test automatizzati in Flutter: misurare jank e frame in CI

Foto di Luke Miller su Unsplash

Best practiceAvanzato45 min Flutter 3.x

Performance test automatizzati in Flutter: misurare jank e frame in CI

I widget test verificano la correttezza, ma non dicono nulla su quanto scorre fluida la tua UI. Una lista che perde frame o una transizione che va a scatti non fa fallire nessun test: se ne accorgono solo gli utenti.

In questo tutorial avanzato costruiamo una suite di performance test automatizzati basata su integration_test e IntegrationTestWidgetsFlutterBinding.watchPerformance. Impareremo a:

  • registrare la timeline di uno scenario reale (scroll di una lista pesante);
  • estrarre le metriche di build e raster time dal file *.timeline_summary.json;
  • definire soglie di jank e far fallire la pipeline in caso di regressione;
  • eseguire tutto su un device fisico/emulatore in profile mode dentro GitHub Actions.

Prerequisito: conoscenza di base di integration_test e della pipeline di rendering (build → layout → paint → raster). I benchmark vanno sempre eseguiti in --profile, mai in debug: i tempi in debug sono inutilizzabili.

  1. 1

    Configurare il progetto per i benchmark

    Aggiungi integration_test alle dev dependencies e crea la cartella integration_test/.

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

    Per avere un benchmark significativo serve uno scenario rappresentativo e deterministico: niente dati random, niente immagini di rete (usa asset locali o placeholder), nessuna animazione infinita fuori controllo.

    Crea una schermata di prova con una lista lunga e costosa a sufficienza da stressare il raster thread (ombre, bordi arrotondati, gradienti).

    // lib/benchmark/heavy_list_page.dart
    import 'package:flutter/material.dart';
    
    class HeavyListPage extends StatelessWidget {
      const HeavyListPage({super.key});
    
      @override
      Widget build(BuildContext context) {
        return Scaffold(
          appBar: AppBar(title: const Text('Heavy list')),
          body: ListView.builder(
            key: const Key('heavy_list'),
            itemCount: 500,
            itemBuilder: (context, i) => Container(
              margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
              height: 96,
              decoration: BoxDecoration(
                borderRadius: BorderRadius.circular(16),
                gradient: const LinearGradient(
                  colors: [Color(0xFF3F51B5), Color(0xFF00BCD4)],
                ),
                boxShadow: const [
                  BoxShadow(blurRadius: 12, offset: Offset(0, 6), color: Colors.black26),
                ],
              ),
              alignment: Alignment.center,
              child: Text('Elemento $i',
                  style: const TextStyle(color: Colors.white, fontSize: 18)),
            ),
          ),
        );
      }
    }

    Risultato atteso

    Il progetto compila e la schermata `HeavyListPage` scorre una lista di 500 card con gradiente e ombra.

  2. 2

    Scrivere il test che registra la timeline

    Il punto chiave è IntegrationTestWidgetsFlutterBinding.watchPerformance: avvolge un blocco di codice, registra la timeline del tracing e produce un summary salvato su disco al termine della sessione (nella cartella build/ del progetto host).

    Usa reportKey per distinguere più scenari nello stesso run: ogni chiave genera un file <reportKey>.timeline_summary.json.

    Attenzione a await tester.pumpAndSettle() fuori dal blocco misurato: vuoi misurare lo scroll, non il warm-up dei widget e la compilazione degli shader.

    // 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/benchmark/heavy_list_page.dart';
    
    void main() {
      final binding = IntegrationTestWidgetsFlutterBinding.ensureInitialized();
    
      testWidgets('scroll della lista pesante', (tester) async {
        await tester.pumpWidget(const MaterialApp(home: HeavyListPage()));
        await tester.pumpAndSettle();
    
        // Warm-up: primo fling non misurato (shader + cache immagini)
        await tester.fling(find.byKey(const Key('heavy_list')), const Offset(0, -500), 1500);
        await tester.pumpAndSettle();
    
        await binding.watchPerformance(() async {
          for (var i = 0; i < 6; i++) {
            await tester.fling(
              find.byKey(const Key('heavy_list')),
              const Offset(0, -400),
              2500,
            );
            await tester.pumpAndSettle();
            await tester.fling(
              find.byKey(const Key('heavy_list')),
              const Offset(0, 400),
              2500,
            );
            await tester.pumpAndSettle();
          }
        }, reportKey: 'scroll_timeline');
      });
    }

    Risultato atteso

    Eseguendo `flutter test integration_test/scroll_perf_test.dart --profile -d <device>` il test passa e viene generato `build/scroll_timeline.timeline_summary.json`.

  3. 3

    Leggere e interpretare le metriche del summary

    Il file di summary contiene decine di chiavi. Quelle che contano davvero:

    Chiave Significato
    average_frame_build_time_millis tempo medio sul UI thread (build/layout/paint)
    90th_percentile_frame_build_time_millis coda alta: dove si nasconde il jank
    average_frame_rasterizer_time_millis tempo medio sul raster thread (GPU)
    missed_frame_build_budget_count frame oltre il budget sul UI thread
    missed_frame_rasterizer_budget_count frame oltre il budget sul raster thread
    frame_count numero totale di frame misurati

    Regola pratica: a 60 Hz il budget è 16,67 ms, a 120 Hz scende a 8,33 ms. Se frame_build_time è alto il collo di bottiglia è nel tuo codice Dart (widget troppo grandi, build costosi, nessun const); se è alto il rasterizer_time il problema è grafico (saveLayer, opacità, blur, clip anti-aliasing, shader non precompilati).

    Il summary può anche essere esportato in formato leggibile insieme alla timeline completa, apribile su chrome://tracing o su Perfetto per l'analisi manuale.

    {
      "average_frame_build_time_millis": 3.41,
      "90th_percentile_frame_build_time_millis": 6.02,
      "worst_frame_build_time_millis": 18.77,
      "average_frame_rasterizer_time_millis": 5.88,
      "90th_percentile_frame_rasterizer_time_millis": 11.4,
      "missed_frame_build_budget_count": 2,
      "missed_frame_rasterizer_budget_count": 7,
      "frame_count": 420
    }

    Risultato atteso

    Sai leggere il summary e distinguere un problema di UI thread da uno di raster thread.

  4. 4

    Trasformare il summary in un test di regressione con soglie

    Un numero su un file JSON non protegge da nulla: serve un gate che fallisca quando le metriche peggiorano. Crea un test Dart lato host (eseguito con dart test o flutter test sulla macchina, non sul device) che legge il summary e confronta i valori con un budget dichiarato.

    Consiglio: usa percentuali di frame persi anziché valori assoluti, così il test resta stabile su hardware diversi. Tieni le soglie realistiche e rivedile quando ottimizzi, altrimenti diventano rumore ignorato dal team.

    // test/perf_gate_test.dart
    import 'dart:convert';
    import 'dart:io';
    import 'package:flutter_test/flutter_test.dart';
    
    class PerfBudget {
      const PerfBudget({
        required this.maxP90Build,
        required this.maxP90Raster,
        required this.maxMissedFramesRatio,
      });
      final double maxP90Build;
      final double maxP90Raster;
      final double maxMissedFramesRatio;
    }
    
    void main() {
      const budget = PerfBudget(
        maxP90Build: 8.0,
        maxP90Raster: 14.0,
        maxMissedFramesRatio: 0.05,
      );
    
      test('scroll rispetta il performance budget', () {
        final file = File('build/scroll_timeline.timeline_summary.json');
        expect(file.existsSync(), isTrue,
            reason: 'Esegui prima il benchmark in profile mode');
    
        final s = jsonDecode(file.readAsStringSync()) as Map<String, dynamic>;
        double n(String k) => (s[k] as num).toDouble();
    
        final frames = n('frame_count');
        final missed = n('missed_frame_build_budget_count') +
            n('missed_frame_rasterizer_budget_count');
        final ratio = frames == 0 ? 1.0 : missed / frames;
    
        expect(n('90th_percentile_frame_build_time_millis'),
            lessThan(budget.maxP90Build),
            reason: 'Regressione sul UI thread');
        expect(n('90th_percentile_frame_rasterizer_time_millis'),
            lessThan(budget.maxP90Raster),
            reason: 'Regressione sul raster thread');
        expect(ratio, lessThan(budget.maxMissedFramesRatio),
            reason: 'Troppi frame persi: ${(ratio * 100).toStringAsFixed(1)}%');
      });
    }

    Risultato atteso

    Se una modifica peggiora lo scroll oltre il budget, il test fallisce indicando quale thread è in regressione.

  5. 5

    Automatizzare benchmark e gate in GitHub Actions

    Il benchmark ha senso solo se gira sempre sullo stesso hardware: un emulatore con profilo fisso su un runner CI, oppure un device farm. Confrontare run su macchine diverse genera falsi positivi.

    Lo schema della pipeline è:

    1. avvio emulatore Android con configurazione fissa;
    2. esecuzione del test integration_test in --profile;
    3. esecuzione del gate che legge il summary;
    4. upload dei file di timeline come artifact per l'analisi post-mortem.

    Fai girare il job su workflow_dispatch e sui merge verso main, non su ogni commit: i benchmark sono lenti e rumorosi sulle PR frequenti.

    # .github/workflows/perf.yml
    name: Performance
    on:
      push:
        branches: [main]
      workflow_dispatch:
    
    jobs:
      benchmark:
        runs-on: ubuntu-latest
        steps:
          - uses: actions/checkout@v4
          - uses: subosito/flutter-action@v2
            with: { channel: stable }
          - run: flutter pub get
          - name: Benchmark su emulatore
            uses: reactivecircus/android-emulator-runner@v2
            with:
              api-level: 34
              arch: x86_64
              profile: pixel_6
              disable-animations: false
              script: flutter test integration_test/scroll_perf_test.dart --profile
          - name: Performance gate
            run: flutter test test/perf_gate_test.dart
          - uses: actions/upload-artifact@v4
            if: always()
            with:
              name: timeline
              path: build/*timeline*.json

    Risultato atteso

    Ad ogni merge su main la CI produce le metriche di scorrimento e blocca il merge successivo in caso di regressione, archiviando le timeline come artifact.

  6. 6

    Intervenire sulle regressioni: dal numero alla causa

    Quando il gate fallisce, apri l'artifact *.timeline.json su ui.perfetto.dev e cerca i frame fuori budget.

    Se il problema è sul UI thread (build time):

    • riduci la profondità del sottoalbero ricostruito: estrai widget e usa const;
    • sposta i calcoli pesanti fuori da build (memoizzazione, compute/isolate);
    • verifica che i ValueNotifier/provider non facciano rebuild dell'intera pagina.

    Se il problema è sul raster thread:

    • elimina i saveLayer impliciti: Opacity → AnimatedOpacity solo dove serve, meglio ancora colori con alpha;
    • preferisci ClipRRect solo quando necessario, o usa BoxDecoration(borderRadius:);
    • usa RepaintBoundary per isolare porzioni che si ridisegnano spesso;
    • con Impeller gli shader sono compilati in anticipo, ma blur e ombre restano costosi: riduci blurRadius o pre-renderizza.

    Infine aggiungi al repo un file perf_budget.md con le soglie e la data di revisione: il budget è un contratto di squadra, non un dettaglio del CI.

    // Isola il repaint della card animata dal resto della lista
    RepaintBoundary(
      child: AnimatedBuilder(
        animation: controller,
        builder: (context, child) => Transform.scale(
          scale: controller.value,
          child: child, // child costante: non viene ricostruito
        ),
        child: const HeavyCard(),
      ),
    );

    Risultato atteso

    Dopo l'ottimizzazione il nuovo run di benchmark mostra p90 di build e raster sotto budget e il gate torna verde.

CondividiXLinkedInFacebookWhatsApp

Commenti (0)

Ancora nessun commento. Inizia tu!