Perché un'app Flutter può "perdere" memoria

Dart ha un garbage collector, quindi molti sviluppatori danno per scontato che i memory leak non siano un problema. In realtà il GC libera solo gli oggetti non più raggiungibili: se un Timer, uno StreamSubscription o un listener globale mantiene un riferimento a uno State già smontato, quell'oggetto (e con lui l'intero sottoalbero di elementi, controller e immagini a cui punta) resta vivo per sempre.

I sintomi tipici sono:

  • la memoria cresce ad ogni navigazione avanti/indietro tra due schermate;
  • dopo qualche minuto d'uso l'app diventa scattosa, soprattutto su dispositivi Android di fascia bassa;
  • crash OutOfMemory o terminazioni improvvise su iOS;
  • eccezioni del tipo A ChangeNotifier was used after being disposed o setState() called after dispose().

In questo articolo vediamo le cause ricorrenti, come individuarle con DevTools e come trasformare la caccia al leak in un controllo automatico nei test grazie a leak_tracker, ormai integrato in flutter_test.

Le cause più comuni

1. Controller e notifier non chiusi

È il caso più frequente. TextEditingController, AnimationController, ScrollController, ChangeNotifier, ValueNotifier, FocusNode, TabController: tutti questi oggetti registrano listener e vanno chiusi esplicitamente.

class _SearchFieldState extends State<SearchField> {
  late final TextEditingController _controller;
  late final FocusNode _focusNode;

  @override
  void initState() {
    super.initState();
    _controller = TextEditingController();
    _focusNode = FocusNode();
  }

  @override
  void dispose() {
    // Ordine inverso rispetto alla creazione: prima i dipendenti.
    _controller.dispose();
    _focusNode.dispose();
    super.dispose();
  }
}

Attenzione: se passi un controller creato dal widget padre, non deve essere il figlio a chiuderlo. La regola è semplice: chi crea l'oggetto lo distrugge.

2. Sottoscrizioni a stream e timer dimenticati

class _PositionViewState extends State<PositionView> {
  StreamSubscription<Position>? _sub;
  Timer? _timer;

  @override
  void initState() {
    super.initState();
    _sub = positionStream.listen(_onPosition);
    _timer = Timer.periodic(const Duration(seconds: 5), (_) => _refresh());
  }

  @override
  void dispose() {
    _sub?.cancel();
    _timer?.cancel();
    super.dispose();
  }
}

Un Timer.periodic non cancellato è un leak doppio: consuma CPU e tiene in vita la closure, quindi lo State, quindi l'intero albero.

3. Listener registrati su oggetti a vita lunga

Questo è il caso più insidioso, perché il riferimento parte da un singleton e arriva al widget.

// ❌ Il servizio è un singleton: il listener sopravvive alla pagina.
SessionService.instance.addListener(_onSessionChanged);

// ✅ Rimuovilo sempre
@override
void dispose() {
  SessionService.instance.removeListener(_onSessionChanged);
  super.dispose();
}

Lo stesso vale per WidgetsBinding.instance.addObserver(this), per i callback registrati su bus di eventi, per AppLifecycleListener e per i listener di package nativi (connettività, notifiche, Bluetooth).

4. Cache statiche senza limiti

Una static final Map<String, Uint8List> _cache = {} riempita con anteprime di immagini è un leak per definizione: nessuno la svuota mai. Se ti serve una cache, usa una struttura con capacità massima (LRU) oppure appoggiati a ImageCache, che Flutter gestisce già con limiti configurabili:

PaintingBinding.instance.imageCache
  ..maximumSize = 200            // numero di immagini
  ..maximumSizeBytes = 80 << 20; // 80 MB

5. Closure che catturano il BuildContext

Passare un BuildContext a un oggetto che vive più a lungo del widget (un service locator, un client HTTP, un singleton di navigazione) tiene in vita l'Element e tutto il suo sottoalbero. Passa invece i dati già estratti, o usa una GlobalKey<NavigatorState> dedicata.

Individuare un leak con DevTools

La procedura più affidabile è il diff tra snapshot, perché isola esattamente gli oggetti creati e mai liberati durante un ciclo di navigazione.

  1. Avvia l'app in modalità profile (flutter run --profile): in debug i numeri sono falsati dal codice di assert e dal JIT.
  2. Apri DevTools e vai nella tab Memory.
  3. Porta l'app nella schermata di partenza (per esempio la home) e premi Take heap snapshot.
  4. Entra nella schermata sospetta e torna indietro. Ripeti il ciclo 5–10 volte.
  5. Premi il pulsante GC per forzare una garbage collection, poi scatta un secondo snapshot.
  6. Apri la sezione Diff e confronta i due snapshot.

Se nella colonna delle differenze vedi 5 o 10 istanze in più di _DetailPageState, AnimationController o Image, hai trovato il leak. Selezionando la classe e poi una singola istanza, DevTools mostra il retaining path: la catena di riferimenti che impedisce al GC di liberare l'oggetto. È lì che troverai il listener o il timer colpevole.

Altri strumenti utili nella stessa pagina:

  • Trace instances: attiva il tracciamento delle allocazioni per classi specifiche e mostra lo stack trace di creazione.
  • Chart: il grafico di Dart Heap e RSS. Se la linea di base dopo ogni GC sale a gradini invece di tornare al valore iniziale, c'è una crescita non fisiologica.

