BLoC, Scoped Model, Redux… Comparaison et quand les utiliser?

Difficulté: Débutant

Introduction

BLoC, ScopedModel, Redux… différences, quand les utiliser, quand NE PAS les utiliser, avantages, inconvénients…

Beaucoup de questions fréquemment posées sur ce sujet et tant de réponses peuvent être trouvées sur Internet mais y a t-il un seul choix?

Afin de fournir ma propre analyse, j’ai examiné 2 types de cas d’utilisation distincts, ai construit une solution rapide pour couvrir ces cas à l’aide des 3 frameworks et les ai ensuite comparés.

Le code source complet qui couvre les solutions Redux, ScopedModel et BLoC est disponible sur GitHub.


Partie 1: Que sont ces frameworks?

Afin de mieux comprendre les différences, je pense qu’il pourrait être utile de rappeler de manière succincte leurs principes fondamentaux.

Redux

Introduction

Redux est un framework de Gestion d’Etat d’une Application (= Application State Management). En d’autres termes, son objectif principal est de gérer un État (= State).

Redux est architecturé autour des principes suivants:

  • Flux de données Unidirectionnel

  • Un seul Store

    Un Store agit comme un chef d’orchestre pour Redux. Le Store:

    • maintient un seul State
    • expose un seul point d’entrée, appelé dispatch qui n’accepte que des Actions en argument
    • expose un getter pour récupérer le State courant
    • permet de s’enregistrer pour être informé (via StreamSubscription) de toute modification appliquée au State
    • envoie les actions et le store au premier MiddleWare
    • envoie les actions et le state actuel à un Reducer (qui pourrait être une façade pour plusieurs reducers)
  • Actions

    • Les Actions sont les seuls types d’informations qui sont acceptées par le Store.
    • Les Actions, combinées au State actuel, sont utilisées par le Middleware et le Reducer pour traiter certaines fonctions, ce qui pourrait entraîner une modification du State.

    Les Actions ne décrivent qu’un événement du passé (= qui s’est déjà déroulé)

  • MiddleWare

    Un Middleware est une fonction généralement utilisée pour une exécution asynchrone (mais pas nécessairement), basée sur une Action. Un Middleware utilise simplement un State (ou une Action comme déclencheur) mais ne modifie pas le State.

  • Reducers

    Un Reducer est une fonction normalement synchrone qui effectue certains traitements en fonction de la combinaison Action - State. Le résultat du traitement pourrait conduire à un nouveau State.

    Le Reducer est le seul qui soit autorisé à changer le State.

Il est important de noter que, selon les recommandations et les bonnes pratiques de Redux, il ne devrait exister qu’ un seul Store par application. Pour scinder la logique de traitement des données, il est conseillé d’utiliser une ‘composition de reducers’ au lieu de nombreux Stores.

Comment fonctionne Redux ?

L’animation suivante montre le fonctionnement interne de Redux:

Redux Redux

Explications:

  • Lorsque quelque chose se passe au niveau de l’interface utilisateur (en fait, ce n’est pas limité au UI), une Action est créée et envoyée au Store (via store.dispatch(action));
  • Si un ou plusieurs Middlewares sont configurés, ils sont appelés en séquence en leur transmettant l’Action et une référence au Store (donc, également au State, indirectement);
  • Les middlewares peuvent eux-mêmes envoyer une Action au Store au cours de leur traitement;
  • Ensuite, les Actions et State courant sont également envoyés au Reducer.
  • Le Reducer est le seul qui puisse potentiellement changer le State
  • Lorsque le State est modifié, le Store informe tous ceux à l’écoute.
  • L’UI (sans toutefois s’y limiter) peut alors prendre les mesures appropriées liées au changement du State.

Implémentations

Redux a été initialement écrit pour Javascript et a été porté en Dart en tant que package.

Un package dédié à Flutter (flutter_redux) fournit des Widgets orientés Redux, tels que:

  • StoreProvider pour passer le Store aux Widgets enfants
  • StoreBuilder pour récupérer le Store à partir d’un StoreProvider et le passer à la fonction builder d’un Widget
  • StoreConnector pour récupérer le Store à partir du StoreProvider parent le plus proche, et le convertir en ViewModel pour le passer à une fonction builder.

