Le State Restoration en Flutter: Guide Pratique et Complet

Compatibilité
Date révision
30 oct. 2023
Publié le
30 oct. 2023
Flutter
v 3.13.x
Dart
v 3.1.x

Introduction

On parle souvent de State Management mais assez peu de la notion de State Restoration.

Cette notion, souvent ignorée, peut être assez importante dans certains types d'applications afin de fournir un confort aux utilisateurs et de leur éviter toute frustration liée au fait d'avoir potentiellement à recommencer ce qu'ils venaient de faire à suite de la mise en background de l'application... par la réception d'un simple coup de téléphone, par exemple...

Cet article vous explique pourquoi utiliser, comment fonctionne et comment implémenter le State Restoration.


Impact de la mise en Background de votre application

Supposez que vous étiez en train de finaliser une commande ainsi que le formulaire qui vous avait pris 2 minutes à compléter, vous étiez sur le point d'appuyer sur le bouton "Envoyer" et vous recevez un coup de fil... Votre application passe en arrière-plan durant votre appel téléphonique et lorsque vous revenez à votre application, votre panier d'achat est vide, ainsi que votre formulaire... Wow... la frustration.

Il faut savoir que lorsque votre application est mise en arrière plan, que ce soit suite à une action de l'utilisateur ou involontairement, lors de la réception d'un coup de téléphone, par exemple, votre application peut être terminée par le système d'exploitation afin de libérer des ressources. Lorsque ce cas arrive et que votre application revient en avant-plan, elle redémarre depuis la case zéro.


Qu'est-ce que le State Restoration ?

Le State Restoration est un mécanisme qui vous permet de faire en sorte que certaines données soient automatiquement sauvegardées lorsque l'application est mise en arrière-plan et puissent être récupérées au redémarrage de l'application.

Par données on n'entend pas seulement des valeurs mais également des parties de l'interface utilisateur (Route, TextFields, Scrolling...) qui permettent de donner l'impression aux utilisateurs que l'application n'a jamais été tuée.

Afin que ceci soit possible, il faut que votre application suive certaines règles et soit donc prévue à cet effet dès le début.

Nous allons aborder ceci pas à pas.


Configuration prélable pour vos tests

Afin de pouvoir tester le State Restoration durant vos développements, il est essentiel de s'assurer que votre application sera tuée par l'OS dès que vous la mettrez en arrière-plan.

Voici la façon de faire, suivant que vous travailliez sur Android ou iOS.

Android

Sur Android, que ce soit sur un véritable téléphone ou un simulateur, c'est assez simple.

  1. Mettez le téléphone ou le simulateur en mode Développement

    a. Ouvrez l'application "Paramètres" (Settings)

    b. Entrez dans la partie "A propos du téléphone" (About phone / About emulated device)

    c. Puis dans la partie "Informations sur le logiciel" (Software information)

    d. Repérez le "Numéro de version" (Build number) et appuyez 7 fois d'affilée.

    Au bout des 7 appuis, vous verrez un message vous informant que votre téléphone est passé en mode "Développement". Un nouveau menu "Options de développement" ("Developer options") est ajouté à l'application "Paramètres" (Settings)

  2. Entrez dans le menu "Options de développement" ("Developer options")

    a. Scrollez vers la fin et repérez le groupe "Applications" ("Apps")

    b. Activez l'option "Ne pas conserver les activités" ("Don't keep activities")

iOS

Pour iOS, cela requiert davantage de préparation au niveau de XCode. Veuillez vous référer à ce guide pour la partie iOS: State Restoration on iOS.

Je trouve qu'il est beaucoup plus simple de tester le State Restoration sur Android que sur iOS.

Dès lors, je vous recommande grandement d'utiliser un téléphone Android ou un simulateur Android lorsque vous développez sur un Mac et souhaitez tester votre implémentation du State Restoration.


Comment cela fonctionne-t-il dans les grandes lignes?

Avant d'entrer dans les détails du comment, il est néanmoins intéressant de comprendre le mécanisme général du fonctionnement.

1. Fonctionnement en mode 'avant-plan'

A la fin d'un Frame (rendering), TOUTES les données (*) en relation avec le State Restoration qui ont fait l'objet d'une modification, sont sérialisées et envoyées à l'Engine (flutter/restoration) qui les met en cache jusqu'à ce que l'OS les lui demande.

(*) en fait ce sont les Buckets mais on verra tout cela en détails, plus tard.

La sérialisation et l'envoi vers l'Engine sont des opérations synchrones.

Il est donc important de limiter la fréquence de modification, la quantité et la taille des données qui font partie cette opération afin de ne pas créer de 'jank'.

2. Quand l'Operating System termine l'application

Lorsque l'application est en arrière-plan et SI l'Operating System décide de terminer l'application afin de libérer des ressources, l'OS demandera à l'Engine de lui fournir la dernière version des données à sauvegarder puis terminera l'application.

Il est à noter que la taille des données qui peuvent être sauvegardées est limitées à quelques K-octets (*)

(*) malgré de longues recherches, je n'ai pas réussi à trouver des limites précises

3. Redémarrage de l'application