Un dettaglio importante su Flutter: la memoria delle immagini decodificate non è nel Dart heap ma nella memoria nativa (RSS). Se l'RSS cresce mentre il Dart heap è stabile, il sospetto principale è la cache delle immagini o un ui.Image creato manualmente e mai dispose()-ato.

Automatizzare: leak_tracker nei test

Individuare un leak a mano è utile una volta; evitarlo per sempre è meglio. Flutter integra leak_tracker, che verifica durante i widget test che ogni oggetto "disposable" creato venga effettivamente disposto e poi raccolto dal GC.

Aggiungi la dipendenza:

dev_dependencies:
  flutter_test:
    sdk: flutter
  leak_tracker_flutter_testing: ^3.0.0

Puoi abilitare il tracciamento su tutta la suite con un file di configurazione 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();
  LeakTesting.settings = LeakTesting.settings.withTrackedAll();
  await testMain();
}

Oppure attivarlo test per test, utile quando introduci il controllo in un progetto esistente:

testWidgets(
  'la pagina dettaglio non perde memoria',
  experimentalLeakTesting: LeakTesting.settings.withTrackedAll(),
  (tester) async {
    await tester.pumpWidget(const MaterialApp(home: DetailPage(id: 1)));
    await tester.pumpAndSettle();

    // Smonta la pagina: tutti i controller devono essere disposti.
    await tester.pumpWidget(const MaterialApp(home: SizedBox.shrink()));
    await tester.pumpAndSettle();
  },
);

Se un AnimationController non viene chiuso, il test fallisce con un messaggio del tipo not disposed e lo stack trace del punto di creazione: informazione preziosa, molto più del generico "l'app consuma memoria".

Per i falsi positivi provenienti da package di terze parti puoi escludere classi specifiche:

LeakTesting.settings = LeakTesting.settings
    .withTrackedAll()
    .withIgnored(classes: const ['SomeThirdPartyController']);

Rendere i propri oggetti tracciabili

Se scrivi un package o una classe che gestisce risorse, puoi notificare al framework la creazione e la distruzione dell'oggetto, così leak_tracker e DevTools lo includono nei report:

import 'package:flutter/foundation.dart';

class AudioSessionController {
  AudioSessionController() {
    if (kFlutterMemoryAllocationsEnabled) {
      FlutterMemoryAllocations.instance.dispatchObjectCreated(
        library: 'my_package',
        className: '$AudioSessionController',
        object: this,
      );
    }
  }

  void dispose() {
    if (kFlutterMemoryAllocationsEnabled) {
      FlutterMemoryAllocations.instance.dispatchObjectDisposed(object: this);
    }
    // ... rilascio delle risorse
  }
}

Pattern difensivi che riducono i leak

Un mixin per non dimenticare il dispose

mixin DisposableBag<T extends StatefulWidget> on State<T> {
  final List<VoidCallback> _disposers = [];

  void autoDispose(VoidCallback disposer) => _disposers.add(disposer);

  @override
  void dispose() {
    for (final d in _disposers.reversed) {
      d();
    }
    _disposers.clear();
    super.dispose();
  }
}

Utilizzo:

@override
void initState() {
  super.initState();
  final sub = repository.watchItems().listen(_onItems);
  autoDispose(sub.cancel);

  final controller = ScrollController();
  autoDispose(controller.dispose);
}

Preferire i widget che gestiscono il ciclo di vita

StreamBuilder, ValueListenableBuilder, AnimatedBuilder e ListenableBuilder si iscrivono e si disiscrivono da soli: usarli al posto di addListener manuali elimina un'intera categoria di errori. Allo stesso modo, i provider di Riverpod o i Bloc creati tramite BlocProvider vengono chiusi automaticamente quando escono di scope.

Attenzione ai keepAlive e agli oggetti globali

Un provider marcato keepAlive o un singleton registrato in get_it non sono leak di per sé, ma diventano radici permanenti: qualsiasi cosa a cui puntano resta viva. Verifica che non conservino liste di risultati illimitate o riferimenti a schermate.

Lint che aiutano

Nel file analysis_options.yaml:

linter:
  rules:
    - close_sinks
    - cancel_subscriptions
    - use_build_context_synchronously

Non coprono tutti i casi (l'analizzatore non traccia i riferimenti a runtime), ma intercettano gli errori più banali già in fase di scrittura.

Un flusso di lavoro consigliato

  1. Prevenzione: attiva i lint e usa widget che gestiscono da soli le sottoscrizioni.
  2. Protezione: abilita leak_tracker sui widget test delle schermate principali; diventa una rete di sicurezza contro le regressioni.
  3. Diagnosi: quando il consumo cresce in produzione, riproduci lo scenario in profile mode e usa il diff degli snapshot di DevTools per trovare il retaining path.
  4. Verifica: dopo la correzione, ripeti il ciclo di 10 navigazioni e controlla che il numero di istanze torni stabile.

I memory leak non sono un problema esotico riservato ai linguaggi senza GC: in Flutter nascono quasi sempre da una riga dimenticata nel dispose. La buona notizia è che gli strumenti per scovarli sono già nel framework e, con leak_tracker nei test, si può smettere di cercarli a mano.