BLoC, Reactive Programming, Streams - Cas practiques d’utilisation et modèles (=pattern) utiles.

Difficulté: Intermédiaire

Introduction

Suite à l’introduction aux notions de BLoC, Reactive Programming et Streams, que j’ai faite il y a quelque temps, j’ai pensé qu’il serait peut-être intéressant de partager avec vous certains modèles (=patterns) que j’utilise régulièrement et que je trouve personnellement très utile (du moins pour moi). Ces modèles me permettent de gagner énormément de temps dans mes développements et rendent également mon code beaucoup plus facile à lire et à déboguer.

Les sujets que je vais aborder sont:

Le code source complet est disponible sur GitHub.


1. BLoC Provider et InheritedWidget

Je profite de cet article pour présenter une autre version de mon BlocProvider, qui repose désormais sur un InheritedWidget.

L’avantage d’utiliser un InheritedWidget est de gagner en performance.

Je m’explique…

1.1. Implémentation précédente

Ma version précédente de BlocProvider était implémentée en tant que StatefulWidget standard, comme suit:

J’ai utilisé un StatefulWidget pour bénéficier de sa méthode dispose() afin de garantir la libération des ressources allouées par BLoC, lorsqu’elles ne sont plus nécessaires.

Cela fonctionne très bien, mais ce n’est pas optimal du point de vue des performances.

La fonction context.ancestorWidgetOfExactType() est de type O(n). Afin de récupérer l’ ancêtre (=ancestor) demandé correspondant à un certain type, il parcourt l’arbre, en partant du context, et remonte récursivement d’un parent à la fois, jusqu’à ce qu’il trouve (ou pas). Si la distance entre le context et l’ancestor est petite, l’appel à cette fonction est acceptable, sinon il convient de l’éviter. Voici le code de cette fonction.

@override
Widget ancestorWidgetOfExactType(Type targetType) {
    assert(_debugCheckStateIsActiveForAncestorLookup());
    Element ancestor = _parent;
    while (ancestor != null && ancestor.widget.runtimeType != targetType)
        ancestor = ancestor._parent;
    return ancestor?.widget;
}

1.2. Nouvelle implémentation

La nouvelle implémentation repose sur un StatefulWidget, associé à un InheritedWidget:

L’avantage est que cette solution se résume en gain de performance.

Grâce à l’utilisation d’un InheritedWidget, on peut maintenant appeler la fonction context.ancestorInheritedElementForWidgetOfExactType(), qui est de type O(1), ce qui signifie que la récupération de l’ancestor est immédiate, comme indiqué par son code source:

@override
InheritedElement ancestorInheritedElementForWidgetOfExactType(Type targetType) {
    assert(_debugCheckStateIsActiveForAncestorLookup());
    final InheritedElement ancestor = _inheritedWidgets == null 
                                    ? null 
                                    : _inheritedWidgets[targetType];
    return ancestor;
}

Cela vient du fait que tous les InheritedWidgets sont mémorisés par le Framework.

Pourquoi utiliser le ancestorInheritedElementForWidgetOfExactType?

Vous avez peut-être remarqué que j’utilise la méthode ancestorInheritedElementForWidgetOfExactType à la place de la méthode inheritFromWidgetOfExactType.

La raison vient du fait que je ne veux pas que le contexte qui appelle le BlocProvider soit enregistré en tant que dépendance du InheritedWidget car je n’en ai pas besoin.

1.3. Comment utiliser le nouveau BlocProvider ?

1.3.1. Injection du BLoC
Widget build(BuildContext context){
    return BlocProvider<MyBloc>{
        bloc: myBloc,
        child: ...
    }
}
1.3.2. Récupération du BLoC
Widget build(BuildContext context){
    MyBloc myBloc = BlocProvider.of<MyBloc>(context);
    ...
}

2. Où initialiser un BLoC

Pour répondre à cette question, vous devez déterminer la portée de son utilisation.

2.1. Disponible partout dans l’application

Supposons que vous ayez à traiter avec certains mécanismes liés à l’Authentification / Profil utilisateur, Préférences utilisateur, Panier… ou tout ce qui nécessiterait qu’un BLoC soit disponible à partir de toutes les parties possibles d’une application (par exemple à partir de pages différentes), il existe 2 façons de rendre ce BLoC accessible.

2.1.1. Utilisation d’un Singleton Global

