Tour du package ‘Provider’, points d’intérêt et d’attention.

Introduction

J’ai récemment reçu de nombreuses questions relatives au package Provider, présenté lors du Google IO’19. Pour cette raison, j’ai décidé d’écrire cet article pour répondre à la plupart des questions qui ont été soulevées.

Cet article tente de montrer ce que le package Provider est réellement et n’est pas, sans entrer dans les détails sur la façon de l’utiliser, et met également l’accent sur les éléments à surveiller.

Avant d’exprimer mon propre point de vue, considérons ce que l’auteur de ce package a dit lui-même lors de la réunion du 17 juin au FlutterLDN:

  • Le package Provider est:

    • “Un ensemble de bonnes pratiques…” [06:07]
    • “une partie d’un Système de Gestion de d’Etat” [07:27]
  • Le Provider n’est pas:

    • “Scoped_Model v2” [06:54]
  • Dans le futur, le Provider évoluera pour fournir des liens vers Mobx et peut-être avec flutter_bloc [24:40]


Partie 1: Le rôle principal d’un fournisseur (= “Provider”)

Tout d’abord, quel est le rôle principal d’un Provider?

L’objectif initial d’un Provider est, comme son nom l’indique, de fournir quelque chose à tout qui aurait à vouloir y accéder.

Dès lors, un Provider doit être positionné en tant que ancestor d’une arborescence de Widgets afin qu’il puisse jouer son rôle de fournisseur à n’importe quel Widget faisant partir de son arborescence.


Représentation du Provider

Le diagramme suivant montre comment le Provider (et ses variantes) est architecturé.

Provider architecture

En regardant le diagramme, nous pouvons directement voir que Provider est un StatefulWidget, qui construira un InheritedProvider (= extension d’un InheritedWidget).


Comportement de base: simple fournisseur

Comme indiqué précédemment, le comportement de base consiste à fournir quelque chose à ses descendants.

Pour cela, voici une implémentation très basique:

Provider<MyClass>(
    builder: (BuildContext context) => MyClass(),
    dispose: (BuildContext context, MyClass value) => value?.dispose(),
    child: Container(),
);

Nous utilisons le builder pour indiquer au Provider ce qu’il doit fournir. Le BuildContext correspond à celui du Provider.

Note Personnelle

Le terme “builder” est un peu trompeur, car il est généralement réservé à la construction de la structure de certains widgets. Quelque chose comme “oneTimeValueBuilder” aurait pu être plus approprié car il n’est appelé qu’une seule fois au moment du initState() du BuilderStateDelegate.

La méthode “dispose” permet de décider de ce qu’il doit être fait lorsque ce Provider est lui-même supprimé.

Pour tout descendant, pour accéder à la “MyClass”, vous pouvez le faire via:

final MyClass myClass = Provider.of<MyClass>(context);

Aussi simple que cela…

Attention: paramètre optionnel “listen”

Toutefois, vous devez savoir que la méthode d’assistance “Provider.of<T>(…)” comporte un paramètre booléen facultatif, appelé “listen” (dont la valeur par défaut est true).

Donc, le code précédent aurait également pu être écrit comme suit:

final MyClass myClass = Provider.of<MyClass>(context, listen: true);

Selon la nature des données que vous manipulez avec Provider et le cas, il y aura des circonstances où vous devrez définir ce paramètre facultatif à false afin d’éviter toute reconstruction inutile… mais je reviendra sur ce paramètre un peu plus tard.


En quoi le Provider est-il différent des autres providers habituels?

À ce stade, le comportement de base du Provider n’est pas très différent de tout autre provider. Les seules différences visibles sont les suivantes:

  • externalization de

    • la façon de fournir les données que devront être servies par le Provider (via the builder)
    • la façon de gérer la libération (= “dispose”)
    • (pas toujours disponible) la décision d’indiquer ou non les contextes dépendants de l’InheritedWidget (via le updateShouldNotify)
  • fourniture des données initiales

    • le builder n’est appelé qu’une seule fois durant tout le cycle de vie de l’instance du Provider

Quel est le principal avantage du Provider par rapport à de nombreux autres?

L’externalisation du moyen d’approvisionnement de la valeur à être fournie et la notification conditionnelle sont, selon moi, les 2 plus grandes valeurs ajoutées du Provider, par rapport aux autres (tels que mon BlocProvider ou autres).

