Scovare e prevenire i memory leak in Flutter con DevTools e leak_tracker

Foto di Brecht Corbeel su Unsplash

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

Scovare e prevenire i memory leak in Flutter con DevTools e leak_tracker

Un'app Flutter può sembrare perfetta in debug e poi degradare dopo venti minuti d'uso: frame rate che cala, consumo di RAM che cresce a ogni navigazione, crash OOM sui dispositivi Android di fascia media. Nella quasi totalità dei casi la causa non è il framework, ma oggetti che restano referenziati più a lungo del necessario: AnimationController non dismessi, StreamSubscription mai cancellate, listener registrati su ChangeNotifier globali, closure che catturano un BuildContext o un intero State.

In Dart non esiste un free(): il garbage collector libera solo ciò che non è più raggiungibile. Un leak in Flutter è quindi sempre un problema di grafo di riferimenti. La buona notizia è che oggi abbiamo strumenti di prima classe per vederlo: la vista Memory di DevTools con diff degli snapshot e retaining path, e il pacchetto leak_tracker che Flutter usa internamente e che possiamo attivare nei nostri widget test per far fallire la build quando un oggetto non viene dismesso.

In questo tutorial costruiremo un caso di leak realistico, lo isoleremo con DevTools, lo automatizzeremo in test, lo correggeremo con pattern riutilizzabili e infine renderemo il controllo parte della pipeline CI. Serve dimestichezza con StatefulWidget, stream e ciclo di vita dei widget.

  1. 1

    Capire cosa è davvero un leak in Flutter e preparare il progetto

    Prima di aprire DevTools serve un modello mentale preciso. In Flutter distinguiamo tre situazioni:

    1. Not disposed: un oggetto Disposable (es. AnimationController, TextEditingController, FocusNode, ScrollController) viene raccolto dal GC senza che sia stato chiamato dispose(). Non è sempre un leak di memoria, ma è quasi sempre un leak di risorse (ticker attivi, listener nativi).
    2. Not GCed / late GC: dispose() è stato chiamato, ma l'oggetto resta raggiungibile da una radice (static, singleton, listener non rimosso) e quindi non viene mai liberato. Questo è il leak classico che fa crescere la RAM.
    3. Crescita fisiologica: cache (ImageCache, cache di rete) che crescono fino a un limite. Non sono leak, ma vanno configurate.

    Il bersaglio tipico è l'oggetto State: se un leak trattiene un State, trattiene l'intero sottoalbero di element e render object, quindi anche immagini decodificate. Un singolo leak su una schermata di dettaglio può costare decine di MB.

    Aggiungi le dipendenze di sviluppo per il tracciamento automatico (le useremo dal passo 4) e assicurati di poter lanciare l'app in profile mode: in debug i numeri della memoria sono inquinati dalle asserzioni e dal codice non ottimizzato.

    # pubspec.yaml
    dev_dependencies:
      flutter_test:
        sdk: flutter
      # tracciamento leak nei widget test
      leak_tracker: ^10.0.0
      leak_tracker_flutter_testing: ^3.0.0
    
    # Verifica l'allineamento delle versioni con il canale Flutter in uso:
    #   flutter pub get
    #   flutter pub deps | grep leak_tracker
    
    # Esecuzione in profile mode per il profiling di memoria:
    #   flutter run --profile --track-widget-creation

    Risultato atteso

    Il progetto compila con le dipendenze di leak tracking e l'app parte in profile mode mostrando l'URL del DevTools nella console.

  2. 2

    Riprodurre un leak deterministico e misurabile

    Per imparare a leggere gli strumenti serve un leak controllato. Creiamo una schermata di dettaglio che commette tre errori molto comuni nel mondo reale:

    • si iscrive a uno Stream globale senza cancellare la StreamSubscription;
    • registra un listener su un singleton (AppBus) senza rimuoverlo in dispose();
    • memorizza se stessa (il State) in una mappa statica usata come "cache di comodo".

    Ogni volta che apri e chiudi la schermata, il State resta vivo e con esso l'albero di widget, i controller e le immagini decodificate. È esattamente lo scenario che produce la crescita lineare di RAM a ogni push/pop.

    Attenzione al punto più subdolo: anche il solo _bus.addListener(_onEvent) basta. _onEvent è un tear-off di un metodo di istanza, quindi la closure cattura this, cioè il State. Il singleton, che vive per tutta la durata dell'app, diventa una radice GC che trattiene la schermata.

    import 'dart:async';
    import 'package:flutter/material.dart';
    
    /// Singleton che vive per tutta l'app: ottima radice per un leak.
    class AppBus extends ChangeNotifier {
      AppBus._();
      static final AppBus instance = AppBus._();
    
      final _events = StreamController<String>.broadcast();
      Stream<String> get events => _events.stream;
    
      void emit(String e) {
        _events.add(e);
        notifyListeners();
      }
    }
    
    /// "Cache" statica: altro classico anti-pattern.
    final Map<int, Object> leakyCache = <int, Object>{};
    
    class LeakyDetailPage extends StatefulWidget {
      const LeakyDetailPage({super.key, required this.id});
      final int id;
    
      @override
      State<LeakyDetailPage> createState() => _LeakyDetailPageState();
    }
    
    class _LeakyDetailPageState extends State<LeakyDetailPage>
        with SingleTickerProviderStateMixin {
      late final AnimationController _controller;
      StreamSubscription<String>? _sub;
      String _last = '-';
    
      @override
      void initState() {
        super.initState();
        _controller = AnimationController(
          vsync: this,
          duration: const Duration(seconds: 1),
        )..repeat();
    
        // LEAK 1: subscription mai cancellata.
        _sub = AppBus.instance.events.listen((e) {
          if (mounted) setState(() => _last = e);
        });
    
        // LEAK 2: listener su singleton mai rimosso (cattura `this`).
        AppBus.instance.addListener(_onBusChanged);
    
        // LEAK 3: lo State finisce in una mappa statica.
        leakyCache[widget.id] = this;
      }
    
      void _onBusChanged() {/* ... */}
    
      @override
      void dispose() {
        // Nota: manca tutto il cleanup, e persino _controller.dispose().
        super.dispose();
      }
    
      @override
      Widget build(BuildContext context) {
        return Scaffold(
          appBar: AppBar(title: Text('Dettaglio ${widget.id}')),
          body: Center(
            child: RotationTransition(
              turns: _controller,
              child: Text('Ultimo evento: $_last'),
            ),
          ),
        );
      }
    }

    Risultato atteso

    Aprendo e chiudendo più volte la schermata l'app continua a funzionare, ma il consumo di memoria cresce in modo monotono ad ogni ciclo.

  3. 3

    Isolare il leak con DevTools: snapshot, diff e retaining path

    Ora la parte investigativa. Avvia l'app in profile mode e apri DevTools sulla scheda Memory.

    Procedura rigorosa (il "protocollo dei tre snapshot"):

    1. Porta l'app in uno stato stabile (home screen) e premi GC manuale, poi Take heap snapshot: è la tua baseline.
    2. Esegui N volte identiche l'azione sospetta (es. 10 push + pop della schermata di dettaglio). Più alto è N, più il segnale emerge dal rumore.
    3. Torna alla home, premi di nuovo GC e scatta il secondo snapshot. Usa il Diff tra i due snapshot.

    Nella tabella di diff ordina per instances delta: se vedi _LeakyDetailPageState con delta +10, il verdetto è immediato. Seleziona la classe e apri il pannello Retaining path: DevTools mostra la catena di riferimenti dalla radice GC all'oggetto, ad esempio AppBus (static) → _listeners → closure → _LeakyDetailPageState. Quella catena è il bug: il fix consiste nel tagliarne un anello.

    Suggerimenti operativi:

    • Attiva Allocation Tracing sulla classe sospetta per avere lo stack trace del punto di allocazione.
    • Guarda anche la sezione Dart / RSS del grafico: se il Dart heap è stabile ma la RSS cresce, il sospetto si sposta su memoria nativa (immagini decodificate, plugin, texture).
    • Puoi loggare gli eventi di creazione/dispose dal codice agganciandoti a FlutterMemoryAllocations, utile quando non puoi tenere DevTools aperto (es. su dispositivo di un tester).
    import 'package:flutter/foundation.dart';
    import 'package:flutter/material.dart';
    
    /// Conta creazioni e dispose degli oggetti "disposable" del framework.
    /// Da attivare SOLO in profile/debug: ha un costo non trascurabile.
    void installAllocationProbe() {
      if (kReleaseMode) return;
    
      final alive = <String, int>{};
    
      FlutterMemoryAllocations.instance.addListener((event) {
        if (event is ObjectCreated) {
          alive.update(event.object.runtimeType.toString(), (v) => v + 1,
              ifAbsent: () => 1);
        } else if (event is ObjectDisposed) {
          alive.update(event.object.runtimeType.toString(), (v) => v - 1,
              ifAbsent: () => -1);
        }
      });
    
      // Stampa periodica del bilancio creati/dismessi.
      Stream<void>.periodic(const Duration(seconds: 10)).listen((_) {
        final sospetti = alive.entries.where((e) => e.value > 0).toList()
          ..sort((a, b) => b.value.compareTo(a.value));
        debugPrint('--- Oggetti vivi (creati - dismessi) ---');
        for (final e in sospetti.take(10)) {
          debugPrint('${e.key}: ${e.value}');
        }
      });
    }
    
    void main() {
      WidgetsFlutterBinding.ensureInitialized();
      installAllocationProbe();
      runApp(const MyApp());
    }

    Risultato atteso

    Nel diff degli snapshot compaiono N istanze residue di _LeakyDetailPageState e il retaining path indica chiaramente il singleton AppBus come radice che le trattiene.

  4. 4

    Automatizzare la rilevazione con leak_tracker nei widget test

    Trovare un leak a mano è utile una volta; evitarne la ricomparsa richiede un test. leak_tracker_flutter_testing consente di attivare il tracciamento per tutti i test della suite tramite il file speciale test/flutter_test_config.dart, che il test runner esegue automaticamente prima del main dei test.

    Il tracker funziona così: intercetta creazione e dispose() degli oggetti strumentati dal framework (controller, notifier, ecc.), poi forza il GC alla fine del test e verifica due invarianti:

    • allNotDisposed: nessun oggetto raccolto senza dispose();
    • allNotGCed: nessun oggetto dismesso ma ancora raggiungibile.

    Scrivi quindi un test che monta la schermata, la smonta e lascia che sia il tracker a emettere il verdetto. Nel test puoi anche verificare manualmente la "radice" con una WeakReference, tecnica preziosa per oggetti applicativi non strumentati dal framework (repository, controller custom, blocchi di logica).

    // test/flutter_test_config.dart
    import 'dart:async';
    import 'package:leak_tracker_flutter_testing/leak_tracker_flutter_testing.dart';
    
    Future<void> testExecutable(FutureOr<void> Function() testMain) async {
      LeakTesting.enable();
      LeakTracking.warnForUnsupportedPlatforms = false;
    
      LeakTesting.settings = LeakTesting.settings
          // Ignora gli oggetti creati dagli helper di test (es. pumpWidget).
          .withIgnoredAll(createdByTestHelpers: true)
          .withTrackedAll();
    
      await testMain();
    }

    Risultato atteso

    Lanciando `flutter test`, i test che lasciano oggetti non dismessi falliscono con un report che elenca classe, numero di istanze e stack di creazione.

  5. 5

    Il test di non-regressione: WeakReference e forzatura del GC

    Il tracker copre gli oggetti del framework, ma spesso il leak riguarda classi tue. Il pattern definitivo è: crea l'oggetto, mantienine solo una WeakReference, smonta il widget, forza la garbage collection e verifica che il target sia diventato null.

    Per forzare il GC in un test si usa leak_tracker: await forceGC() esegue più cicli di raccolta attendendo tra uno e l'altro, evitando i falsi negativi tipici del GC generazionale. Evita assolutamente di trattenere il riferimento forte in una variabile locale del test: Dart potrebbe mantenerlo vivo fino a fine scope, rendendo il test instabile. Usa una funzione dedicata che restituisca solo la WeakReference.

    Questo test, aggiunto alla suite, fallirà finché la schermata resta "leaky" e passerà dopo il fix del passo successivo: è la rete di sicurezza che vuoi in CI.

    import 'package:flutter/material.dart';
    import 'package:flutter_test/flutter_test.dart';
    import 'package:leak_tracker/leak_tracker.dart';
    
    import 'package:my_app/leaky_detail_page.dart';
    
    Future<WeakReference<Object>> _mountAndCapture(WidgetTester tester) async {
      await tester.pumpWidget(const MaterialApp(home: LeakyDetailPage(id: 1)));
      await tester.pump(const Duration(milliseconds: 16));
    
      final state = tester.state(find.byType(LeakyDetailPage));
      final ref = WeakReference<Object>(state);
    
      // Smonta l'albero: dopo questo punto nulla dovrebbe trattenere lo State.
      await tester.pumpWidget(const MaterialApp(home: SizedBox.shrink()));
      await tester.pumpAndSettle();
      return ref;
    }
    
    void main() {
      testWidgets('la pagina di dettaglio non viene trattenuta dopo l\'unmount',
          (tester) async {
        final ref = await _mountAndCapture(tester);
    
        await forceGC(fullGcCycles: 3);
    
        expect(
          ref.target,
          isNull,
          reason: 'Lo State è ancora raggiungibile: controlla listener, '
              'subscription e cache statiche.',
        );
      });
    }

    Risultato atteso

    Il test fallisce con il messaggio personalizzato finché il leak è presente: hai ora una prova riproducibile e automatica.

  6. 6

    Correggere: pattern di dispose a prova di dimenticanza

    La correzione puntuale è ovvia (cancellare subscription, rimuovere listener, svuotare la cache). Il vero obiettivo però è rendere strutturalmente difficile dimenticarsene. Introduciamo un mixin AutoDispose che centralizza il cleanup: ogni risorsa viene registrata al momento della creazione e liberata in ordine inverso nel dispose().

    Regole di progettazione che eliminano intere classi di leak:

    • Chi crea, dismette: se un widget riceve un controller dall'esterno, non deve dismetterlo (e viceversa). Documentalo nell'API.
    • Niente cache statiche di oggetti con ciclo di vita UI: se serve una cache, usa chiavi/valori dati, mai State, BuildContext o widget.
    • Mai catturare BuildContext in callback a lunga vita: cattura solo i valori che ti servono, e proteggi con if (!mounted) return; dopo ogni await.
    • Listener globali con scope: preferisci stream con StreamSubscription gestita al addListener su singleton; se usi ChangeNotifier, accoppia sempre addListener/removeListener nello stesso file e nella stessa classe.
    • Chiudi i StreamController che possiedi, anche quelli broadcast.
    import 'dart:async';
    import 'package:flutter/material.dart';
    
    /// Centralizza il cleanup: tutto ciò che registri viene liberato in dispose().
    mixin AutoDispose<T extends StatefulWidget> on State<T> {
      final List<VoidCallback> _cleanups = <VoidCallback>[];
    
      /// Registra una funzione di cleanup arbitraria.
      void onDispose(VoidCallback cleanup) => _cleanups.add(cleanup);
    
      /// Ascolta uno stream cancellando automaticamente la subscription.
      StreamSubscription<E> listenTo<E>(
        Stream<E> stream,
        void Function(E event) onData, {
        Function? onError,
      }) {
        final sub = stream.listen(onData, onError: onError);
        onDispose(sub.cancel);
        return sub;
      }
    
      /// Registra un listener su un Listenable rimuovendolo automaticamente.
      void bindListener(Listenable listenable, VoidCallback listener) {
        listenable.addListener(listener);
        onDispose(() => listenable.removeListener(listener));
      }
    
      /// Registra un oggetto con dispose() (controller, notifier, focus node...).
      C own<C extends ChangeNotifier>(C notifier) {
        onDispose(notifier.dispose);
        return notifier;
      }
    
      @override
      void dispose() {
        for (final cleanup in _cleanups.reversed) {
          cleanup();
        }
        _cleanups.clear();
        super.dispose();
      }
    }
    
    class SafeDetailPage extends StatefulWidget {
      const SafeDetailPage({super.key, required this.id});
      final int id;
    
      @override
      State<SafeDetailPage> createState() => _SafeDetailPageState();
    }
    
    class _SafeDetailPageState extends State<SafeDetailPage>
        with SingleTickerProviderStateMixin, AutoDispose {
      late final AnimationController _controller;
      String _last = '-';
    
      @override
      void initState() {
        super.initState();
    
        _controller = own(AnimationController(
          vsync: this,
          duration: const Duration(seconds: 1),
        ))
          ..repeat();
    
        listenTo<String>(AppBus.instance.events, (e) {
          if (!mounted) return;
          setState(() => _last = e);
        });
    
        bindListener(AppBus.instance, _onBusChanged);
      }
    
      void _onBusChanged() {/* ... */}
    
      @override
      Widget build(BuildContext context) {
        return Scaffold(
          appBar: AppBar(title: Text('Dettaglio ${widget.id}')),
          body: Center(
            child: RotationTransition(
              turns: _controller,
              child: Text('Ultimo evento: $_last'),
            ),
          ),
        );
      }
    }

    Risultato atteso

    Il test con WeakReference passa: dopo l'unmount lo State viene raccolto dal GC e il tracker non segnala oggetti non dismessi.

  7. 7

    Memoria nativa, cache immagini e controllo continuo in CI

    Risolto il Dart heap, resta la memoria nativa, spesso la voce più pesante su mobile. Le immagini decodificate vivono in ImageCache e nel heap nativo di Skia/Impeller: una lista con immagini a piena risoluzione può saturare la RAM anche senza alcun leak logico.

    Azioni concrete:

    • Ridimensiona in decodifica con cacheWidth/cacheHeight oppure ResizeImage: decodificare un JPEG 4000×3000 per mostrarlo a 300 px costa ~48 MB contro ~0,4 MB.
    • Taratura esplicita di imageCache.maximumSizeBytes in base al target di dispositivo; svuota la cache sugli eventi di memoria critica (didHaveMemoryPressure).
    • Evita di trattenere ui.Image manuali senza chiamare dispose(): sono memoria nativa pura, invisibile nel Dart heap.

    Infine, rendi il controllo permanente: esegui la suite di test (che ora include il leak tracking) in CI. Un leak reintrodotto fa fallire la pipeline prima della code review, non tre release dopo in produzione. Per le campagne periodiche, pianifica una sessione manuale di profiling in profile mode sulle schermate più trafficate, seguendo il protocollo dei tre snapshot del passo 3.

    import 'package:flutter/material.dart';
    
    class MemoryAwareApp extends StatefulWidget {
      const MemoryAwareApp({super.key, required this.child});
      final Widget child;
    
      @override
      State<MemoryAwareApp> createState() => _MemoryAwareAppState();
    }
    
    class _MemoryAwareAppState extends State<MemoryAwareApp>
        with WidgetsBindingObserver {
      @override
      void initState() {
        super.initState();
        WidgetsBinding.instance.addObserver(this);
    
        final cache = PaintingBinding.instance.imageCache;
        cache.maximumSizeBytes = 64 << 20; // 64 MB invece dei 100 di default
        cache.maximumSize = 200;           // numero massimo di immagini
      }
    
      @override
      void didHaveMemoryPressure() {
        PaintingBinding.instance.imageCache.clearLiveImages();
        PaintingBinding.instance.imageCache.clear();
      }
    
      @override
      void dispose() {
        WidgetsBinding.instance.removeObserver(this);
        super.dispose();
      }
    
      @override
      Widget build(BuildContext context) => widget.child;
    }
    
    // Decodifica ridimensionata: enorme risparmio di memoria nativa.
    Widget thumbnail(String url) => Image.network(
          url,
          width: 150,
          height: 150,
          fit: BoxFit.cover,
          cacheWidth: 300, // 2x per schermi ad alta densità
        );

    Risultato atteso

    Il picco di memoria sulle schermate con immagini si riduce sensibilmente e la CI fallisce automaticamente se un nuovo commit reintroduce oggetti non dismessi.

CondividiXLinkedInFacebookWhatsApp

Commenti (0)

Ancora nessun commento. Inizia tu!