Au redémarrage d'une application qui est prévue pour le State Restoration (*), les données sont récupérées de l'Engine de manière synchrone. Le rendering de l'application peut alors commencer.

(*)On verra comment préparer l'application et quand la restauration démarre réellement plus tard.

4. Rendering des composants de l'application

Durant le State Restoration, au fur et à mesure du rendering des composants (Widgets...), les données qui leur sont liées (on verra plus tard comment) sont récupérées et "appliquées", ce qui a pour objectif et conséquence, de reconstruire l'arborescence des Widgets et, en partie (j'expliquerai plus tard), le contenu visuel.

A la fin de cette opération, l'aspect visuel de l'application devrait avoir été restauré et l'utilisateur peut continuer à utiliser l'application "comme si rien ne s'était passé".


Hot Reload, Hot Restart, Démarrage à froid


Simple remarque avant d'aller dans le vif du sujet.


La notion de State Restoration ne s'applique pas durant un Hot Reload, un Hot Restart ou un Démarrage à froid de l'application.

En d'autres termes, toute modification de la structure de votre code relative à la notion de restoration n'est pas "prise en compte" durant un Hot Reload. Pour que vos modifications soient prises en compte, il vous faudra stopper et redémarrer votre application.


Je répète: au "Démarrage" d'une application, le State Restoration ne s'applique PAS.


Le State Restoration ne s'applique qu'aux applications qui ont été terminées par l'OS, c'est pour cela que j'utilise le terme "Redémarrage".


C'est assez troublant au début...

Maintenant que l'on a défini les grandes lignes, il est grand temps de voir comment tout cela est mis en place...


Quels sont les "composants" principaux ?

Commençons tout d'abord par voir quels sont les composants principaux qui constituent le State Restoration.

RestorationManager (le "chef d'orchestre")

Le RestorationManager est le chef d'orchestre. Il est unique dans toute application Flutter.

Il est démarré dès l'initialisation des Bindings (voir Flutter Internals) et s'occupe des communications avec l'Engine pour ce qui a trait au State Restoration et de la gestion des "données" doivent être envoyées à l'Engine à la fin du prochain Frame Rendering.


RestorationScope (le "compartimentage")

Visualisez un RestorationScope comme une façon de structurer et hiérarchiser votre application par blocs de restauration autonomes, d'où le suffixe "~Scope".

Un RestorationScope est un Widget qui peut lui-même contenir d'autres RestorationScope parmi ses descendants. Chaque RestorationScope descendant est indépendant de ses ancêtres.

Le RootRestorationScope

Le tout premier de la hiérarchie est le RootRestorationScope, comme son nom l'indique et devrait normalement être unique dans le cadre d'une application.

Il se positionne généralement le plus tôt possible dans l'arborescence des Widgets, dès que la notion de restauration peut avoir du sens. Il est donc assez fréquent de le trouver (mais ce n'est pas une obligation) aux environs du runApp() comme illustré ci-après:

1
2void main(){
3    runApp(
4        const RootRestorationScope(
5            restorationId: 'root',
6            child: Application(),
7        ),
8    );
9}
10

C'est un RestorationScope special dans le fait qu'il est celui qui prépare votre application à supporter le State Restoration.

Bon, d'autres choses doivent encore être faites mais, grâce à la présence d'un RootRestorationScope, le méchanisme de récupération des données sauvegardées au niveau de l'OS est mis en place et la création du Root RestorationBucket est effectuée.

Le RestorationScope

Comme mentionné précédemment, le RestorationScope est un Widget dont le seul but est de définir une nouvelle "zône" (ou Scope) isolée de restauration et de permettre de récupérer le RestorationBucket principal relatif à cette "zône".

A sa création, un RestorationBucket est initialisé et inséré dans l'arborescence des Widgets, via un InheritedWidget spécialisé, appelé UnmanagedRestorationScope.

L'identité d'un RestorationScope est PRIMORDIALE et est mentionnée via sa propriété: restorationId.

Un RestorationScope dont l'identité n'est pas mentionnée (= null) désactive le 'State Restoration' pour toute sa sous-arborescence.

Cela peut être utile dans certains cas transitoires mais dans la plupart des cas, vous n'aurez pas à utiliser cette particularité.

Note personnelle:
Il est dommage que la propriété qui soit utilisée pour définir le comportement s'appelle restorationId ou restorationScopeId et non enabled car cela aurait permis de faciliter les explications comme vous le verrez plus loin.

Le RootRestorationScope est-il indispensable?

Cela dépend.

Il faut savoir que lorsque vous utilisez un MaterialApp ou un CupertinoApp, un RootRestorationScope est automatiquement inséré.

Dès lors, si aucune donnée ne peut faire l'objet d'une restauration en amont, le seul fait de mentionner un restorationScopeId au niveau du MateriaApp/CupertinoApp active le State Restoration et donc, un RootRestorationScope préalable n'est pas nécessaire.

Par contre, si vous deviez récupérer des données de session, telles que: langue, monnaie, thème pour pouvoir initialiser votre MateriaApp/CupertinoApp, alors, vous avez besoin d'un RootRestorationScope en amont.

ULTRA important !

