Il problema che quasi nessuno testa

Un utente sta compilando un form nella tua app, riceve una chiamata, apre la fotocamera, risponde a un messaggio. Dieci minuti dopo torna nella tua app toccando l'icona nel multitasking e... si ritrova sulla home, con il form vuoto.

Non è un bug del tuo codice: Android (e in misura minore iOS) termina i processi in background quando ha bisogno di memoria. Quando l'utente rientra, il sistema ricrea l'app da zero ma si aspetta che la UI appaia esattamente come l'aveva lasciata. Questo meccanismo si chiama state restoration e Flutter lo supporta nativamente dalla versione 1.22, anche se resta una delle funzionalità più ignorate del framework.

Attenzione: la state restoration non è persistenza dei dati. I dati di dominio (prodotti, messaggi, profili) vanno su un database locale. Qui parliamo di stato effimero della UI: la tab selezionata, la posizione di scroll, il testo digitato, lo step di un wizard, la rotta corrente.

Come riprodurre il problema in 30 secondi

Prima di scrivere codice, impara a testarlo. Su Android:

  1. Opzioni sviluppatore → Non mantenere le attività ("Don't keep activities") → ON
  2. Apri la tua app, naviga in profondità, metti dell'input
  3. Vai in home, apri un'altra app, torna indietro

Se l'app riparte dalla schermata iniziale, non hai la restoration attiva. In alternativa, da terminale:

adb shell am kill it.tuodominio.tuaapp

Su iOS il sistema è più conservativo, ma lo stesso meccanismo entra in gioco quando il sistema rimuove l'app dalla memoria (non quando l'utente la chiude manualmente con lo swipe: in quel caso lo stato viene volutamente scartato su entrambe le piattaforme).

Attivare la restoration: una riga

Tutto parte dal restorationScopeId sulla root dell'app. Senza questo, nessun meccanismo di restore è attivo.

MaterialApp(
  restorationScopeId: 'app',
  home: const HomePage(),
);

Impostandolo ottieni subito due vantaggi gratuiti:

  • il Navigator radice ripristina lo stack delle rotte dichiarate (con le dovute accortezze, vedi sotto);
  • i widget che espongono un restorationId iniziano a funzionare.

Molti widget del framework accettano già un restorationId:

ListView.builder(
  restorationId: 'lista_prodotti', // ripristina l'offset di scroll
  itemCount: prodotti.length,
  itemBuilder: (context, i) => ProductTile(prodotti[i]),
);

TextField(
  restorationId: 'campo_ricerca', // ripristina testo e selezione
  controller: _controller,
);

Funziona anche con PageView, TabBarView, SingleChildScrollView, CupertinoTabScaffold e simili. È il frutto migliore a portata di mano: aggiungere un restorationId alle liste principali costa pochi minuti.

RestorationMixin: ripristinare lo stato dei tuoi widget

Per lo stato custom dentro uno StatefulWidget serve il RestorationMixin. Il pattern è sempre lo stesso: sostituisci i campi normali con Restorable properties e le registri in restoreState.

class CheckoutPage extends StatefulWidget {
  const CheckoutPage({super.key});

  @override
  State<CheckoutPage> createState() => _CheckoutPageState();
}

class _CheckoutPageState extends State<CheckoutPage> with RestorationMixin {
  // Al posto di: int _step = 0;
  final RestorableInt _step = RestorableInt(0);
  final RestorableBool _accettaTermini = RestorableBool(false);
  final RestorableTextEditingController _note = RestorableTextEditingController();

  @override
  String? get restorationId => 'checkout_page';

  @override
  void restoreState(RestorationBucket? oldBucket, bool initialRestore) {
    registerForRestoration(_step, 'step');
    registerForRestoration(_accettaTermini, 'termini');
    registerForRestoration(_note, 'note');
  }

  @override
  void dispose() {
    _step.dispose();
    _accettaTermini.dispose();
    _note.dispose();
    super.dispose();
  }

  void _avanti() => setState(() => _step.value++);

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: Text('Step ${_step.value + 1} di 3')),
      body: Column(
        children: [
          TextField(controller: _note.value),
          CheckboxListTile(
            value: _accettaTermini.value,
            onChanged: (v) => setState(() => _accettaTermini.value = v ?? false),
            title: const Text('Accetto i termini'),
          ),
          FilledButton(onPressed: _avanti, child: const Text('Avanti')),
        ],
      ),
    );
  }
}

Punti chiave da ricordare:

  • restorationId deve essere univoco nel suo scope: se la stessa pagina compare più volte (per esempio in tab diverse) usa id differenti o avvolgi in un RestorationScope.
  • le Restorable properties vanno sempre dispose(): sono ChangeNotifier.
  • restoreState viene chiamato anche al primo avvio, con initialRestore == true. Serve per inizializzare, non solo per ripristinare.
  • modificare .value notifica automaticamente il framework che lo stato va riserializzato, ma non ricostruisce il widget: il setState serve ancora.

Le classi pronte all'uso sono numerose: RestorableInt, RestorableDouble, RestorableBool, RestorableString, RestorableNum, RestorableDateTime, RestorableEnum<T>, i loro equivalenti nullable (RestorableIntN, RestorableStringN...), RestorableTextEditingController e RestorableChangeNotifier.