ScopedModel

Introduction

ScopedModel est un ensemble d’utilitaires qui permettent de passer un Model d’un Widget parent vers ses descendants.

ScopedModel est articulé autour de 3 classes:

  • Model

    Un Model est une classe qui contient les données et la logique métier liée aux données. Il est implémenté en tant que Listenable et peut informer tout ceux intéressés, qu’un changement a été appliqué au Model.

  • ScopedModel

    ScopedModel est un Widget, similaire à un Provider, qui ‘contient’ le Model et permet:

    • la récupération du Model, via l’appel habituel ScopedModel.of<Model>(context)
    • l’enregistrement d’un context en tant que dépendence du InheritedWidget sous-jacent.

    Le ScopedModel est basé sur un AnimatedBuilder qui écoute les notifications envoyées par le Model, reconstruit le InheritedWidget qui, à son tour, demandera à toutes ses dépendences de se reconstruire.

  • ScopedModelDescendant

    ScopedModelDescendant est un Widget qui réagit aux variations du Model; il se reconstruit (= build) lorsque le Model notifie qu’un changement a eu lieu.

Comment cela fonctionne-t-il ?

L’extrait de code suivant imite l’application “counter”, à l’aide du ScopedModel.

Scoped Model (code) Scoped Model (code)

L’animation suivante montre ce qui se passe lorsque l’utilisateur appuie sur le Bouton.

Scoped Model Scoped Model

Explications:

  • Lorsque l’utilisateur appuie sur le RaisedButton, la méthode model.increment() est appelée.
  • Cette méthode incrémente simplement la valeur du compteur, puis appelle l’API notifyListeners(), disponible grâce au fait que le Model hérite de la classe Listenable.
  • Cet appel au notifyListeners() est intercepté par AnimatedBuilder, qui reconstruit son enfant de type InheritedWidget.
  • Le InheritedWidget insère la Column et ses enfants
  • L’un des enfants est le ScopedModelDescendant, qui appelle son constructeur et fait référence au Modèle qui contient maintenant une nouvelle valeur du compteur.

BLoC

Introduction

Le modèle BLoC ne nécessite aucune bibliothèque ou package externe car il repose simplement sur l’utilisation des Streams. Toutefois, pour accéder à des fonctionnalités plus conviviales (par exemple Sujet), il est très souvent associé au package RxDart.

Le modèle BLoC s’appuie sur:

  • StreamController

    Un StreamController expose un StreamSink pour injecter des données dans le Stream et un Stream pour écouter des données, circulant à l’intérieur du Stream.

  • StreamBuilder

    Un StreamBuilder est un Widget à l’écoute d’un stream et qui est reconstruit lorsque des nouvelles données sont émises par ce stream.

  • StreamSubscription

    Un StreamSubscription permet d’écouter un stream pour récupérer les données qui y circulent.

  • BlocProvider

    Un BlocProvider est un Widget utilitaire, généralement utilisé pour “contenir” un BLoC et le rendre disponible aux Widgets enfants.

Comment cela fonctionne-t-il ?

L’animation suivante montre le fonctionnement du BLoC lorsque nous injectons des données dans l’un de ses sinks ou via une API.
(Je sais, les BLoC ne sont supposés être utilisés qu’avec Sinks et des Streams … mais rien n’empêche vraiment d’utiliser aussi une API …)

BLoC BLoC

Explications:

  • Certaines données sont injectées dans l’un des sinks du BLoC
  • Les données sont traitées par le BLoC qui émet éventuellement des données à partir de l’un des flux de sortie
  • La même chose peut également s’appliquer lors de l’utilisation de l’API du BLoC

Pour une explication supplémentaire sur la notion de BLoC, veuillez consulter mes 2 autres articles sur le sujet:


Partie 2

Maintenant que nous avons une meilleure idée de leur nature et de leur façon de fonctionner, comparons-les…

Pour ce faire, je vais considérer 2 cas d’utilisation pour illustrer leurs différences, avantages et inconvénients…

