State restoration in Flutter: ripristinare lo stato dell'app con RestorationMixin

Foto di ThisisEngineering su Unsplash

GuideAvanzato45 min Flutter 3.x

State restoration in Flutter: ripristinare lo stato dell'app con RestorationMixin

Su Android (e in misura minore su iOS) il sistema operativo può uccidere il processo della tua app quando resta in background e la memoria scarseggia. Quando l'utente torna nell'app, Android ricrea l'Activity: l'utente si aspetta di ritrovare la stessa schermata, gli stessi campi compilati e la stessa posizione di scroll, ma un'app Flutter non preparata riparte dalla home con tutto lo stato azzerato.

Flutter risolve questo problema con il framework di state restoration: un albero di RestorationBucket serializzati che il framework consegna al motore, il quale li passa alla piattaforma. Al riavvio i dati tornano indietro e i widget possono ricostruire il proprio stato prima del primo frame.

In questo tutorial avanzato vedremo come:

  • attivare la restoration con restorationScopeId e verificarla realmente sul dispositivo;
  • registrare proprietà con RestorationMixin e le RestorableProperty di serie;
  • rendere ripristinabili rotte e dialoghi con Navigator.restorablePush e RestorableRouteFuture;
  • scrivere una RestorableValue custom per modelli di dominio complessi;
  • coprire tutto con widget test usando tester.restartAndRestore().