Restorable personalizzate per i tuoi modelli

Se devi ripristinare un oggetto tuo, estendi RestorableValue<T>. I dati devono essere serializzabili dallo StandardMessageCodec (numeri, stringhe, booleani, liste e mappe di questi tipi).

class Filtro {
  const Filtro({required this.categoria, required this.prezzoMax});
  final String categoria;
  final double prezzoMax;
}

class RestorableFiltro extends RestorableValue<Filtro> {
  RestorableFiltro(this._default);
  final Filtro _default;

  @override
  Filtro createDefaultValue() => _default;

  @override
  void didUpdateValue(Filtro? oldValue) {
    // Avvisa il framework che lo stato serializzato è cambiato
    notifyListeners();
  }

  @override
  Filtro fromPrimitives(Object? data) {
    final map = data! as Map<Object?, Object?>;
    return Filtro(
      categoria: map['categoria']! as String,
      prezzoMax: map['prezzoMax']! as double,
    );
  }

  @override
  Object toPrimitives() => <String, Object>{
        'categoria': value.categoria,
        'prezzoMax': value.prezzoMax,
      };
}

Ripristinare la navigazione

Se l'utente era tre schermate in profondità, riportarlo alla home è comunque una sconfitta. Con il Navigator imperativo si usano le varianti restorable*, che richiedono un builder statico o top-level (non può essere una closure, perché deve essere ricostruibile dopo il riavvio del processo):

class _HomeState extends State<Home> with RestorationMixin {
  @override
  String? get restorationId => 'home';

  @override
  void restoreState(RestorationBucket? oldBucket, bool initialRestore) {}

  static Route<void> _dettaglioRoute(BuildContext context, Object? arguments) {
    return MaterialPageRoute<void>(
      builder: (_) => DettaglioPage(id: arguments! as String),
    );
  }

  void _apriDettaglio(String id) {
    Navigator.of(context).restorablePush(_dettaglioRoute, arguments: id);
  }
}

Esistono anche restorablePushNamed, restorablePushReplacement e RestorableRouteFuture per gestire il valore di ritorno di una rotta (tipico dei dialog che restituiscono una scelta).

Se usi il routing dichiarativo con go_router, il discorso si semplifica: basta passare un restorationScopeId al router e usare pagine con restorationId.

final router = GoRouter(
  restorationScopeId: 'router',
  routes: [
    GoRoute(
      path: '/dettaglio/:id',
      pageBuilder: (context, state) => MaterialPage(
        restorationId: 'dettaglio_${state.pathParameters['id']}',
        child: DettaglioPage(id: state.pathParameters['id']!),
      ),
    ),
  ],
);

MaterialApp.router(
  restorationScopeId: 'app',
  routerConfig: router,
);

Testare la restoration nei widget test

La parte più sottovalutata: puoi verificare il comportamento senza toccare un dispositivo reale. WidgetTester espone metodi dedicati.

testWidgets('lo step del checkout sopravvive al restore', (tester) async {
  await tester.pumpWidget(const MyApp());

  await tester.tap(find.text('Avanti'));
  await tester.pumpAndSettle();
  expect(find.text('Step 2 di 3'), findsOneWidget);

  // Simula la distruzione e ricreazione del processo
  await tester.restartAndRestore();

  expect(find.text('Step 2 di 3'), findsOneWidget);
});

Per scenari più complessi puoi catturare i dati e riapplicarli manualmente:

final data = await tester.getRestorationData();
await tester.restartAndRestore();
await tester.restoreFrom(data);

Limiti e best practice

  • Tieni i payload piccoli. Su Android lo stato finisce nel Bundle dell'Activity: superare il limite della transazione Binder (circa 1 MB complessivo) provoca una TransactionTooLargeException. Mai salvare liste di risultati, immagini in base64 o risposte JSON intere: salva gli identificativi e ricarica i dati dal repository.
  • Separa i ruoli. Lo stato di dominio va in database o cache (Drift, Isar, Hive); la restoration serve solo a ricostruire il contesto visivo.
  • Non salvare dati sensibili. Il bundle di sistema non è uno storage cifrato: token e credenziali restano in flutter_secure_storage.
  • Attenzione ai gestori di stato globali. Provider, Riverpod o BLoC vivono fuori dall'albero dei widget: dopo il kill del processo ripartono vuoti. Puoi combinare le due cose registrando nel bucket di restoration i parametri minimi (id selezionato, filtro attivo) e lasciando che il provider ricarichi il resto all'avvio.
  • Procedi per priorità. Non serve restaurare tutto: concentrati su form lunghi, wizard multi-step, flussi di checkout, liste con scroll profondo e sul percorso di navigazione.

Conclusione

La state restoration è uno di quei dettagli che l'utente non nota mai quando funziona e che genera recensioni negative quando manca. Il costo di ingresso è bassissimo: restorationScopeId sulla MaterialApp, qualche restorationId sulle liste e sui campi di testo, e il RestorationMixin sulle due o tre schermate dove perdere il lavoro dell'utente fa più male. Attiva "Non mantenere le attività" sul tuo device di sviluppo e lascialo acceso: scoprirai quanti flussi della tua app non sopravvivono a una semplice telefonata.