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
OutOfMemoryo terminazioni improvvise su iOS; - eccezioni del tipo
A ChangeNotifier was used after being disposedosetState() 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.
- Avvia l'app in modalità profile (
flutter run --profile): in debug i numeri sono falsati dal codice di assert e dal JIT. - Apri DevTools e vai nella tab Memory.
- Porta l'app nella schermata di partenza (per esempio la home) e premi Take heap snapshot.
- Entra nella schermata sospetta e torna indietro. Ripeti il ciclo 5–10 volte.
- Premi il pulsante GC per forzare una garbage collection, poi scatta un secondo snapshot.
- 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 HeapeRSS. 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
- Prevenzione: attiva i lint e usa widget che gestiscono da soli le sottoscrizioni.
- Protezione: abilita leak_tracker sui widget test delle schermate principali; diventa una rete di sicurezza contro le regressioni.
- 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.
- 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.