Cas 1: Authentification

Ce cas d’utilisation courant est très intéressant car il implique une notion de “Application State”. Dans cet exemple, je souhaite que la page agisse comme suit:

Case 1 Case 1

  • un texte indiquant si l’utilisateur n’est pas authentifié et un bouton pour simuler une authentification;
  • un CircularProgressIndicator lorsqu’un processus d’authentification simulé est en cours;
  • le prénom et le nom de famille de l’utilisateur authentifié, ainsi qu’un bouton permettant de se déconnecter.

Comparaison du Code

Les 2 images suivantes montrent, côte à côte, les codes liés à l’initialisation de l’application et de la ‘page’, respectivement.

Case 1 - Application Case 1 - Application
Case 1 - Page Case 1 - Page

Comme on peut le constater, il n’y a pas beaucoup de différences.

Cette absence de grande différence pourrait être le résultat de la décision architecturale que j’ai prise, étant donné que je pensais qu’il était nécessaire que nous puissions avoir à nous déconnecter depuis n’importe quel endroit de l’application. Par conséquent, j’avais besoin que les solutions ScopedModel et BLoC injectent leur model et bloc respectifs en amont du MaterialApp, afin qu’ils soient disponibles ultérieurement.

Cependant, il y a des différences… Jettons-y un coup d’oeil…


Différences et Observations

Nombre de fichiers

En Redux, la solution conduit à beaucoup plus de fichiers (si nous voulons nous en tenir au paradigme “une entité, un fichier”). Même si nous regroupons les entités en fonction de leurs responsabilités (par exemple, si l’on rassemble toutes les actions ensemble), nous avons toujours plus de fichiers.

La solution ScopedModel nécessite moins de fichiers car le modèle contient à la fois les données et la logique.

La solution BLoC nécessite un fichier supplémentaire par rapport au ScopedModel, si nous souhaitons scinder à la fois modèle et logique, mais ce n’est pas obligatoire.

Execution du code

En Redux, en raison de sa manière de fonctionner, nous avons beaucoup plus de code exécuté, parfois pour rien. En effet, la façon d’écrire un reducer est basée sur des évaluations de conditions telles que: “if action is … then”, et il en va de même pour les middlewares.

De plus, et peut-être à cause de l’implémentation faite par le package flutter_redux, un StoreConnector nécessite un convertor, ce qui n’est parfois pas nécessaire. Ce convertor est destiné à fournir un moyen de produire un ViewModel.

Les solutions ScopedModel et BLoC semblent être celles qui nécessitent moins d’exécution de code.

Complexité de Code

Si vous gardez à l’esprit que c’est toujours une Action qui déclenche l’exécution séquentielle de tous les middlewares (jusqu’au moment où ils implémentent quelque chose d’asynchrone), ensuite le code du reducer (qui doit faire les choses en comparant un type d’Action), ce code est relativement simple. Cependant, très vite, il faudra utiliser la notion de composition de reducers (voir combineReducers avec TypedReducer).

La solution ScopedModel ressemble à celle qui conduit au code le plus simple: vous appelez une méthode qui met à jour le modèle qui en informe ceux à l’écoute. Cependant, il n’est pas évident pour ceux qui sont à l’écoute de connaître la raison qui a conduit à la notification puisque toute modification du modèle génère des notifications (même si cela n’a aucun intérêt pour cet auditeur particulier).

La solution BLoC est un peu plus complexe car elle implique la notion de Streams.

Entre nous, la solution flutter_redux repose également sur l’utilisation de Streams, mais cela est masqué du point de vue du développeur.

Nombre de (re-)Builds

Si nous regardons le nombre de fois où des parties de l’application sont reconstruites, cela devient intéressant…

En interne, comme flutter_redux utilise la notion de Streams pour réagir aux modifications appliquées au State, si vous n’essayez pas d’accéder au Store via l’API StoreProvider.of(context), seul le StoreConnector sera reconstruit (comme implémenté à l’aide d’un StreamBuilder). Cela rend l’implémentation du flutter_redux intéressante dans une perspective “build”.