Si vous avez un RootRestorationScope en amont, vous DEVEZ mentionner un restorationScopeId au niveau de votre MateriaApp/CupertinoApp, car, comme mentionné plus tôt, une absence d'identité revient à désactiver le State Restoration pour toute la sous-arborescence!!!


Le RestorationBucket (la "mémoire")

Comme son nom l'indique le RestorationBucket est un endroit où sont stockées les informations qui sont échangées entre votre application et l'OS dans le cadre du State Restoration.

Un RestorationBucket n'est rien d'autre qu'une classe qui:

  • a un nom unique pour le même RestorationScope (on verra plus tard)
  • stocke des informations (les RestorableProperty) sous le format en tant que Map<Object?, Object?>, en d'autres termes, une série de paires: key - value
  • connait sa position dans l'arborescence: parent - children

Bien que rien de vous n'en empêche, en général, vous ne manipulez pas directement les RestorationBuckets mais vous déléguez cela au RestorationMixin.


Le RestorableProperty (la "donnée")

Voici enfin les derniers éléments, les RestorableProperty, c'est-à-dire, les données utilisées par le State Restoration.

Le schéma suivant liste les différents types de données qui sont "restorable" et prévus de base par Flutter. Sachez déjà que rien ne vous empêche de créer vos propres types (nous verrons comment faire plus tard).


RestorableProperty: différents types -- 2263x843

Cliquez pour Zoomer

Comme vous pouvez le voir, un RestorableProperty est un ChangeNotifier. Ceci est utilisé par le RestorationMixin pour savoir quand une propriété a été modifiée et quand elle doit être modifiée au niveau du RestorableBucket.

Doit-on systématiquement utiliser un RestorableProperty pour sauvegarder/restaurer des données?

Non.

Vous pouvez sans aucun problème utiliser n'importe quel type de données néanmoins, il y a 2 avantages à utiliser des RestorableProperty:

  1. Vous utilisez un mixin qui se charge de toute la complexité relative à la gestion des RestorationBucket et des moments quand il est nécessaire de "sauvegarder" ou "récupérer" ces données.
  2. Grâce à l'utilisation d'un RestorableProperty, il est beaucoup plus aisé d'identifier les données qui font partie du processus.

Quels sont les types de données supportées de base ?

Le tableau suivant établit le parallèle entre les types de données et leur équivalent restorable.

TypeRestorable
StringRestorableString
String?RestorableStringN
intRestorableInt
int?RestorableIntN
doubleRestorableDouble
double?RestorableDoubleN
numRestorableNum
num?RestorableNumN
boolRestorableBool
bool?RestorableBoolN
EnumRestorableEnum
Enum?RestorableEnumN
DateTimeRestorableDateTime
DateTime?RestorableDateTimeN
TimeOfDayRestorableTimeOfDay

D'autres types de RestorableProperty existent mais vous ne les utiliserez pas très souvent. Afin d'être complet, les voici:

  • RestorableRouteFuture
  • RestorableCupertinoTabController
  • RestorableTextEditingController

State Restoration en pratique

Maintenant que nous connaissons les intervenants du State Restoration, il est temps de voir comment les utiliser ensemble.

Cas simple - l'application "compteur"

Pour démarrer, commençons par l'application de base: le "compteur" et adaptons son code pour qu'il supporte le State Restoration.

1
2    import 'package:flutter/material.dart';
3
4    void main() {
5      runApp(const RootRestorationScope(
6        restorationId: 'root',
7        child: MyApp(),
8      ));
9    }
10
11    class MyApp extends StatelessWidget {
12      const MyApp({super.key});
13
14      
15      Widget build(BuildContext context) {
16        return MaterialApp(
17          title: 'Restorable Counter',
18          restorationScopeId: 'application',
19          theme: ThemeData(
20            colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple),
21            useMaterial3: true,
22          ),
23          home: const MyHomePage(),
24        );
25      }
26    }
27
28    class MyHomePage extends StatefulWidget {
29      const MyHomePage({super.key});
30
31      
32      State<MyHomePage> createState() => _MyHomePageState();
33    }
34
35    class _MyHomePageState extends State<MyHomePage> with RestorationMixin<MyHomePage> {
36      final RestorableInt _counter = RestorableInt(0);
37
38      void _incrementCounter() {
39        setState(() {
40          _counter.value++;
41        });
42      }
43
44      
45      Widget build(BuildContext context) {
46        return Scaffold(
47          appBar: AppBar(
48            backgroundColor: Theme.of(context).colorScheme.inversePrimary,
49            title: const Text('Restorable Demo'),
50          ),
51          body: Center(
52            child: Column(
53              mainAxisAlignment: MainAxisAlignment.center,
54              children: <Widget>[
55                const Text(
56                  'You have pushed the button this many times:',
57                ),
58                Text(
59                  '${_counter.value}',
60                  style: Theme.of(context).textTheme.headlineMedium,
61                ),
62              ],
63            ),
64          ),
65          floatingActionButton: FloatingActionButton(
66            onPressed: _incrementCounter,
67            tooltip: 'Increment',
68            child: const Icon(Icons.add),
69          ),
70        );
71      }
72
73      
74      String? get restorationId => 'counter_page';
75
76      
77      void restoreState(RestorationBucket? oldBucket, bool initialRestore) {
78        registerForRestoration(_counter, 'counter');
79      }
80    }
81