Cette solution repose sur l’utilisation d’un objet Global, instancié une fois pour toutes et ne faisant partie d’aucune arborescence de widgets (=Widgets tree).

Pour utiliser ce BLoC, il vous suffit d’importer la classe et d’appeler directement ses méthodes comme suit:

1
2
3
4
5
6
7
8
9
import 'global_bloc.dart';

class MyWidget extends StatelessWidget {
    @override
    Widget build(BuildContext context){
        globalBloc.push('building MyWidget');
        return Container();
    }
}

C’est une solution acceptable si vous avez besoin d’un BLoC unique, qui doit être accessible de n’importe où dans l’application.

  • c’est très facile à utiliser;
  • cela ne dépend d’aucun BuildContext;
  • pas besoin de chercher le BLoC via un BlocProvider et,
  • afin de libérer ses ressources, assurez-vous simplement d’implémenter l’application en tant que StatefulWidget et appelez le globalBloc.dispose() dans la méthode surchargée dispose() du Widget de l’application.

Beaucoup de puristes sont contre cette solution. Je ne sais pas vraiment pourquoi mais… alors regardons-en une autre…

2.1.2. Accessible partout

En Flutter, l’ancêtre de toutes les pages doit être lui-même le parent du MaterialApp. Ceci est dû au fait qu’une page (ou Route) fait partie d’un OverlayEntry, enfant d’un Stack commun pour toutes les pages.

En d’autres termes, chaque page a un BuildContext qui est indépendant de toute autre page. Ceci explique pourquoi, sans utiliser d’astuce, il est impossible que 2 pages (Routes) aient quelque chose en commun.

Par conséquent, si vous avez besoin qu’un BLoC soit disponible n’importe où dans l’application, vous devez le définir comme parent du MaterialApp, comme suit:


2.2. Accessible à une arborescence

La plupart du temps, vous devrez peut-être utiliser un BLoC dans certaines parties spécifiques de l’application.

Par exemple, nous pourrions penser à un fil de discussion où le BLoC serait utilisé pour

  • interagir avec le serveur afin de récupérer, ajouter, mettre à jour des publications
  • lister les discussions à afficher dans une certaine page

Pour cet exemple, vous n’avez pas besoin que ce BLoC soit disponible pour l’ensemble de l’application mais pour certains Widgets, qui font partie d’un arbre (ou arborescence).

Une première solution pourrait consister à injecter le BLoC à la racine de l’arborescence de Widgets, comme suit:

De cette façon, tous les Widgets accéderont au BLoC via un appel à la méthode BlocProvider.of.

Note

La solution indiquée ci-dessus n’est pas optimale car elle instanciera le BLoC à chaque reconstruction (=build).


Conséquences:

  • vous perdrez tout contenu existant du BLoC
  • cela coûtera du temps CPU car il doit être instancié à chaque construction.

Dans ce cas, une meilleure approche consiste à utiliser un StatefulWidget pour bénéficier de son état persistant (=persistent State), comme suit:

Avec cette approche, si le widget “MyTree” doit être reconstruit, il ne sera pas nécessaire de ré-instancier le BLoC et l’on pourra réutiliser directement l’instance existante.


2.3. Accessible depuis un Widget spécifique

Cela concerne les cas où un BLoC ne serait utilisé que par une seule instance d’un Widget.

Dans ce cas, il est acceptable d’instancier le BLoC à l’intérieur du widget.


3. Event State

Il est parfois extrêmement difficile de programmer une série d’activités pouvant être séquentielles ou parallèles, longues ou courtes, synchrones ou asynchrones et qui peuvent également conduire à divers résultats. Vous pourriez également avoir besoin de mettre à jour l’affichage avec la progression ou en fonction des états.

Ce premier cas d’utilisation vise à faciliter la gestion d’une telle situation.

Cette solution repose sur le principe suivant:

  • un événement est émis;
  • cet événement déclenche une action menant à un ou plusieurs états;
  • chacun de ces états pourrait à son tour émettre d’autres événements ou mener à un autre état;
  • ces événements déclencheraient alors d’autres actions, basées sur l’état actif;
  • etc…