La solution ScopedModel est celle qui produit le plus grand nombre de (re-)constructions (= build) car chaque fois qu’un modèle notifie ses écouteurs, il reconstruit l’arbre entier sous le widget ScopedModel() (en fait, sous le AnimatedBuilder sous-jacent).

La solution BLoC, basée idéalement sur StreamBuilder pour répondre aux modifications, est, avec flutter_redux, celle qui provoque les moins de (re-)constructions (uniquement la partie relative au StreamBuilder est reconstruite).

Isolation de code

En Redux, les reducers et les middlewares sont “habituellement” mais pas nécessairement des fonctions/méthodes de niveau supérieur ne faisant pas partie d’une classe. En conséquence, rien n’empêcherait de les appeler en dehors du cadre du Store, ce qui ne serait pas idéal.

Les deux modèles ScopedModel et BLoC ont tendance à isoler le code: une classe spécifique pour le modèle ou le bloc.


Cas 1: Conclusions

Pour ce cas particulier mais aussi parce qu’il n’y a pas de contrainte liée à la performance (nombre de (re-)constructions), je ne vois personnellement aucun avantage à opter pour l’une ou l’autre solution.

Le petit avantage que je voudrais donner à Redux est la possibilité d’insérer un middleware pour enregistrer les différentes Actions qui sont exécutées: vous devez simplement ajouter la référence de la méthode middleware lors de l’initialisation du Store.

Cela nécessiterait beaucoup d’efforts pour ScopedModel et pour BLoC.


Cas 2:

Dans ce cas, nous allons simuler un type de tableau de bord où l’utilisateur peut ajouter de manière dynamique de nouveaux panneaux. Chaque panneau simule une évolution des données en temps réel (par exemple, cours de la bourse). L’utilisateur peut activer / désactiver la récupération de données en temps réel pour chacun de ces panneaux, individuellement.

Voici à quoi ressemble l’application:

Case 2 Case 2

Comparaison du Code

Comme je voulais m’en tenir au principe de base Redux (un Store par application), il était plus difficile de l’implémenter car le ApplicationState est obligé de mémoriser et de gérer chaque panneau.

Maintenant, si vous ne voulez pas vous en tenir à ce principe, rien ne vous empêche réellement d’utiliser plusieurs Stores, un par Panel. En procédant de cette façon, le code est un peu plus simple.

En ce qui concerne les versions ScopedModel et BLoC, les codes sont très similaires.


Différences et Observations

Nombre de fichiers

Dans ce cas également, Redux nécessite plus de fichiers (même si nous regroupons autant que possible) par rapport aux 2 autres solutions.

Dans ce cas, les solutions ScopedModel et BLoC nécessitent le même nombre de fichiers.

Exécution de Code

Les mêmes commentaires que pour le cas 1.

Redux exécute beaucoup plus de code que les solutions ScopedModel et BLoC car le reducer est basé sur des évaluations de conditions telles que: “if action is … then”, et il en va de même pour les middlewares. De plus, 3 instances de StoreConnector sont nécessaires:

  • au niveau Page pour ajouter un nouveau Panel
  • au niveau du Widget pour récupérer les statistiques
  • au niveau du Widget pour gérer le bouton qui active / désactive les statistiques.

ScopedModel nécessite l’exécution de code supplémentaire par rapport à BLoC car ScopedModel repose sur la notion de Listenable/InheritedWidget pour être reconstruit à chaque fois que le Modèle change.

La solution basée sur ScopedModel nécessite, par Panel:

  • un ScopedModel (= injecteur)
  • un ScopedModelDescendant pour gérer l’affichage des statistiques
  • un ScopedModelDescendant pour gérer le bouton qui active / désactive les statistiques

BLoC est celui qui exécute le moins de code. Par Panel, la solution demande:

  • un StreamBuilder pour afficher les statistiques
  • un StreamBuilder pour gérer le bouton qui active / désactive les statistiques
Complexité du Code

La solution Redux est plus complexe car elle nécessite l’envoi d’actions depuis 3 endroits différents:

  • au niveau Page, lorsque nous devons instancier un nouveau Panel
  • au niveau Widget, lorsque nous devons activer / désactiver la minuterie via le bouton
  • au niveau Middleware, lorsqu’une nouvelle valeur de statistiques est obtenue du serveur