Explications:

  • lignes 4-5: Nous définissons le RootRestorationScope et lui donnons un nom (comme je vous l'ai dit précédemment, dans cet exemple-ci, nous n'avions pas besoin d'en insérer un, mais c'est simplement pour que ce soit plus "visible" au niveau des explications)
  • ligne 17: Comme nous l'avons vu, nous devons également donner un nom de restauration à un MaterialApp
  • ligne 35: Nous incluons le RestorationMixin pour nous aider
  • ligne 36: Au lieu d'utiliser un int, nous utilisons sa version restorable RestorableInt et l'initialisons à 0
  • ligne 40: L'incrémentation s'effectue au niveau de sa "value"
  • ligne 59: Nous récupérons sa value
  • lignes 73-74: Nous donnons un nom au RestorationBucket relatif à notre StatefulWidget qui utilise des données à restaurer.
  • lignes 78: Nous informons le RestorationMixin que le RestorationInt fait partie des propriétés à sauvegarder/restaurer.

Comme vous le voyez, il n'a pas été nécessaire de faire énormément de modifications pour rendre le code compatible.


Qu'en est-il des Routes?

Toute application qui se respecte ne se limite pas à une seule "page" et l'utilisateur va naviguer au travers de l'application et ouvrir des "pages" (= Route).

Il est donc également nécessaire de pouvoir sauvegarder ces Routes afin de les restaurer au redémarrage.

Flutter a prévu cette possibilité et offre des variations aux habituels Navigator.of(context).xxx():

Non-RestorableRestorable
.pushNamed<T>(...).restorablePushNamed<T>(...)
.pushReplacementNamed<T>(...).restorablePushReplacementNamed<T>(...)
.popAndPushNamed<T>(...).restorablePopAndPushNamed<T>(...)
.pushNamedAndRemoveUntil<T>(...).restorablePushNamedAndRemoveUntil<T>(...)
.push<T>(...).restorablePush<T>(...)
.pushReplacement<T>(...).restorablePushReplacement<T>(...)
.pushAndRemoveUntil<T>(...).restorablePushAndRemoveUntil<T>(...)
.replace<T>(...).restorableReplace<T>(...)
.replaceRouteBelow<T>(...).restorableReplaceRouteBelow<T>(...)

Bien entendu, uniquement les méthodes relatives à l'ajout ou la modification de la liste des routes sont prises en considération. Le fait de retirer une route (pop) ne requiert pas que l'en enregistre les routes qui ne sont plus là.

Dès lors, si vous désirez qu'une page soit restaurée au démarrage, utilisez les méthodes restorablexxx<T>(...).

Voici un exemple:

1
2class BasketPage extends StatefulWidget {
3    const BasketPage({super.key});
4
5    
6    State<BasketPage> createState() => _BasketPageState();
7
8    //
9    // Static Routing
10    //
11    static Route<Object?> restorableRoute(BuildContext context, Object? arguments) {
12        return BasketPage.route();
13    }
14
15    static Route<void> route() => MaterialPageRoute(
16            settings: BasketPage.routeSettings,
17            builder: (BuildContext context) => const BasketPage(),
18        );
19    static RouteSettings routeSettings = const RouteSettings(name: '/basket_page');
20    }
21
22    class _BasketPageState extends State<BasketPage> {
23        ...
24    }
25
26        //
27        // Code pour ajouter la nouvelle route
28        //
29        Navigator.of(context).restorablePush<void>(BasketPage.restorableRoute);
30

Création d'un type spécifique

Supposons que vous désiriez conserver les données de session dans une instance de classe spécifique. Comme ce n'est pas un type supporté de base, comment faire?

Afin d'illustrer le cas, voyons le code et des explications complémentaires suivront:

1
2class ApplicationSession extends ChangeNotifier {
3    String? _language;
4
5    String? get language => _language;
6
7    set language(String? value){
8        if (value != _language){
9            _language = value;
10            notifyListeners();
11        }
12    }
13
14    // -------------------------------
15    // Serialization
16    // -------------------------------
17    Map<String, dynamic> toJson() => {
18        "language": _language,
19        };
20
21    // -------------------------------
22    // Deserialization
23    // -------------------------------
24    void loadFromJson(Map<String, dynamic> json) {
25        _language = json["language"] as String?;
26    }
27}
28
29class RestorableApplicationSession extends RestorableListenable<ApplicationSession> {
30    // ---------------------------------------------------------------------
31    // Appelé au démarrage initial de l'application pour définir une valeur
32    // par défaut
33    // ---------------------------------------------------------------------
34    
35    ApplicationSession createDefaultValue() {
36      // Retournons simplement une instance de notre classe
37      return ApplicationSession();
38    }
39
40    // ------------------------------------------------------------------
41    // Appelé lors du redémarrage de l'application.  On réinitialise
42    // notre instance avec les valeurs sauvegardées
43    // ------------------------------------------------------------------
44    
45    ApplicationSession fromPrimitives(Object? data) {
46      final Map<String, dynamic> savedData = Map<String, dynamic>.from(data as Map<Object?, Object?>);
47
48      // On définit une nouvelle instance
49      final ApplicationSession session = ApplicationSession();
50
51      // et l'initialisons avec les valeurs restaurées
52      session.loadFromJson(savedData);
53
54      return session;
55    }
56
57    // ------------------------------------------------------------------
58    // Appelé SOIT après un createDefaultValue() OU fromPrimitives(...)
59    // ou à n'importe quel moment dès qu'une nouvelle "valeur" a été fournie
60    // par le RestorationMixin.
61    // IMPORTANT -- Si l'on surcharge cette méthode:
62    //    Comme la valeur interne est initialisée par le RestorableListenable
63    //    nous devons impérativement appelé le super.initWithValue(value)
64    // ------------------------------------------------------------------
65    
66    void initWithValue(ApplicationSession value) {
67      super.initWithValue(value);
68      // Faites ce que vous voulez avec l'instance
69    }
70
71    // ------------------------------------------------------------------
72    // Appelé lorsque nous devons sauvegarder l'information pour une
73    // restauration ultérieure potentielle.  Egalement appelé à la fin
74    // de l'initialisation (ou au cas où le initWithValue aurait modifié qch)
75    // ------------------------------------------------------------------
76    
77    Object? toPrimitives() {
78      // Sérialisation
79      return value.toJson();
80    }
81
82    // ------------------------------------------------------------------
83    // Appelé pour libérer des resources éventuelles.
84    // ------------------------------------------------------------------
85    
86    void dispose() {
87      // Libération de resources, si nécessaire
88      super.dispose();
89    }
90}
91

Le tableau suivant résume les cycles de vie de notre RestorableApplicationSession:

Démarrage à froid de l'applicationRedémarrage de l'application
1. createDefaultValue1. fromPrimitives
2. initWithValue2. initWithValue
3. toPrimitives-

Le code suivant montre de manière ultra simpliste comment utiliser ce nouveau type:

1
2void main(){
3    runApp(
4        const RootRestorationScope(
5            restorationId: 'root',
6            child: const Application(),
7        ),
8    );
9}
10
11class Application extends StatefulWidget {
12    const Application({super.key});
13
14    
15    State<Application> createState() => _ApplicationState();
16}
17
18class _ApplicationState extends State<Application> with RestorationMixin {
19    late RestorableApplicationSession restorableApplicationSession;
20
21    // ------------------------------------------------------------------
22    // Nom unique pour le RestorationBucket
23    // ------------------------------------------------------------------
24    
25    String get restorationId => 'application';
26
27    // ------------------------------------------------------------------
28    // Initialise ou restaure la RestorableProperty utilisée par ce State
29    // ------------------------------------------------------------------
30    
31    void restoreState(RestorationBucket? oldBucket, bool initialRestore) {
32
33    // Initialisons le type
34    restorableApplicationSession = RestorableApplicationSession();
35
36    // Informons le système qu'il doit considérer cette instance dans le
37    // casdre du State Restoration
38    registerForRestoration(restorableApplicationSession, 'restoration_application_session');
39    }
40
41    ...
42}
43

Rien d'autre n'est nécessaire afin d'intégrer ce nouveau type de donnée au mécanisme de restauration.

Vous avez très certainement remarqué que je n'avais pas instancié le RestorableApplicationSession directement mais que j'avais utilisé un "late" et que l'initialisation avait eu lieu au niveau de la méthode restoreState(...). Pourquoi et est-ce nécessaire de faire comme cela?

Pour pouvoir comprendre exactement pourquoi je l'ai fait, nous devons maintenant jeter un coup d'oeil au RestorationMixin et analyser comment tout fonctionne.

Comment fonctionne le RestorationMixin ?

Si vous vous souvenez, un RestorationScope ne fait que définir un bloc de restauration isolé des autres. Rien d'autre.

Pour pouvoir sauvegarder/récupérer les données, vous devez utiliser des RestorationBucket et c'est ici que le RestorationMixin intervient.

Le RestorationMixin est responsable de gérer les RestorationBucket au sein du RestorationScope direct auquel le StatefulWidget appartient.

1. restorationId

Le RestorationMixin demande à ce qu'on lui fournisse un restorationId UNIQUE pour le RestorationScope direct auquel le Widget appartient ou null.

Si le restorationId n'est pas null, le RestorationMixin instancie un RestorationBucket qui sera identifié par ce restorationId.

Les données sujettes à la sauvegarde/restauration de CE Widget seront sauvegardées dans CE bucket.

a. Importance que restorationId soit unique

Pourquoi est-ce important que restorationId soit unique?

Tout simplement parce que

Il NE peut PAS y avoir 2 RestorationBucket qui portent le même nom au sein d'un RestorationScope.

Cette contrainte d'unicité est importante. Dès lors, si vous avez plusieurs instances d'un même StatefulWidget au sein d'un même RestorationScope, vous devrez vous assurer d'utiliser un nom unique et constant car le restorationId sera utilisé comme identifiant lors de la sauvegarde des données, et réutilisé afin d'identifier le "propriétaire" des données lors de la restauration !!!