Comme le Provider contrôle totalement le moment où il demandera de lui fournir la “valeur à être fournie”, cela assure que cette valeur ne sera pas réclamée deux fois. En d’autres termes, pas instanciée 2 fois. Ceci est dû à l’externalisation de son paramètre (builder) qui est appelé lors de l’exécution de la méthode initState() du StatefulWidget correspondant.


Le Provider est-il un système de gestion d’état?

Cela dépend de ce que vous entendez par “système de gestion d’état”.

Si vous entendez par système de gestion des états un endroit pour conserver un état (ou une valeur, un modèle…) et le/la rendre accessible à tous ceux qui en ont besoin, cela peut également être considéré comme un Sytème de Gestion d’État mais une classe Singleton de base pourrait également être considérée comme une solution de gestion d’état.

Par conséquent, je suis d’accord avec l’auteur du package: le Provider n’est pas un système de gestion d’état en soi, mais peut être considéré comme une partie d’un système de gestion d’état.


Partie 2: La partie Notifier (= Notification)

Pourquoi le Provider est-il différent?

Simplement car le Provider ne se limite pas au fait d’être un fournisseur. Le Provider est également un Notifier.

Note personnelle

Peut-être un autre nom aurait-il pu être plus approprié, tel que “ProviderNotifier”, par exemple.

Comme vous pouvez le constater dans la documentation, le Provider comprend de nombreuses variantes:

  • ListenableProvider
  • ChangeNotifierProvider
  • ChangeNotifierProxyProvider
  • ValueListenableProvider
  • StreamProvider
  • FutureProvider

et avec un Widget wrapper très pratique:

  • Consumer

Pour chacune de ces variantes du Provider, lorsque le “valeur fournie” change, vous avez la possibilité de notifier (= “notify”) les Widgets qui font partie de l’arborescence dont le Provider est à l’origine, sous les conditions suivantes:

  1. La “classe fournie” demande à notifier (via le notifyListeners()) ou est un Stream ou une Future qui se termine
  2. La méthode externe facultative updateShouldNotify renvoie true (ou est absente)
  3. Vous avez un Widget Consumer ou des widgets qui ont appelé le Provider.of<…>(context, listen: true) (avec le paramètre facultatif listen == true).

Comment être notifié?

Le package Provider offre 2 façons d’être notifié:

  1. Provider.of<T>(context, [listen = true])

    Lorsqu’un widget s’inscrit comme une dépendance du InheritedWidget du Provider, ce widget est reconstruit chaque fois qu’une variation dans les “données fournies” se produit (plus précisément lorsque notifyListeners() est appelé ou lorsqu’un flux StreamProvider émet une nouvelle données ou lorsque le “future” de FutureProvider se termine).

  2. Consumer Widget

    Comme le widget Consumer est un widget qui englobe la méthode statique Provider.of<T>(), il agit presque comme décrit ci-dessus et sera reconstruit si nécessaire.

Grosse différence lorsque l’on utilise le widget ‘Consumer’

Comme c’est le Consumer lui-même qui invoque la méthode statique Provider.of<T>(context), uniquement le Consumer sera reconstruit (= “build”) (bien sûr, si le parent du Consumer ne s’est pas lui-même enregistré en tant que dépendance de l’InheritedWidget interne du Provider)


Le Provider est-il une nouvelle version du Scoped Model?

Même si l’auteur dit que ce n’est pas le cas, les ChangeNotifierProvider et ListenableProvider peuvent très facilement être assimilés à une forme d’alternative du Scoped Model.


Le Provider va-t-il remplacer BLoC?

Définitivement NON. Le Provider pourrait être utilisé comme une nouvelle version du BlocProvider mais c’est tout.

Bien sûr, si votre BLoC n’expose qu’UN SEUL stream, vous pourriez considérer le fait de remplacer votre BLoC par un StreamProvider.


Puis-je utiliser le Provider avec la notion de BLoC?

OUI, bien sûr, vous pouvez utiliser le Provider avec un BLoC et c’est très facile à implementer.

Le code suivant montre comment “fournir” le BLoC:

class MyWidget extends StatelessWidget {
    @override
    Widget build(BuildContext context){
        return Provider<MyBloc>(
            builder: (BuildContext context){
                return MyBloc();
            },
            dispose: (BuildContext context, MyBloc myBloc){
                myBloc.dispose();
            },
            // child: ...
        );
    }
}

Le code suivant montre comment “récupérer” le BLoC:

class AnotherWidget extends StatelessWidget {
    @override
    Widget build(BuildContext context){
        return StreamBuilder<String>(
            stream: Provider.of<MyBloc>(context, listen: false),
            initialValue: "une valeur",
            builder: (BuildContext context, AsyncSnapshot<String> snapshot){
                // ...
            }
        );
    }
}

Remarque intéressante.

Comme vous pouvez le voir dans le code ci-dessus, j’ai utilisé le paramètre optionnel “listen” et l’ai mis à false. Est-ce nécessaire ?

Dans ce cas précis, ce n’est pas nécessaire puisque dans cette méthode “build”, rien ne pourrait forcer un rebuild (= reconstruction) néanmoins, il n’y a également aucune raison d’enregistrer ce Widget comme une dépendance de l’InheritedWidget, interne au Provider… Ceci explique pourquoi j’ai assigné ce paramètre à false


Partie 3: A quoi faire attention…

La section suivante attire l’attention sur certaines notions pouvant présenter un intérêt…


Peut-on utiliser le Provider au sein d’un initState()?

En d’autres mots, peut-on écrire ceci dans la méthode initState()

MyClass myClass;

@override
void initState(){
    super.initState();
    myClass = Provider.of<MyClass>(context);
}

Le réponse est NON mais, si vous modifiez légèrement le code comme suit, alors cela fonctionnera:

MyClass myClass;

@override
void initState(){
    super.initState();
    myClass = Provider.of<MyClass>(context, listen: false);
}

La seule différence réside dans l’utilisation du paramètre facultatif listen.

Si vous assignez ce paramètre à false (par opposition à la valeur par défaut), vous pouvez alors utiliser le Provider au sein d’un initState(), autrement, vous ne pouvez pas et vous devrez déplacer ce code vers la méthode didChangeDependencies() par exemple.

En quoi ce simple paramètre fait-il toute la différence ?

Tout simplement parce que lorsque listen == true, la méthode statique of invoquera “context.inheritFromWidgetOfExactType” qui n’est pas acceptée dans un initState(), alors que si listen == false, la méthode of invoquera “context.ancestorInheritedElementForWidgetOfExactType” qui, elle, peut être exécutée dans un initState().


Rebuilds non nécessaires

La valeur par défaut du paramètre facultatif listen est définie sur true. Cela signifie que chaque fois que vous appelez le Provider.of<T>(context) sans mentionner le paramètre ‘listen’, vous ajoutez le BuildContext du widget à la liste de ceux qui dépendent du InheritedWidget du Provider.

Cela peut sembler anodin, mais parfois, ce n’est pas le cas.

Prenons comme exemple un bouton qui doit accéder à un modèle (appelons-le “Counter”) pour augmenter un compteur. Le modèle Counter a été fourni via ChangeNotifierProvider.

class Counter with ChangeNotifier {
    int _count = 0;
    int get count => _count;

    void increment(){
        _count++;
        notifyListeners();
    }
}

class ButtonToIncrease extends StatelessWidget {

    @override
    Widget build(BuildContext context){
        Counter counter = Provider.of<Counter>(context);
        return RaisedButton(
            onPressed: (){
                counter.increment();
            },
            child: Text('Appuyez pour augmenter le compteur'),
        );
    }
}

Parce que le paramètre optionnel listen n’est pas mentionné, sa valeur par défaut est utilisée et, à chaque fois que le modèle ‘Counter’ appellera sa méthode notifyListeners(), ce bouton sera reconstruit.

Par conséquent, lorsque vous devez simplement accéder à un modèle pour utiliser ses méthodes, n’oubliez pas de définir listen: false pour eviter des “build()” inutiles…


Prudence avec le StreamProvider

Cas 1: Récupération du Stream ou StreamController

Cette section se concentre sur StreamProvider et en particulier sur le fait que le StreamController (cas d’utilisation de StreamProvider.controller) ou le Stream (cas d’utilisation de StreamProvider ou StreamProvider.value) doit être défini en dehors du StreamProvider si vous souhaitez pouvoir les utiliser pour émettre des données ultérieurement.