Prerequisiti: buona conoscenza di StatefulWidget, Navigator 2.0/1.0 e dei widget test. Il codice è compatibile con Flutter 3.x.

  1. 1

    Attivare la restoration e riprodurre la terminazione del processo

    La state restoration è opt-in. Il primo passo è assegnare un restorationScopeId alla MaterialApp (o CupertinoApp): questo crea il bucket radice dal quale discendono tutti gli altri. Senza di esso, ogni RestorationMixin più in basso nell'albero resta inattivo e i dati non vengono mai serializzati.

    Per testare davvero il comportamento non basta mettere l'app in background: bisogna simulare la terminazione del processo da parte del sistema.

    Android (metodo consigliato):

    1. Impostazioni → Opzioni sviluppatore → attiva Non mantenere le attività ("Don't keep activities").
    2. Metti l'app in background (tasto Home) e riaprila dal task switcher.

    In alternativa, da terminale con l'app in background:

    adb shell am kill com.example.mia_app
    

    Attenzione: usare flutter run in modalità debug e killare l'app dall'IDE non riproduce lo scenario, perché il processo viene distrutto insieme al bundle di restoration. Usa build --profile o --release installate sul dispositivo.

    iOS: la restoration è supportata; in simulatore puoi usare Device → Trigger Memory Warning oppure verificare via test automatici (passo 6), che restano il modo più affidabile e ripetibile.

    import 'package:flutter/material.dart';
    
    void main() => runApp(const MyApp());
    
    class MyApp extends StatelessWidget {
      const MyApp({super.key});
    
      @override
      Widget build(BuildContext context) {
        return MaterialApp(
          // Radice dell'albero di restoration: senza questo id nulla viene salvato.
          restorationScopeId: 'app',
          title: 'Restoration Demo',
          theme: ThemeData(useMaterial3: true, colorSchemeSeed: Colors.indigo),
          home: const HomePage(restorationId: 'home'),
        );
      }
    }

    Risultato atteso

    L'app si avvia normalmente. Con "Non mantenere le attività" attivo, tornando nell'app il processo viene ricreato: al momento lo stato è ancora azzerato, ma l'infrastruttura di restoration è attiva.

  2. 2

    Registrare lo stato con RestorationMixin e le RestorableProperty

    RestorationMixin si applica alla classe State di uno StatefulWidget e richiede due membri:

    • restorationId: identificatore univoco tra i fratelli nello stesso bucket. Se è null la restoration per quel sottoalbero è disabilitata.
    • restoreState(oldBucket, initialRestore): qui si chiama registerForRestoration per ogni proprietà. Viene invocato dopo initState e prima del primo build, quindi lo stato è già ripristinato quando disegni la UI.

    Regole operative importanti:

    1. Le RestorableProperty vanno dichiarate come campi final e rilasciate in dispose().
    2. Non leggere il valore prima di registerForRestoration: prima della registrazione le proprietà espongono il valore di default.
    3. restoreState può essere chiamato più volte (es. quando il bucket cambia): deve essere idempotente, quindi limitati a registrare proprietà.
    4. Le proprietà pronte all'uso includono RestorableInt, RestorableDouble, RestorableBool, RestorableString, RestorableDateTime, RestorableEnum, RestorableTextEditingController e le varianti *N nullable (RestorableIntN, ...).

    Per TextField, ListView/SingleChildScrollView e TabBarView spesso basta passare direttamente un restorationId al widget: la posizione di scroll e il testo vengono gestiti dal framework.

    class HomePage extends StatefulWidget {
      const HomePage({super.key, this.restorationId});
    
      final String? restorationId;
    
      @override
      State<HomePage> createState() => _HomePageState();
    }
    
    class _HomePageState extends State<HomePage> with RestorationMixin {
      final RestorableInt _counter = RestorableInt(0);
      final RestorableBool _notifiche = RestorableBool(true);
      final RestorableTextEditingController _noteController =
          RestorableTextEditingController();
    
      @override
      String? get restorationId => widget.restorationId;
    
      @override
      void restoreState(RestorationBucket? oldBucket, bool initialRestore) {
        registerForRestoration(_counter, 'counter');
        registerForRestoration(_notifiche, 'notifiche');
        registerForRestoration(_noteController, 'note');
      }
    
      @override
      void dispose() {
        _counter.dispose();
        _notifiche.dispose();
        _noteController.dispose();
        super.dispose();
      }
    
      void _incrementa() => setState(() => _counter.value++);
    
      @override
      Widget build(BuildContext context) {
        return Scaffold(
          appBar: AppBar(title: const Text('Restoration Demo')),
          body: ListView(
            // Anche la posizione di scroll viene ripristinata.
            restorationId: 'home_list',
            padding: const EdgeInsets.all(16),
            children: [
              Text('Contatore: ${_counter.value}',
                  style: Theme.of(context).textTheme.headlineSmall),
              SwitchListTile(
                title: const Text('Notifiche'),
                value: _notifiche.value,
                onChanged: (v) => setState(() => _notifiche.value = v),
              ),
              TextField(
                controller: _noteController.value,
                restorationId: 'note_field',
                decoration: const InputDecoration(labelText: 'Note'),
              ),
              const SizedBox(height: 400),
            ],
          ),
          floatingActionButton: FloatingActionButton(
            onPressed: _incrementa,
            child: const Icon(Icons.add),
          ),
        );
      }
    }

    Risultato atteso

    Dopo aver incrementato il contatore, scritto una nota e scrollato, uccidendo il processo e riaprendo l'app ritrovi contatore, switch, testo e posizione di scroll esattamente come li avevi lasciati.

  3. 3

    Rendere ripristinabili rotte e dialoghi con restorablePush

    Se l'utente è su una schermata di dettaglio o ha un dialog aperto quando il processo viene ucciso, la restoration delle sole proprietà non basta: bisogna ripristinare anche lo stack di navigazione.

    Il Navigator espone le varianti restorablePush, restorablePushNamed, restorablePushReplacement, ecc. La regola chiave è che il route builder deve essere una funzione statica o top-level (non una closure che cattura lo scope): il framework ne salva il riferimento e la richiama al riavvio. Gli arguments devono essere primitivi serializzabili dal StandardMessageCodec (numeri, stringhe, bool, liste e mappe di questi tipi).

    Se usi rotte con nome, assegna un onGenerateRoute e usa restorablePushNamed: gli argomenti seguono le stesse regole.

    Quando devi recuperare il valore di ritorno di una rotta ripristinata (tipico dei picker), usa RestorableRouteFuture: registrala come una qualunque RestorableProperty e implementa onPresent (come aprire la rotta) e onComplete (cosa fare con il risultato).

    class _HomePageState extends State<HomePage> with RestorationMixin {
      final RestorableDateTime _dataSelezionata =
          RestorableDateTime(DateTime(2024, 1, 1));
    
      late final RestorableRouteFuture<DateTime?> _datePickerRoute =
          RestorableRouteFuture<DateTime?>(
        onComplete: _impostaData,
        onPresent: (NavigatorState navigator, Object? arguments) {
          return navigator.restorablePush(
            _datePickerRouteBuilder,
            arguments: _dataSelezionata.value.millisecondsSinceEpoch,
          );
        },
      );
    
      // DEVE essere statica: il framework la richiama dopo il riavvio.
      static Route<DateTime> _datePickerRouteBuilder(
        BuildContext context,
        Object? arguments,
      ) {
        return DialogRoute<DateTime>(
          context: context,
          builder: (context) => DatePickerDialog(
            restorationId: 'date_picker_dialog',
            initialDate: DateTime.fromMillisecondsSinceEpoch(arguments! as int),
            firstDate: DateTime(2020),
            lastDate: DateTime(2030),
          ),
        );
      }
    
      void _impostaData(DateTime? data) {
        if (data == null) return;
        setState(() => _dataSelezionata.value = data);
      }
    
      static Route<void> _dettaglioRouteBuilder(
        BuildContext context,
        Object? arguments,
      ) {
        return MaterialPageRoute<void>(
          builder: (_) => DettaglioPage(id: arguments! as int),
        );
      }
    
      void _apriDettaglio(int id) {
        Navigator.of(context).restorablePush(_dettaglioRouteBuilder, arguments: id);
      }
    
      @override
      String? get restorationId => widget.restorationId;
    
      @override
      void restoreState(RestorationBucket? oldBucket, bool initialRestore) {
        registerForRestoration(_dataSelezionata, 'data');
        registerForRestoration(_datePickerRoute, 'date_picker_route');
      }
    
      @override
      void dispose() {
        _dataSelezionata.dispose();
        _datePickerRoute.dispose();
        super.dispose();
      }
    
      // ... build()
    }

    Risultato atteso

    Aprendo la pagina di dettaglio o il date picker e uccidendo il processo, al ritorno nell'app la rotta (o il dialogo) è di nuovo aperta con gli stessi argomenti, e il valore scelto nel picker viene applicato correttamente.

  4. 4

    Creare una RestorableValue custom per i modelli di dominio

    Le proprietà di serie coprono i tipi primitivi, ma nelle app reali si salva spesso un oggetto: un filtro di ricerca, una selezione multipla, lo step di un wizard.

    Estendendo RestorableValue<T> devi implementare quattro membri:

    • createDefaultValue(): valore iniziale quando non c'è nulla da ripristinare;
    • fromPrimitives(Object? data): deserializzazione dal bucket;
    • toPrimitives(): serializzazione (solo tipi supportati dallo StandardMessageCodec);
    • didUpdateValue(T? oldValue): qui devi chiamare notifyListeners() quando il nuovo valore richiede un rebuild.

    Vincoli da rispettare:

    1. Mantieni il payload piccolo (indicativamente sotto qualche centinaio di KB): il bucket viaggia verso la piattaforma a ogni serializzazione. Non salvare liste di entità complete, salva id e ricarica i dati dalla cache/DB.
    2. Non salvare dati sensibili: il bundle viene gestito dal sistema operativo, usa flutter_secure_storage per i token.
    3. Se il modello cambia tra due versioni dell'app, gestisci la migrazione in fromPrimitives con un campo version e un fallback al default.

    Per oggetti che sono già Listenable (es. un controller) esiste anche RestorableListenable; per contenitori più complessi puoi comporre più proprietà in un unico RestorationMixin dedicato.

    import 'package:flutter/widgets.dart';
    
    enum Ordinamento { recenti, popolari, prezzo }
    
    @immutable
    class FiltroCatalogo {
      const FiltroCatalogo({
        this.query = '',
        this.ordinamento = Ordinamento.recenti,
        this.categorieIds = const <int>[],
      });
    
      final String query;
      final Ordinamento ordinamento;
      final List<int> categorieIds;
    
      FiltroCatalogo copyWith({
        String? query,
        Ordinamento? ordinamento,
        List<int>? categorieIds,
      }) =>
          FiltroCatalogo(
            query: query ?? this.query,
            ordinamento: ordinamento ?? this.ordinamento,
            categorieIds: categorieIds ?? this.categorieIds,
          );
    }
    
    class RestorableFiltroCatalogo extends RestorableValue<FiltroCatalogo> {
      @override
      FiltroCatalogo createDefaultValue() => const FiltroCatalogo();
    
      @override
      void didUpdateValue(FiltroCatalogo? oldValue) {
        // Il filtro è immutabile: qualunque cambio richiede un rebuild.
        notifyListeners();
      }
    
      @override
      FiltroCatalogo fromPrimitives(Object? data) {
        final map = (data as Map?)?.cast<String, Object?>();
        if (map == null || map['v'] != 1) return createDefaultValue();
        return FiltroCatalogo(
          query: map['query'] as String? ?? '',
          ordinamento: Ordinamento.values[(map['ordinamento'] as int?) ?? 0],
          categorieIds:
              (map['categorie'] as List?)?.cast<int>() ?? const <int>[],
        );
      }
    
      @override
      Object toPrimitives() => <String, Object?>{
            'v': 1,
            'query': value.query,
            'ordinamento': value.ordinamento.index,
            // Salviamo solo gli id, non le entità complete.
            'categorie': value.categorieIds,
          };
    }

    Risultato atteso

    Il filtro di ricerca viene serializzato come mappa di primitivi e ricostruito dopo il riavvio; se il formato salvato è di una versione precedente, si torna in sicurezza al filtro di default.

  5. 5

    Coordinare la restoration con lo state management e i dati remoti

    La restoration non è persistenza: il bundle sopravvive alla terminazione del processo ma non alla chiusura esplicita dell'app da parte dell'utente né a un reboot. Il pattern corretto è quindi:

    • restoration → stato effimero della UI: tab attiva, scroll, testo in bozza, filtri, rotta corrente, id dell'elemento aperto;
    • storage (Hive/SQLite/secure storage) → stato di dominio: sessione utente, dati scaricati, preferenze.

    Se usi Provider/Riverpod/BLoC, non tentare di serializzare il bloc intero: registra le chiavi minime nel RestorationMixin e usale per riallineare lo state manager subito dopo il ripristino. Il momento giusto è dopo restoreState, in initState/didChangeDependencies con un addPostFrameCallback, così eviti di leggere il valore prima della registrazione.

    Un pattern robusto: la pagina di dettaglio riceve solo l'id come argomento ripristinabile e ricarica i dati dal repository (che a sua volta legge dalla cache offline). Così il payload di restoration resta minuscolo e i dati mostrati sono sempre freschi.

    class _CatalogoPageState extends State<CatalogoPage> with RestorationMixin {
      final RestorableFiltroCatalogo _filtro = RestorableFiltroCatalogo();
      final RestorableIntN _dettaglioApertoId = RestorableIntN(null);
    
      @override
      String? get restorationId => 'catalogo';
    
      @override
      void restoreState(RestorationBucket? oldBucket, bool initialRestore) {
        registerForRestoration(_filtro, 'filtro');
        registerForRestoration(_dettaglioApertoId, 'dettaglio_id');
    
        if (initialRestore) {
          // Riallinea lo state manager DOPO il primo frame:
          // qui il valore ripristinato è già disponibile.
          WidgetsBinding.instance.addPostFrameCallback((_) {
            if (!mounted) return;
            context.read<CatalogoCubit>().applicaFiltro(_filtro.value);
          });
        }
      }
    
      void _onFiltroCambiato(FiltroCatalogo nuovo) {
        setState(() => _filtro.value = nuovo);
        context.read<CatalogoCubit>().applicaFiltro(nuovo);
      }
    
      @override
      void dispose() {
        _filtro.dispose();
        _dettaglioApertoId.dispose();
        super.dispose();
      }
    
      // ... build()
    }

    Risultato atteso

    Dopo il ripristino, la lista viene ricaricata dal repository applicando automaticamente il filtro salvato, senza che l'utente debba reimpostarlo.

  6. 6

    Testare la restoration in modo automatico con restartAndRestore

    Verificare a mano con "Non mantenere le attività" è utile una volta; in CI serve un test deterministico. WidgetTester offre tre API dedicate:

    • await tester.restartAndRestore(): distrugge e ricostruisce l'albero usando i dati di restoration correnti, esattamente come farebbe il sistema;
    • final data = await tester.getRestorationData(): cattura uno snapshot del bundle;
    • await tester.restoreFrom(data): ripristina l'app da uno snapshot precedente (utile per simulare "kill dopo N secondi" e ignorare le interazioni successive).

    Ricorda che nei test l'restorationScopeId deve essere presente: pompa direttamente la tua MaterialApp reale, non un widget isolato senza scope.

    Inserisci questi test nella pipeline CI insieme ai golden test: una regressione sulla restoration è silenziosa e difficilissima da notare durante lo sviluppo quotidiano.

    import 'package:flutter/material.dart';
    import 'package:flutter_test/flutter_test.dart';
    import 'package:mia_app/main.dart';
    
    void main() {
      testWidgets('il contatore e la nota sopravvivono al riavvio', (tester) async {
        await tester.pumpWidget(const MyApp());
    
        await tester.tap(find.byType(FloatingActionButton));
        await tester.tap(find.byType(FloatingActionButton));
        await tester.enterText(find.byType(TextField), 'bozza importante');
        await tester.pumpAndSettle();
    
        expect(find.text('Contatore: 2'), findsOneWidget);
    
        // Simula il kill del processo e il successivo ripristino.
        await tester.restartAndRestore();
    
        expect(find.text('Contatore: 2'), findsOneWidget);
        expect(find.text('bozza importante'), findsOneWidget);
      });
    
      testWidgets('la rotta di dettaglio viene ripristinata', (tester) async {
        await tester.pumpWidget(const MyApp());
    
        await tester.tap(find.text('Apri dettaglio'));
        await tester.pumpAndSettle();
        expect(find.byType(DettaglioPage), findsOneWidget);
    
        // Snapshot del bundle: tutto ciò che accade dopo verrà scartato.
        final TestRestorationData data = await tester.getRestorationData();
    
        await tester.tap(find.byTooltip('Back'));
        await tester.pumpAndSettle();
        expect(find.byType(DettaglioPage), findsNothing);
    
        await tester.restoreFrom(data);
        expect(find.byType(DettaglioPage), findsOneWidget);
      });
    }

    Risultato atteso

    Entrambi i test passano: lo stato dei widget e lo stack di navigazione vengono ricostruiti dopo `restartAndRestore()` e `restoreFrom()`, dando una rete di sicurezza in CI contro le regressioni.

  7. 7

    Checklist finale e insidie da evitare

    Prima di considerare la feature completa, verifica questi punti:

    1. restorationScopeId ovunque serve. Se una sotto-parte dell'app usa un Navigator annidato (es. tab con stack indipendenti), assegna un restorationScopeId anche a quel Navigator, altrimenti le sue rotte non verranno salvate.
    2. Id univoci tra fratelli. Due RestorationMixin fratelli con lo stesso restorationId provocano un'eccezione in debug: usa prefissi legati alla feature (catalogo_lista, catalogo_filtri).
    3. Liste dinamiche. Per elementi generati in un ciclo, deriva l'id dalla chiave stabile dell'elemento ('item_${item.id}'), mai dall'indice.
    4. Niente oggetti non serializzabili. toPrimitives accetta solo ciò che supporta lo StandardMessageCodec: niente DateTime diretti (usa millisecondsSinceEpoch), niente enum (usa index o il nome), niente istanze di classi.
    5. Closure nelle rotte ripristinabili. restorablePush con un builder non statico lancia un assert: estrai sempre una funzione top-level o static.
    6. Payload sotto controllo. Monitora la dimensione del bundle: se cresce, stai salvando dati di dominio che dovrebbero stare in un database locale.
    7. Privacy. Nessun token, password o dato personale nel bucket.
    8. Web e desktop. Il framework funziona, ma sulle piattaforme dove il processo non viene ucciso dal sistema il beneficio è limitato: concentra lo sforzo su Android.

    Un ultimo consiglio: introduci la restoration per feature, partendo dai flussi lunghi (form multi-step, checkout, editor). Sono quelli dove la perdita di stato costa di più all'utente e dove l'investimento si ripaga subito.

    // Esempio: Navigator annidato dentro una tab, con il proprio scope.
    Navigator(
      key: _tabNavigatorKey,
      restorationScopeId: 'tab_catalogo_navigator',
      onGenerateRoute: (settings) => MaterialPageRoute<void>(
        settings: settings,
        builder: (_) => const CatalogoPage(),
      ),
    );
    
    // Esempio: id derivato da una chiave stabile, mai dall'indice.
    ListView.builder(
      restorationId: 'catalogo_lista',
      itemCount: prodotti.length,
      itemBuilder: (context, index) {
        final prodotto = prodotti[index];
        return ProdottoTile(
          key: ValueKey(prodotto.id),
          restorationId: 'prodotto_${prodotto.id}',
          prodotto: prodotto,
        );
      },
    );

    Risultato atteso

    L'app supera il test del "Don't keep activities" su tutti i flussi critici: l'utente non perde mai il lavoro in corso e la CI protegge il comportamento nel tempo.

CondividiXLinkedInFacebookWhatsApp

Commenti (0)

Ancora nessun commento. Inizia tu!