Astuce pour les StatefulWidget multi-instanciables.

Ajoutez une propriété à vos StatefulWidget pour contrôler leur restorationId et évitez de hardcoder le restorationId au sein du StatefulWidget.

Privilégiez ce genre de signature afin d'avoir un meilleur contrôle sur le restorationId.

1
2class MyRestorableMultiInstantiableWidget extends StatefulWidget {
3    const MyRestorableMultiInstantiableWidget({
4        super.key,
5        this.restorationId,
6        ...
7    });
8
9    final String? restorationId;
10    ...
11}
12

Si jamais il devait vous arriver que ce ne soit pas possible de savoir à l'avance si 2 instances de Widgets portent le même restorationId au sein d'un même RestorationScope, rien ne vous empêche de créer une nouvelle zône en insérant un RestorationScope, comme ceci:

1
2
3Widget build(BuildContext context){
4  return Column(
5    children: [
6      MyRestorableMultiInstantiableWidget(
7        restorationId: 'myRestorationId',
8      ),
9      RestorationScope(
10        restorationId: 'zone2',
11        child: MyRestorableMultiInstantiableWidget(
12          restorationId: 'myRestorationId',
13        ),
14      ),
15    ],
16  );
17}
18

Comme vous le voyez, grâce au fait d'avoir ajouté un RestorationScope, nous avons créé une segmentation qui me permet d'utiliser le même restorationId.


b. restorationId comme flag d'activation/désactivation

Comme mentionné précédemment, le même nom de propriété "restorationId" est utilisé pour les RestorationScope et RestorationBucket, ce qui est dommage car cela peut amener à des confusions.

Pour rappel, un restorationId qui est null revient à désactiver la restauration.

Pour un RestorationScope, la désactivation s'effectue au niveau de l'arborescence démarrée par le RestorationScope.

Pour un RestorationMixin, la désactivation est limitée au StatefulWidget, seulement.


2. restoreState(...)

Parmi les méthodes ajoutées par le RestorationMixin, on trouvera le restoreState(...) qui est appelé juste après le initState().

C'est l'endroit où vous mentionnez (et enregistrez) la liste des RestorableProperty liées à l'instance du StatefulWidget.

Exemple typique d'utilisation:

1
2
3void restoreState(RestorationBucket? oldBucket, bool initialRestore) {
4    // Initialisation du type
5    restorableApplicationSession = RestorableApplicationSession();
6
7    // Dis au State Restoration de considérer cette instance
8    registerForRestoration(restorableApplicationSession, 'restoration_application_session');
9}
10

Il est important de se rappeler que la méthode "registerForRestoration" est exécutée de manière synchrone par le mixin et qu'au terme de cette exécution le contenu du RestorableProperty sera soit initialisé ou restauré, selon le cas.

Ceci explique pourquoi

Il est généralement considéré comme une bonne pratique d'instancier tous les RestorableProperty au niveau du restoreState(...) afin de pouvoir gérer leurs interdépendances potentielles mais également d'autres dépendances.

3. StatefulWidget - Changement de workflow

Voilà à quoi ressemble le workflow de "démarrage" d'un StatefulWidget qui fait usage du State Restoration.


812x632

Bien entendu, ceci considère le fait que vous respectiez et appeliez les super.initState() et super.didChangeDependencies() immédiatement comme prévu par le framework, c'est-à-dire:

1
2
3void initState(){
4  super.initState();
5  // Autres initialisations
6}
7
8
9void didChangeDependencies(){
10  super.didChangeDependencies();
11  // Autres initialisations
12}
13

4. Le RestorationMixin est à l'écoute...

Lorsque vous enregistrez un RestorableProperty via la méthode registerForRestoration(...), le RestorationMixin va commencer à "écouter" les modifications qui pourraient être appliquées au contenu du RestorationProperty. Ceci explique pourquoi les RestorableProperty sont des ChangeNotifier.

Ceci explique également pourquoi:

  • tout nouveau type de RestorableProperty doit référer à un ChangeNotifier
  • il est important que vous appeliez le dispose() pour chacun des RestorationProperty que vous instanciez.

Donc, en quoi le fait que le mixin soit à l'écoute est important à savoir ?

C'est grâce au fait que le mixin réagisse aux modifications des contenus des RestorableProperty, que leur "valeur" est sauvegardée.

Voici comment...


1004x476

En clair,

  • Lorsque le contenu d'un RestorableProperty est modifié, il notifie tous ses listeners dont le RestorationMixin.
  • Celui-ci récupère le nouveau contenu et le stocke au niveau du RestorationBucket.
  • Ce dernier avertit le RestorationManager qu'à la fin du prochain Frame Rendering, il faudra envoyer une mise à jour des données à restaurer à l'Engine.

Frame Rending (le "trigger")