La complexité des solutions ScopedModel et BLoC se situe uniquement aux niveaux Model et BLoC, respectivement. Comme chaque Panel a son propre Modèle ou BLoC, le code est moins complexe.

Nombre de (re-)Builds

La solution Redux est celle qui provoque le plus de reconstructions.

Dans l’implémentation, basée sur “un Store par application”, chaque fois qu’une modification s’applique au ApplicationState, tout est reconstruit, ce qui signifie:

  • quand on ajoute un nouveau Panel
  • lorsque nous activons / désactivons la collecte de statistiques, tous les panneaux sont reconstruits
  • lorsqu’une nouvelle valeur est ajoutée à un Panel, tous les Panels sont reconstruits

Si j’avais choisi un Store par Panel, le nombre de (re-)constructions aurait été beaucoup plus limité.

En ce qui concerne la solution ScopedModel, le nombre de reconstructions est un peu plus limité:

  • quand on ajoute un nouveau Panel
  • limité à un Panel, lorsque
    • nous activons / désactivons la collecte de statistiques
    • une nouvelle valeur est collectée pour un panneau spécifique

Enfin, la solution BLoC est celle qui nécessite le moins de reconstructions:

  • quand on ajoute un nouveau Panel
  • lorsque nous activons / désactivons la collecte de statistiques, seul le bouton associé au panneau spécifique est reconstruit
  • lorsqu’une nouvelle valeur est collectée pour un Panel spécifique, seul ce Panel est reconstruit.

Cas 2: Conclusions

Pour ce cas particulier, je trouve personnellement que la solution BLoC est la meilleure option, tant du point de vue de la complexité du code que de la reconstruction (nombre de “rebuilds”).

La solution ScopedModel vient ensuite.

L’architecture Redux n’est pas optimale pour cette solution, mais il est toujours possible de l’utiliser.


Autres packages

Avant de parler de conclusions, je voulais mentionner qu’il existe plusieurs paquets supplémentaires autour de la notion de Redux, parmi lesquels les 2 suivants pourraient être intéressants pour ceux qui préfèrent encore Redux:

  • rebloc, qui combine les aspects de Redux et BLoC

    Ce package est assez intéressant mais ne résout rien de ce qui concerne l’exécution de code, lié à la notion de reducer (if action is… then). Cependant, cela vaut la peine d’y jeter un coup d’œil.

  • fish_redux, de l’équipe Alibaba Xianyu.

    Ce package n’est pas un framework de type State Management, mais plutôt un Application Framework, basé sur Redux.
    Ce package est très intéressant mais nécessite de changer totalement la façon de développer une application. Cette solution facilite la structuration du code en termes d’Actions et Reducers.


Conclusions

Cette analyse m’a permis de comparer 3 des frameworks les plus couramment utilisés (ou tout autre nom que nous pouvons utiliser pour les désigner), dans leur forme initiale, sur la base de 2 cas d’utilisation distincts.

Ces 3 frameworks ont leurs avantages et inconvénients, que je liste ci-dessous (Ceci est mon point de vue personnel, bien sûr … ):

Redux

  • Pour

    • Redux permet de centraliser la gestion d’un State grâce au fait que les Reducers sont les seuls agents pouvant effectuer la transition d’un état à un autre. Cela rend la transition d’état parfaitement prévisible et parfaitement vérifiable (= testable).
    • La facilité d’insertion des middlewares dans le flux est également un atout. Si par exemple, vous devez valider en permanence la connectivité avec le serveur ou tracer les activités, l’utilisation des middlewares se révèle très utile.
    • Il oblige le développeur à structurer l’application en termes de “Evénement -> Action -> Modèle -> ViewModel -> View”.
  • Cons

    • Un seul Store et un énorme State (si vous voulez vous en tenir aux recommandations Redux)
    • Utilisation de fonctions / méthodes de premier niveau
    • Trop de comparaisons “if … then” aux niveaux reducers et middlewares
    • Trop de reconstructions (à chaque fois qu’il y a un changement dans le State)
    • Nécessite l’utilisation d’un package externe avec les risques liés aux évolutions potentielles (non compatibles) du package.
  • Recommandé

    • Je pourrais recommander Redux lorsque vous devez traiter un état d’application global, tel que, par exemple, l’authentification d’utilisateur, un panier d’achats, les préférences (langue, devise)…
  • Non recommandé

    • Je ne recommanderais pas Redux lorsque vous devez gérer plusieurs instances de quelque chose, chacune d’entre elles ayant son propre State