Pour illustrer ce concept, prenons 2 exemples courants:

  • Initialisation de l’application

    Supposons que vous deviez exécuter une série d’actions pour initialiser une application. Les actions peuvent être liées à des interactions avec un serveur (par exemple, charger des données).
    Au cours de ce processus d’initialisation, vous devrez peut-être afficher une barre de progression avec une série d’images pour que l’utilisateur patiente.

  • Authentification

    Au démarrage, une application peut nécessiter l’authentification ou l’enregistrement d’un utilisateur.
    Une fois l’utilisateur authentifié, il est redirigé vers la page principale de l’application. Ensuite, si l’utilisateur se déconnecte, il est redirigé vers la page d’authentification.

Afin de pouvoir gérer tous les cas possibles, séquences d’événements, mais aussi, si nous considérons que des événements pourraient être déclenchés n’importe où dans l’application, cela pourrait devenir assez difficile à gérer.

C’est là que le BlocEventState, associé à un BlocEventStateBuilder, peut beaucoup aider…

3.1. BlocEventState

L’idée derrière l’utilisation d’un BlocEventState est de définir un BLoC qui:

  • accepte des Events (=événements) en entrée;
  • fasse appel à un eventHandler lorsqu’un nouvel event est émis;
  • le eventHandler est responsable pour la prise en charge des actions appropriées en fonction de l’event et d’émettre en réponse un ou plusieurs State (=état).

L’image suivante montre l’idée générale:

BlocEventState

Voici le code source de cette classe. L’explication viendra juste après:

Comme vous pouvez le constater, il s’agit d’une classe abstraite (=abstract) qui doit être étendue pour définir le comportement de la méthode eventHandler.

Elle expose:

  • un Sink (emitEvent) pour émettre un Event;
  • un Stream (state) pour réagir aux State émis.

Au moment de l’initialisation (cfr Constructor):

  • un initialState (=état initial) doit être fourni;
  • il crée un StreamSubscription pour répondre aux Events émis et
    • les envoyer au eventHandler
    • émettre les state(s) résultants.

3.2. BlocEventState spécialisé

Le modèle à utiliser pour implémenter un tel BlocEventState est donné ci-dessous. Juste après, nous allons en implémenter de véritables.

Ne vous inquiétez pas si ce modèle ne compile pas… Ceci est normal car nous n’avons pas encore défini BlocState.notInitialized()… Cela viendra dans quelques instants.

Ce template fournit simplement un initialState au moment de l’initialisation et le code de l’eventHandler.

Quelque chose de très intéressant est à noter ici. Nous utilisons le générateur asynchrone: async* et l’instruction yield.

En marquant une fonction avec le modificateur async*, la fonction est identifiée comme un générateur asynchrone:

Chaque fois que l’instruction yield est appelée, elle ajoute le résultat de l’expression qui suit le yield à la sortie Stream.

Ceci est particulièrement utile si nous devons émettre une séquence de States, résultant d’une série d’actions (nous le verrons plus tard de manière pratique)

Pour un complément d’informations sur les asynchronous generators, suivez ce lien.

3.3. BlocEvent et BlocState

Comme vous l’avez remarqué, nous avons défini les classes abstraites BlocEvent et BlocState.

Ces classes doivent être étendues (=extends) à des événements et états (=event, state) spécialisés que vous voulez émettre.

3.4. BlocEventStateBuilder Widget

Le dernier élément de ce modèle est le widget BlocEventStateBuilder qui vous permet de répondre aux BlocState, émis par le BlocEventState.

Voici son code source:

Ce Widget n’est rien d’autre qu’un StreamBuilder spécialisé, qui invoquera l’argument d’entrée builder à chaque fois qu’un nouveau BlocState sera émis.


OK. Maintenant que nous avons toutes les pièces, il est temps de montrer ce que nous pouvons en faire …

3.5. Cas 1: Initilisation d’une Application

Ce premier exemple illustre le cas où votre application doit exécuter certaines tâches au moment du démarrage.

Une utilisation courante est un jeu qui affiche initialement un écran de démarrage (animé ou non) pendant qu’il récupère certains fichiers du serveur, vérifie si de nouvelles mises à jour sont disponibles, tente de se connecter à un centre de jeu (=game center)… avant d’afficher l’écran principal. Afin de ne pas donner l’impression que l’application soit bloquée, il est possible qu’une barre de progression, des images s’affichent à intervalles réguliers pendant le processus d’initialisation.

La mise en oeuvre que je vais vous montrer est très simple. Cet exemple n’affichera qu’une progression en termes de pourcentage à l’écran, mais cet exemple pourra être très facilement étendu à vos besoins.

La première chose à faire est de définir les événements et les états…

3.5.1. ApplicationInitializationEvent