Comme on vient de le voir, la sauvegarde des données (c'est-à-dire, l'envoi vers l'Engine) est effectuée à la fin du prochain Frame Rendering.

Dans 99.9% des cas, on n'a pas besoin de se préoccuper de cela cependant, il peut exister des circonstances où l'on devrait modifier un RestorableProperty en-dehors de tout refresh du layout.

C'est notamment le cas du Scrollable où à la fin d'une activité de scrolling la position finale du scrolling (offset) est déterminée entre 2 frames, sans avoir à planifier une nouvelle.

Pour couvrir ce cas extrêmement rare, le RestorationManager expose une méthode qui permet de forcer cette sauvegarde et l'envoi vers l'Engine:

1
2    ServicesBinding.instance.restorationManager.flushData();
3

Comment restaurer le layout complet ?

Au tout début de cet article, je vous ai dit que la taille des données qui peuvent être sauvegardées était limitées à quelques K-octets.

Cela signifie qu'il faut se limiter aux données essentielles et donc, ne pas stocker la totalité des informations.

Supposons que l'utilisateur était en plein milieu d'une recherche de produits affichés dans une ListView interminable. Comment faire pour donner l'impression à l'utilisateur de se retrouver exactement là où il était?

Bien entendu, on aura très certainement sauvegardé ses identifiants, sa langue, les critères de recherche, sa position de scrolling dans la ListView et la clé du produit pour lequel il regardait les détails mais il est hors de question d'avoir sauvegardé la liste des produits, leurs détails, leurs photos, ... Alors comment faire?

Il n'y a pas de solution universelle mais voici une piste.

1. Identifiez les cas complexes de restauration

Lorsque vous établissez l'architecture de votre application, il est important que vous pensiez au(x) cas où ce serait complexe de restaurer l'interface utilisateur.

2. Mode Restauration ou pas ?

Comment déterminer si vous êtes en mode "restauration" ou pas?

Comme nous allons le voir un peu plus loin, de temps à autres, il peut être intéressant de savoir si l'on est en mode "restauration" et d'agir en conséquence.

La manière la plus évidente est de se baser sur l'existence du restorationId. Si ce paramètre est null, au moins, il n'y a pas de doutes possibles. Par contre, sa présence ne veut rien dire en soi.

La façon la plus évidente est de vérifier l'existence d'un RestorableProperty dans le RestorationBucket.

1
2  
3  void restoreState(RestorationBucket? oldBucket, bool initialRestore) {
4    isRestoring = initialRestore && (bucket?.contains('oneOfMyRestorationId') ?? false);
5    ...
6  }
7

Bien que cette façon de faire soit la plus courante, il existe des cas où la restauration d'un Widget serait postposée (par, par exemple le fait de ne pas passer le restorationId directement mais plus tard). Dans ce cas, la méthode restorationState ne sera pas appelée. Alors comment faire?

Une astuce que j'utilise de temps à autres consiste en un "flag" que je positionne directement au niveau du Root RestorationBucket et que je retire lorsque mon StatefulWidget est disposé.

Si, je suis en mode "restauration", cela veut dire que je n'ai pas eu le temps de retirer ce flag car la méthode dispose() n'avait pas été appelée. Donc, si ce flag est présent au niveau du root RestorationBucket, cela signifie que je suis en mode restauration.

Voici comme il m'arrive de l'implémenter:

1
2class _MyStatefulPageState extends State<MyStatefulPage> with RestorationMixin {
3    final String _restorationFlagName = "flag_my_stateful_page";
4
5    bool isRestoring = false;
6    RestorationBucket? rootRestorationBucket;
7
8    
9    void initState() {
10        super.initState();
11        _determineIfInRestorationMode();
12    }
13
14    /// ------------------------------------------------------------------
15    /// Cette routine valide de manière SYNCHRONE si nous sommes en mode
16    /// restauration ou pas, via la presence d'un certain flag dans le
17    /// Root RestorationBucket
18    /// ------------------------------------------------------------------
19    void _determineIfInRestorationMode() {
20        //
21        // Empêche le rendu du premier rendering
22        //
23        RendererBinding.instance.deferFirstFrame();
24
25        //
26        // Récupère le Root RestorationBucket (si existe)
27        //
28        ServicesBinding.instance.restorationManager.rootBucket.then((RestorationBucket? bucket) {
29          //
30          // Sauve le RestorationBucket pour réutilisation future
31          //
32          rootRestorationBucket = bucket;
33
34          //
35          // Teste si le flag est présent
36          //
37          final bool? flagPresence = bucket?.read(_restorationFlagName);
38
39          //
40          // Si oui, nous sommes en mode restauration
41          //
42          isRestoring = flagPresence == true;
43
44          //
45          // Autrement créons le flag
46          //
47          if (isRestoring == false) {
48              bucket?.write(_restorationFlagName, true);
49          }
50
51          //
52          // Maintenant, on peut laisser le système faire
53          // le rendering du frame
54          //
55          RendererBinding.instance.allowFirstFrame();
56        });
57    }
58
59    
60    void dispose() {
61        //
62        // Si nous arrivons ici, cela veut dire que nous
63        // n'avons plus besoin du flag
64        //
65        rootRestorationBucket?.remove(_restorationFlagName);
66        super.dispose();
67    }
68    }
69

Je dois avouer que cette implémentation est assez tricky... car il est impératif de connaître le mode au niveau du initState() et que l'on ne peut attendre le didChangeDependencies() car le RestorationMixin aura déjà appelé le restoreState(...) (voir plus haut).

Comme la récupération du root RestorationBucket est une fonction asynchrone, et que nous savons pertinemment que la restauration du root bucket a déjà eu lieu au moment où j'exécute mon code, afin de contourner cet appel asynchrone, je reporte le Frame Rendering durant ma vérification, via l'appel à RendererBinding.instance.deferFirstFrame();.

Dès que j'ai mon information, je réactive le Frame Rendering via l'appel à RendererBinding.instance.allowFirstFrame();.

Donc, maintenant je sais dans quel mode je suis, ce qui me permet de prendre les "bonnes" décisions quant au moment d'enregistrer les RestorableProperties...

3. Enregistrement des RestorableProperty au bon moment

Souvenez-vous

Les contenus des RestorableProperties sont restaurés au moment où on les "enregistre" via l'appel à registerForRestoration(...).

Dès lors, si vous les restaurez au mauvais moment, vous n'aurez plus la possibilité de le faire (facilement) par la suite.

C'est notamment le cas avec les Scrollable dont la position dans une liste ne serait pas réétablie correctement car... votre liste serait vide à ce moment-là. Alors, comment faire?

Restaurez les RestorableProperty qui vous donnent les informations nécessaires à la recomposition des données complexes et DIFFEREZ les autres RestorableProperty.

4. Récupérez les données "lourdes"

Une fois que vous avez enregistré les RestorableProperty qui vous permettent de connaître l'état des données et NON celles qui assurent l'affichage, vous pouvez lancer la récupération des données 'lourdes' (par exemple, via des appels vers le serveur).

Un bon emplacement pour lancer cette fonction pourrait être au niveau du restoreState.

Voici un exemple:

1
2  
3  void restoreState(RestorationBucket? oldBucket, bool initialRestore) {
4    restorableSearchCriteria = RestorableSearchCriteria();
5    registerForRestoration(restorableSearchCriteria, 'restorable_search_criteria');
6
7    //
8    // Une fois que nous avons les critères de recherche ET que nous sommes
9    // en mode restauration, récupérons les data.
10    //
11    if (isRestoring == true){
12      unawaited(_fetchItemsFromServer(oldBucket, initialRestore));
13    } else {
14      _restoreStateLayoutRelated(oldBucket, initialRestore)
15    }
16  }
17
18  void _restoreStateLayoutRelated(RestorationBucket? oldBucket, bool initialRestore){
19    //
20    // Procéder à l'autre enregistrement
21    //
22    registerForRestoration(restorableStatus, 'restorable_status');
23  }
24
25  /// -------------------------------------------------------
26  /// Récupération des data depuis le serveur et une fois fait,
27  /// enregistrer les RestorableProperty liés au layout
28  /// -------------------------------------------------------
29  Future<void> _fetchItemsFromServer(RestorationBucket? oldBucket, bool initialRestore) async {
30    //
31    // Récupération des données depuis le serveur
32    //
33    ...
34
35    //
36    // Une fois terminé, récupération des _RestorableProperty_ responsables du layout
37    //
38    _restoreStateLayoutRelated(oldBucket, initialRestore);
39
40    //
41    // Ici, très probablement, nous devrons forcer une reconstruction mais aussi dire
42    // que nous en avons fini avec la restauration
43    //
44    if (mounted){
45      setState((){
46        isRestoring = false;
47      });
48    }
49  }
50

5. N'affichez les listes qu'après avoir tout récupéré

Comme tous les Scrollables (ListView, GridView, SingleChildScrollView, ...) ne sauvegardent que le scrollOffset, il devient évident que vous ne pouvez les afficher que lorsque vous avez récupéré l'ensemble des données afin que le repositionnement soit correct.

Dès lors, n'oubliez pas d'utiliser le flag isRestoring afin de vous assurer de n'afficher ces éléments que lorsque les données sont prêtes.


Avant de conclure

Avant de conclure, il est important de rappeler que les Widgets mis directement à disposition par Flutter ne sont pas tous prévus pour fonctionner en mode restauration.

Gardez également en mémoire que les Controllers, y compris les AnimationController, ne sont pas non plus sauvegardés.

Plus important encore...

Malheureusement, la plupart des plugins/packages ne supportent pas le State Restoration !!

Je ne vous conseillerai jamais assez de vérifier quels plugins/packages vous utilisez si vous désirez que votre application supporte pleinement cette notion de State Restoration.


Conclusion

A nouveau un long article et pourtant il y aurait encore beaucoup à dire notamment au sujet des UnmanagedRestorationScope mais on peut parfaitement utiliser le State Restoration sans ces connaissances supplémentaires...

J'espère que cet article a servi à démystifier ou tout simplement permis de faire connaissance avec cette notion importante et tellement peu utilisée, qu'est le State Restoration.

Restez à l'écoute pour de prochains articles et d'ici-là, happy coding!

0 Commentaires
Soyez le premier à faire un commentaire...
© 2024 - Flutteris
email: info@flutteris.com

Flutteris



Quand l'excellence rencontre l'innovation
"votre satisfaction est notre priorité"

© 2024 - Flutteris
email: info@flutteris.com