Scoped Model

  • Pour

    • ScopedModel facilite le regroupement du Modèle et de sa logique dans un emplacement unique.
    • ScopedModel ne nécessite aucune connaissance de la notion de Streams. Ceci peut-être un grand avantage pour les développeurs débutants.
  • Contre

    • ScopedModel ne fournit aucun moyen de faire savoir au code quelle(s) partie(s) du Model a/ont été modifiée(s) et ce qui a provoqué l’appel au ScopedModelDescendant.
    • Trop de (re-)constructions. Chaque fois qu’un Modèle notifie à ses auditeurs, tout ce qui est lié à ce Model est reconstruit (AnimatedBuilder, InheritedWidget …)
    • Nécessite l’utilisation d’un package externe avec les risques liés aux évolutions potentielles (non compatibles) du package.
  • Recommandé

    • Quand les développeurs ne sont pas très familiers avec la notion de Streams
    • Quand le Model n’est pas trop complexe
  • Non recommandé

    • Lorsqu’une application doit réduire le nombre de “builds”, pour des raisons de performances.
    • Lorsqu’une application nécessite de savoir avec précision quelle partie du Model a été modifiée.

BLoC

  • Pour

    • BLoC facilite le regroupement de la Business Logic en un seul emplacement
    • BLoC facilite la détermination précise de la nature des modifications (via son interface de sortie, basée sur des Streams)
    • BLoC facilite très bien la limitation du nombre de (re-)constructions au strict minimum, grâce à l’utilisation du Widget StreamBuilder
    • L’utilisation de Streams est très puissante et ouvre la porte vers des fonctionnalités très riches (transform, distinct, debounce…)
    • Les BLoC peuvent être utilisés pour les logiques globale et locale
    • BLoC n’est pas limité à la Gestion d’état (= State Management)
    • Ne nécessite pas l’utilisation d’un package externe.
  • Contre

    • Si vous désirez vous tenir à la règle principale, vous ne pouvez utiliser que des sinks et des streams pour interagir avec le BLoC
      Personnellement, mes BLoCs exposent également les getters / setters / API, ce qui supprime ce “contre”.
    • Il est plus difficile pour les débutants de commencer avec BLoC car cela requière des connaissances un peu plus précises sur le fonctionnement de Flutter
  • Recommandé

    • Je ne vois aucune restriction.
  • Non recommandé

    • Je ne vois aucun cas où je recommanderais de ne pas utiliser un BLoC, sauf pour le cas où développeurs ne seraient pas à l’aise avec la notion de Streams

Par conséquent, existe-t-il une solution unique et parfaite?

En fait, je dirais qu’il n’existe pas de “une seule” solution parfaite. Cela dépend vraiment de votre cas d’utilisation et, en plus, cela dépend vraiment de fait de savoir avec quel framework vous êtes le plus à l’aise.

En conclusion, je ne parlerai que pour moi…

Jusqu’à présent, je n’ai jamais utilisé Redux dans aucun de mes projets et je n’ai jamais eu le sentiment de manquer de quoi que ce soit. La même chose s’applique à ScopedModel

Il y a plusieurs mois, j’ai commencé à travailler avec BLoC et j’utilise cette notion partout pour presque tout, c’est tellement pratique. Cela a vraiment rendu mon code plus propre, plus facile à tester, beaucoup plus structuré et réutilisable.

J’espère que cet article aura pu vous donner des informations supplémentaires…

Restez à l’écoute de nouveaux articles. En attendant, laissez-moi vous souhaiter un bon codage!