Pour cet exemple, je ne considérerai que 2 événements:

  • start: cet événement déclenchera le processus d’initialisation;
  • stop: cet événement pourrait être utilisé pour forcer l’arrêt du processus d’initialisation.

Voici la définition:

3.5.2. ApplicationInitializationState

Cette classe fournira les informations relatives au processus d’initialisation.

Pour cet exemple, je considérerai:

  • 2 flags:
    • isInitialized pour indiquer si l’initialisation est terminée
    • isInitializing pour savoir si nous sommes en train d’initialiser
  • le taux d’avancement

Voici son code source:

3.5.3. ApplicationInitializationBloc

Ce BLoC est responsable du traitement du processus d’initialisation en fonction des événements.

Voici le code:

Quelques explications:

  • lorsque l’événement “ApplicationInitializationEventType.start” est reçu, on commence à compter de 0 à 100 (par pas de 10) et, pour chacune des valeurs (0, 10, 20, …), on émet (via le yield) un nouvel état qui indique que l’initialisation est en cours d’exécution (isInitializing = true) et sa valeur progress.
  • lorsque l’événement “ApplicationInitializationEventType.stop” est reçu, on considère que l’initialisation est terminée.
  • comme vous pouvez le voir, j’ai mis un peu de retard dans la boucle du compteur. Cela vous montre comment utiliser un Future (par exemple, dans le cas où vous auriez besoin de contacter le serveur)

3.5.4. Mettre tout ensemble

Maintenant, la partie restante consiste à afficher le pseudo écran de démarrage qui affiche le compteur…

Explication:

  • Comme ApplicationInitializationBloc n’a pas besoin d’être utilisé n’importe où dans l’application, nous pouvons l’initialiser dans un StatefulWidget;
  • Nous émettons directement l’événement ApplicationInitializationEventType.start pour déclencher le eventHandler
  • Chaque fois qu’un ApplicationInitializationState est émis, nous mettons à jour le texte
  • Lorsque l’initialisation est terminée, nous redirigeons l’utilisateur vers la page Home.

Astuce

Comme à l’intérieur d’un build, nous ne pouvons pas rediriger directement vers la page Home, nous utilisons la méthode WidgetsBinding.instance.addPostFrameCallback() pour demander à Flutter d’exécuter une méthode dès que le rendu est terminé


3.6. Cas 2: Authentification et Déconnexion

Pour cet exemple, considérons le cas d’utilisation suivant:

  • au démarrage, si l’utilisateur n’est pas encore authentifié, la page Authentification / Enregistrement est automatiquement affichée;
  • lors de l’authentification de l’utilisateur, un CircularProgressIndicator est affiché;
  • une fois authentifié, l’utilisateur est redirigé vers la page Home;
  • n’importe où dans l’application, l’utilisateur a la possibilité de se déconnecter;
  • lorsque l’utilisateur se déconnecte, l’utilisateur est automatiquement redirigé vers la page Authentification.

Bien sûr, il est très possible de gérer tout cela par programmation, mais il est beaucoup plus facile de déléguer tout cela à un BLoC.

Le diagramme suivant montre la solution que je vais expliquer:

BlocAuthentication

Une page intermédiaire, appelée “DecisionPage”, sera responsable de la redirection automatique de l’utilisateur vers la page Authentication ou vers la page Home, en fonction du statut de l’authentification de l’utilisateur. Cette DecisionPage n’est bien sûr jamais affichée et ne doit pas être considérée comme une page en tant que telle.

La première chose à faire est de définir les événements et les états …

3.6.1. AuthenticationEvent

Pour cet exemple, je ne considérerai que 2 événements:

  • login: cet événement est émis lorsque l’utilisateur s’authentifie correctement;
  • déconnexion: l’événement est émis lorsque l’utilisateur se déconnecte.

Voici la définition:

3.6.2. AuthenticationState

Cette classe fournira les informations relatives au processus d’authentification.

Pour cet exemple, je considérerai:

  • 3 flags:
    • isAuthenticated pour indiquer si l’authentification est complète
    • isAuthenticating pour savoir si nous sommes en train de nous authentifier
    • hasFailed pour indiquer que l’authentification a échoué
  • le nom de l’utilisateur une fois authentifié

Voici son code source:

3.5.3. AuthenticationBloc

Ce BLoC est responsable du traitement du processus d’authentification en fonction des événements.

Voici le code:

Quelques explications:

  • lorsque l’événement “AuthenticationEventLogin” est reçu, on émet (via le yield) un nouvel état indiquant que l’authentification est en cours d’exécution (isAuthenticating = true).
  • on exécute ensuite l’authentification et, une fois cela fait, on émet un autre état qui indique que l’authentification est terminée.
  • lorsque l’événement “AuthenticationEventLogout” est reçu, on affiche un nouvel état indiquant que l’utilisateur n’est plus authentifié.

3.5.4. AuthenticationPage

Comme vous allez le voir, cette page est très basique et ne fait pas grand chose, par souci d’explication.

Voici le code. Explication just après:

Explication:

  • ligne #11: la page récupère la référence à AuthenticationBloc
  • lignes #24-70: il écoute les émissions AuthenticationState:
    • si l’authentification est en cours, il affiche un CircularProgressIndicator qui informe l’utilisateur que quelque chose se passe et l’empêche d’accéder à la page (lignes #25-27)
    • Si l’authentification est réussie, nous n’avons pas besoin d’afficher quoi que ce soit (lignes 29 à 31).
    • si l’utilisateur n’est pas authentifié, il affiche 2 boutons pour simuler une authentification réussie ou un échec.
      • Lorsque nous cliquons sur l’un de ces boutons, nous émettons un événement AuthenticationEventLogin, ainsi que certains paramètres (qui seront normalement utilisés par le processus d’authentification).
      • si l’authentification a échoué, nous affichons un message d’erreur (lignes 60 à 64)

C’est tout! Rien d’autre à faire… Facile n’est-ce pas?

Astuce

Comme vous l’avez peut-être remarqué, j’ai enveloppé la page dans un WillPopScope.

La raison est que je ne veux pas que l’utilisateur puisse utiliser le bouton “Back” d’Android, car dans cet exemple, l’authentification est une étape obligatoire qui empêche à l’utilisateur d’accéder à une autre partie s’il n’est pas correctement authentifié.


3.5.5. DecisionPage

Comme indiqué précédemment, je souhaite que l’application automatise la redirection vers AuthenticationPage ou HomePage, en fonction du statut d’authentification.

Voici le code de cette DecisionPage, l’explication viendra juste après:

Rappel

Pour expliquer ceci en détail, nous devons revenir à la façon dont les Pages (= Route) sont gérées par Flutter. Pour gérer les Routes, nous utilisons un Navigator, qui crée un Overlay.

Cet Overlay est une Stack de OverlayEntry, chacun contenant une Page.

Lorsque nous ajoutons, retirons, remplaçons (=push, pop, replace) une page via le Navigator.of(context), ce dernier met à jour son Overlay (donc le Stack), qui est reconstruit (=build).

Lorsque le Stack est reconstruit, chaque OverlayEntry (et donc son contenu) est également reconstruit.

En conséquence, toutes les pages restantes sont reconstruites lorsque nous effectuons une opération via le Navigator.of(context)!

  • Alors, pourquoi ai-je implémenté cela en tant que StatefulWidget?

    Pour pouvoir réagir à tout changement de AuthenticationState, cette “page” doit rester présente tout au long du cycle de vie de l’application.

    Cela signifie que, suivant le rappel ci-dessus, cette page sera reconstruite à chaque fois qu’une action est effectuée par le Navigator.of(context).

    En conséquence, son BlocEventStateBuilder sera également reconstruit, en invoquant sa propre méthode builder.

    Parce que ce builder est responsable de la redirection de l’utilisateur vers la page qui correspond à AuthenticationState, si nous redirigeons l’utilisateur à chaque fois que la page est reconstruite, elle continuera à être redirigée indéfiniment.

    Pour éviter que cela ne se produise, nous devons simplement mémoriser le dernier AuthenticationState pour lequel nous avons effectué une action et considérer une autre action uniquement lorsqu’un autre AuthenticationState est reçu.

  • Comment cela marche-t-il?

    Comme indiqué précédemment, BlocEventStateBuilder appelle son builder à chaque fois qu’un AuthenticationState est émis.

    En fonction des indicateurs d’état (isAuthenticated), nous savons vers quelle page nous devons rediriger l’utilisateur.

Astuce

Comme nous ne pouvons pas rediriger directement vers une autre page à partir du builder, nous utilisons la méthode WidgetsBinding.instance.addPostFrameCallback() pour demander à Flutter d’exécuter une méthode dès que le rendu est terminé

Aussi, comme nous devons supprimer toute page existante avant de rediriger l’utilisateur, sauf cette DecisionPage, qui doit rester en toutes circonstances, nous utilisons le Navigator.of(context).pushAndRemoveUntil(…) pour y parvenir.


3.5.8. Déconnexion

Pour permettre à l’utilisateur de se déconnecter, vous pouvez maintenant créer un “LogOutButton” et le placer n’importe où dans l’application.

Ce bouton doit simplement émettre un événement AuthenticationEventLogout(), qui conduira à la chaîne automatique d’actions suivante:

  1. il sera géré par le AuthenticationBloc
  2. qui à son tour émettra un AuthentiationState (isAuthenticated = false)
  3. qui sera géré par la DecisionPage via le BlocEventStateBuilder
  4. qui redirigera l’utilisateur vers le AuthenticationPage

Voici le code de ce bouton:


3.5.7. AuthenticationBloc

Comme AuthenticationBloc doit être disponible pour toute page de cette application, nous l’injecterons également en tant que parent du MaterialApp, comme suit:


4. Validation de formulaire

Une autre utilisation intéressante d’un BLoC est lorsque vous devez valider un formulaire et:

  • valider l’entrée liée à un TextField par rapport à certaines règles business;
  • afficher les messages d’erreur de validation en fonction des règles business;
  • automatiser l’accessibilité des Widgets, en fonction des règles business.

L’exemple que je vais maintenant prendre est un RegistrationForm composé de 3 TextFields (email, mot de passe, confirmation du mot de passe) et 1 RaisedButton pour lancer le processus d’inscription.

Les règles de gestion que je souhaite implémenter sont les suivantes:

  • le champ email doit être une adresse email valide. Sinon, un message d’erreur doit être affiché.
  • Le champ mot de passe doit être valide (doit contenir au moins 8 caractères, dont 1 majuscule, 1 minuscule, 1 chiffre et 1 caractère spécial). Si non valide, un message d’erreur doit être affiché.
  • le champ confirmer le mot de passe doit respecter la même règle de validation ET être le même que le mot de passe. Si ce n’est pas pareil, un message doit être affiché.
  • Le bouton Register ne peut être actif que lorsque TOUTES les règles business ont été validées.

4.1. RegistrationFormBloc

Ce BLoC est responsable du traitement des règles business de validation, comme indiqué précédemment.

Voici son code source:

Voyons tout cela en détail…

  • Nous initialisons d’abord 3 BehaviorSubject pour gérer les Streams correspondant à chaque TextField du formulaire.
  • Nous exposons 3 Function(String) qui seront utilisées pour accepter les entrées des TextFields.
  • Nous exposons 3 Stream<String> qui seront utilisés par chaque TextField pour afficher un message d’erreur potentiel, résultant de leur validation respective.
  • Nous exposons 1 Stream<bool> qui sera utilisé par le RaisedButton pour l’activer / le désactiver en fonction du résultat de validation complet.

Ok, il est maintenant temps de plonger dans davantage de détails…

Comme vous l’avez peut-être remarqué, la signature de cette classe est un peu spéciale. Regardons de plus près.

class RegistrationFormBloc extends Object 
                           with EmailValidator, PasswordValidator 
                           implements BlocBase {
  ...
}

Le mot-clé with signifie que cette classe utilise des MIXINS (= “une façon de réutiliser du code de classe dans une autre classe”) et, pour pouvoir utiliser le mot-clé with, la classe doit étendre la classe Object. Ces mixins contiennent le code qui valide les emails et passwords, respectivement.

Pour plus de détails sur les Mixins, je vous recommende cet excellent article de Romain Rastel.

4.1.1. Validator Mixins

Je n’expliquerai que le EmailValidator puisque le PasswordValidator est très similaire.

Tout d’abord, le code:

Cette classe expose une fonction final (”validateEmail”) qui est un StreamTransformer.

Rappel

Un StreamTransformer est invoqué comme suit: stream.transform(StreamTransformer).

Le StreamTransformer tire son entrée du Stream qui s’y réfère via la méthode transform. Il traite ensuite cette information et la réinjecte, une fois transformée, dans le stream initial.

Dans ce code, le traitement de l’entrée consiste à la comparer à une expression régulière. Si l’entrée correspond à l’expression régulière, nous réinjectons simplement l’entrée dans le stream, sinon, nous injectons un message d’erreur dans le stream.

4.1.2. Pourquoi utiliser un stream.transform()?

Comme indiqué précédemment, si la validation réussit, le StreamTransformer réinjecte l’entrée dans le Stream. Mais, pourquoi est-ce utile?

Voici l’explication liée à Observable.combineLatest3()… Cette méthode n’émettra aucune valeur tant que tous les streams auxquels elle fait référence n’auront pas émis au moins une valeur.

Regardons l’image suivante pour illustrer ce que nous voulons réaliser.

Observable.combineLatest3

  • si l’utilisateur entre un email et que ce dernier est validé, il sera émis par le stream email qui constituera une entrée de Observable.combineLatest3();
  • si l’email n’est pas valide, une erreur sera ajoutée au stream (et aucune valeur ne sortira du stream);
  • la même chose s’applique à password et retyped password;
  • quand toutes ces 3 validations seront réussies (ce qui signifie que tous ces 3 streams émettront une valeur), le Observable.combineLatest3() émettra à son tour un true grâce au “(e, p, c) => true” (voir ligne 35).

4.1.2. Validation de 2 passwords

J’ai vu beaucoup de questions liées à cette comparaison sur Internet. Plusieurs solutions existent, laissez-moi vous expliquer 2 d’entre elles.

4.1.2.1. Solution de base - pas de message d’erreur

Une première solution pourrait être la suivante:

Cette solution compare simplement les 2 mots de passe dès qu’ils ont été validés et, s’ils correspondent, émet une valeur (= true).

Comme nous le verrons bientôt, l’accessibilité du bouton Enregistrer dépendra de ce stream registerValid.

Si les deux mots de passe ne correspondent pas, ce stream n’émet aucune valeur et le bouton Enregistrer reste inactif mais l’utilisateur ne recevra aucun message d’erreur l’aidant à comprendre pourquoi.

4.1.2.2. Solution avec message d’erreur

Une autre solution consiste à étendre le traitement du stream confirmPassword comme suit:

Une fois que le mot de passe confirmPassword a été validé, il est émis par le Stream et, à l’aide du doOnData, nous pouvons obtenir directement cette valeur émise et la comparer à la valeur du stream password. Si les deux ne correspondent pas, nous avons maintenant la possibilité d’envoyer un message d’erreur.


4.2. RegistrationForm

Regardons maintenant le RegistrationForm avant de l’expliquer:

Explication:

  • Comme RegisterFormBloc est uniquement destiné à être utilisé par ce formulaire, il est accceptable de l’initialiser ici.
  • Chaque TextField est encapsulé dans un StreamBuilder<String> pour pouvoir répondre à tout résultat du processus de validation (cfr errorText: snapshot.error)
  • Chaque fois qu’une modification est appliquée au contenu d’un TextField, nous envoyons l’entrée au BLoC pour validation via onChanged: _registrationFormBloc.onEmailChanged (cas de saisie de l’email)
  • Pour le RegisterButton, ce dernier est également encapsulé dans un StreamBuilder<bool>.
    • Si une valeur est émise par _registrationFormBloc.registerValid, la méthode onPressed fera quelque chose.
    • Si aucune valeur n’est émise, la méthode onPressed sera assignée à null, ce qui désactivera le bouton.

C’est tout! Aucune règle business n’apparaît dans le formulaire, ce qui signifie que les règles peuvent être modifiées sans qu’il soit nécessaire de modifier le formulaire, ce qui est excellent!


5. Part Of

Parfois, il est intéressant pour un Widget de savoir s’il fait partie d’un ensemble pour adapter son comportement.

Pour ce dernier cas d’utilisation de cet article, nous allons considérer le scénario suivant:

  • une application traite des articles;
  • un utilisateur peut sélectionner des articles à mettre dans son panier;
  • un article ne peut être placé dans le panier qu’une seule fois;
  • un article, présent dans le panier, peut être retiré du panier;
  • une fois retiré, il est possible de le remettre en place.

Pour cet exemple, chaque article affichera un bouton qui dépendra de la présence de l’article dans le panier. S’il ne fait pas partie du panier, le bouton permettra à l’utilisateur de l’ajouter au panier. S’il fait partie du panier, le bouton permettra à l’utilisateur de le retirer du panier.

Pour mieux illustrer le modèle “Part of”, je considérerai l’architecture suivante:

  • une ShoppingPage affiche la liste de tous les articles possibles;
  • chaque article dans la ShoppingPage affiche un bouton pour ajouter l’article au panier ou le supprimer, en fonction de sa présence dans le panier;
  • si un article dans la ShoppingPage est ajouté au panier, son bouton sera automatiquement mis à jour pour permettre à l’utilisateur de le supprimer du panier (et vice versa) sans avoir à reconstruire la ShoppingPage
  • une autre page, Shopping Basket, listera tous les articles présents dans le panier;
  • il sera possible de retirer n’importe quel article du panier, à partir de cette page.

Note

Le nom Part Of est un nom que j’ai donné à ce pattern. Ce n’est pas un nom officiel.

5.1. ShoppingBloc

Comme vous pouvez maintenant l’imaginer, nous devons envisager un BLoC dédié au traitement de la liste de tous les articles possibles et de ceux faisant partie du Panier.

Ce BLoC pourrait ressembler à ceci:

La seule méthode pouvant nécessiter une explication est la méthode _postActionOnBasket(). Chaque fois qu’un élément est ajouté ou supprimé du panier, nous devons “actualiser” le contenu du stream _shoppingBasketController afin que tous les Widgets qui écoutent les modifications apportées à ce stream soient informés et puissent être actualisés.

5.2. ShoppingPage

Cette page est très simple et affiche uniquement tous les éléments.

Explication:

  • le Widget AppBar affiche un bouton qui:
    • affiche le nombre d’articles présents dans le panier
    • redirige l’utilisateur vers la page ShoppingBasket lorsque l’utilisateur clique dessus
  • la liste d’éléments est construite à l’aide d’un GridView, encapsulé dans un StreamBuilder<List<ShoppingItem>>
  • chaque article correspond à un ShoppingItemWidget

5.3. ShoppingBasketPage

Cette page est très similaire à la ShoppingPage sauf que le StreamBuilder écoute maintenant les variations du stream _shoppingBasket exposé par le ShoppingBloc.


5.4. ShoppingItemWidget et ShoppingItemBloc

Le pattern Part Of repose sur la combinaison de ces 2 éléments:

  • Le ShoppingItemWidget est responsable de:
    • l’affichage de l’article, et
    • de bouton pour ajouter ou supprimer l’article dans/du panier
  • le ShoppingItemBloc est responsable de dire à ShoppingItemWidget si ce dernier fait partie ou non du panier.

Voyons comment ils travaillent ensemble …

5.4.1. ShoppingItemBloc

Le ShoppingItemBloc est instancié par chaque ShoppingItemWidget, ce qui lui donne son “identité”.

Ce BLoC écoute toutes les variations du stream ShoppingBasket et vérifie si l’identité de l’article spécifique fait partie du panier.

Si oui, il émet une valeur boolean (= true), qui sera capturée par le ShoppingItemWidget pour savoir s’il fait partie du panier ou non.

Voici le code du BLoC:

5.4.2. ShoppingItemWidget

Ce widget est responsable:

  • de créer une instance de ShoppingItemBloc et passer sa propre identité au BLoC
  • d’écouter toute variation du contenu ShoppingBasket et le transférer vers le BLoC
  • d’écouter le ShoppingItemBloc pour savoir si cela fait partie du panier
  • de l’affichage du bouton correspondant (ajouter/supprimer) en fonction de sa présence dans le panier
  • de répondre à l’action utilisateur du bouton
    • lorsque l’utilisateur clique sur le bouton add, pour s’ajouter au panier
    • lorsque l’utilisateur clique sur le bouton remove, pour se retirer du panier.

Voyons comment cela fonctionne (l’explication est donnée dans le code).

5.5. Comment cela fonctionne-t-il, finalement?

Le diagramme suivant montre comment toutes les pièces fonctionnent ensemble.

Part_Of


Conclusions

Un autre long article que j’aurai souhaité un peu plus court, mais je pense que tout cela méritait quelques explications.

Comme je l’ai dit dans l’introduction, j’utilise personnellement ces “patterns” très fréquemment dans mes développements. Cela me fait économiser énormément de temps et d’efforts; mon code est plus lisible et plus facile à déboguer.

En outre, cela permet de séparer les notions de Business et View.

Il y a très certainement d’autres moyens de le faire et des moyens encore meilleurs, mais cela fonctionne simplement pour moi et c’est tout ce que je voulais partager avec vous.

Restez à l’écoute de nouveaux articles et d’ici-là, je vous souhaite un bon codage…