Pour comprendre cette affirmation, considérons le cas d’un Stream (d’entiers) qui aurait été “créé” au niveau de StreamProvider, comme suit:

StreamProvider<int>.value(
    value: StreamController<int>.broadcast().stream,
    initialData: 0,
)

Supposons maintenant que vous ayez un ‘Consumer’ à l’écoute des variations de type <int>.

Consumer<int>(
    builder: (_, int value, __) {
        // faire quelque chose
    },
)

Le ‘Consumer’ invoquera son builder à chaque fois que le flux écouté par StreamProvider<int> émettra une valeur, mais … comment accéder au sink du StreamController correspondant?

Vous pourriez répondre à cette question en disant, par exemple: “en utilisant StreamProvider.controller …”

StreamProvider<int>.controller(
    builder: (_) => StreamController<int>.broadcast(),
    initialData: 0,
)

…et pour obtenir le StreamController, faire quelque chose comme:

StreamSink<int> sink = Provider.of<StreamController<int>>(context).sink;

…mais cela ne fonctionne pas car la méthode Provider.of n’est pas capable de récupérer le correct StreamController avec une garantie de 100%.

Par conséquent, il n’y a pas d’autre moyen que d’avoir le Stream, défini et accessible, en dehors du champ d’application de StreamProvider.


Cas 2: Soyez prudent avec les types de données, étant émis par les flux

Supposons que vous ayez besoin de 2 flux d’entiers, vous pouvez considérer ce qui suit:

MultiProvider(
    providers: [
        StreamProvider<int>.value(
            value: stream1.stream,
        ),

        StreamProvider<int>.value(
            value: stream2.stream,
        ),
    ],
),

Dans cet exemple, j’ai défini les StreamProviders au même moment, mais rien ne vous empêche d’utiliser StreamProvider à différentes étapes, mais faisant partie du même arbre de widgets.

Ensuite, comment pouvez-vous écouter le plus haut StreamProvider dans la hiérarchie du widget (ici: stream1.stream) ?

Ce n’est pas possible. Le Provider.of<int>(context) fera systématiquement référence au Provider le plus proche (en fonction du contexte, utilisé dans le Provider.of<int>(context).


Par conséquent, me demanderez-vous existe-t-il un avantage à utiliser un StreamProvider ?

Oui, il y a des avantages et je vais illustrer cela avec le cas d’utilisation pratique suivant.

Considérons un widget dont le but est de rediriger l’utilisateur vers une page de connexion ou vers la page d’accueil, en fonction de son état d’authentification. En même temps, je souhaite que ce widget ne soit uniquement averti qu’en cas de changement de cet état d’authentification.

Pour illustrer cela, considérons un flux de FirebaseUser (cas de l’authentification Firebase, par exemple).

class Application extends StatelessWidget {
    @override
    Widget build(BuildContext context){
        return StreamProvider<FirebaseUser>.value(
            value: FirebaseAuth.instance.onAuthStateChanged,
            updateShouldNotify: (FirebaseUser previous, FirebaseUser current)
                => (current != previous),
            child: MaterialApp(
                //...
            ),
        );
    }    
}

class RedirectionWidget extends StatelessWidget {
    @override
    Widget build(BuildContext context){
        return Consumer<FirebaseUser>(
            builder: (BuildContext context, FirebaseUser firebaseUser, Widget child){
                if (firebaseUser == null){
                    _redirectToLogin();
                } else {
                    _redirectToHome();
                }
                return Container();
            }
        );
    }
}

Ce RedirectionWidget appellera _redirectToLogin() quand l’utilisateur n’est pas encore authentifié ou s’il vient de se déconnecter.

Le _redirectToHome() sera appelée lorsque l’utilisateur se sera authentifié.

Grâce à la fonction updateShouldNotify, on fait en sorte que le Consumer ne se reconstruira (= build()) que le FirebaseUser variera et uniquement à cette condition.


Conclusions

J’espère que ce petit article aura répondu à la plupart des questions que l’on se pose au sujet du package Provider.

Restez à l’écoute pour de nouveaux articles, très bientôt. D’ici-là, je vous souhaite un excellent codage.