Mon application Flutter avec Redux

le 15/02/2024 par Rémi Dormoy
Tags: Software Engineering, Mobile

En choisissant de construire votre application mobile en Flutter, vous avez déjà fait le choix du meilleur framework de développement mobile : bravo à vous ! (Je sais, je suis un peu vendu 😁) Et si maintenant vous complétiez cette première étape en choisissant une librairie de state management qui vous aidera à structurer votre code, à partager simplement vos données dans toute votre application, à vous assurer de la maintenabilité de votre code, et même à améliorer votre efficacité à travailler en équipe ? Pour cela, je vous propose de découvrir REDUX et plus précisément flutter redux, de son origine à son implémentation, en passant par la théorie, ses avantages et ses inconvénients.

L’origine de REDUX

Contrairement à d’autres solutions de state management, REDUX est avant tout un pattern d’architecture, et peut donc exister dans d’autres technologies, voire même sans aucune technologie. En l’occurrence, ce n’est pas sur Flutter mais dans le monde du Web, et plus précisément du framework React, que REDUX est né.

C’est en 2016, dans les équipes de Facebook, et inspiré de Flux (une librairie issue de Facebook également) que REDUX est née. Dès ses premiers pas, certains principes forts sont énoncés, et notamment que “Redux n’est pas censé être la manière la plus rapide d’écrire du code - Il est pensé pour le rendre prédictible et compréhensible”.

D’abord une librairie dans le framework React, REDUX s’est assez vite exporté dans les autres framework Web, puis sur mobile, que ce soit en natif Android, iOS ou enfin sur Flutter. On retrouve par exemple dans la documentation officielle de Vue.js l’affirmation suivante : “Redux est en fait agnostique de la couche vue, et peut donc être facilement utilisé avec Vue” (On peut facilement remplacer “Vue” par n’importe quel framework front).

La théorie : l’architecture REDUX

Quand on parle de REDUX, quel que soit le framework sur lequel on l’applique, la confusion est souvent faite entre la librairie qui implémente REDUX et le pattern d’architecture en lui-même. Le pattern étant relativement complexe, il est plus compliqué que d’autres à implémenter “à la main”, c'est-à-dire seulement avec le langage que l’on a à disposition. Chaque framework a donc sa propre implémentation (flutter_redux pour Flutter, comme on le verra plus loin).

REDUX est organisé autour de trois fondamentaux :

  • Une source de vérité unique : l’état global de notre application est stockée dans un arbre d’objets au sein d’un unique store.
  • L’état est en lecture seule : la seule manière de le modifier est d’émettre des actions.
  • Les changements sont faits à l’aide de fonctions pures.

Ces trois principes nous amènent sur le schéma de fonctionnement suivant, nous venant tout droit de la documentation officielle :

Schéma représentant le fonctionnement de REDUX

Sur le schéma, nous voyons apparaître les différentes briques de notre pattern :

  • Le store : c’est la pièce centrale de notre architecture, celle qui contient la plupart des éléments, et notamment le state, les reducers et les actions. REDUX nous conseille de n’avoir qu’un seul store, et ainsi de pouvoir partager toutes nos données, utilitaires, etc…
  • Le state : comme son nom l’indique, il va représenter l’état de notre application. Il peut être vu comme un grand catalogue, ou dictionnaire de toutes les données dont on a besoin pour afficher à notre utilisateur ce qu’il demande. C’est également la mise à jour du state qui déclenche la plupart du temps un changement au niveau de la partie graphique.
  • Les actions : ce sont les petits éléments que l’on voit bouger sur le schéma du dessus. Elles sont utilisées pour représenter les événements qui se passent dans l’application. Souvent des objets assez simples, elles contiennent un type (qui nous servira à identifier le reducer qui l’utilisera) et facultativement des données attachées à cet événement.
  • Les reducers : placés à l’intérieur du store, et associés à un certain type d’action, ils sont “réveillés” quand leur action est dispatchée dans le store. À ce moment, ils vont devoir transformer le state.
  • Les middlewares (qui ne sont pas sur le schéma) : ils sont relativement similaires aux reducers. Ils seront également réveillés par des actions. Leur rôle sera alors d’appliquer une logique métier, d’appeler un repository, puis de dispatcher une nouvelle action à destination d’un reducer.

Tout est clair ? Non ? C’est normal, le concept peut paraître un peu abstrait au premier abord. C’est d’ailleurs l’une des grandes faiblesses de REDUX : ce n’est pas instantanément intuitif. Pour récapituler, reprenons le schéma du dessus étape par étape :

  1. État initial : notre state contient un solde de 0$.
  2. Notre utilisateur clique sur le bouton “Déposer 10$”, ce qui provoque la création d’une action ‘Déposer 10$’.
  3. Un middleware est réveillé par cette action. Il fera un appel au repository associé, qui enregistrera (disons dans un back-end) ce dépôt. Au retour du repository, notre middleware va dispatcher une action de type ‘Dépôt effectué’ et contenant la donnée ‘10$’.
  4. Un reducer est réveillé par cet action et va ajouter ‘10$’ au state initial (0$) ce qui nous fera un nouveau state contenant un solde de ‘10$’.
  5. La mise à jour du state va “réveiller” notre écran, qui affichera le nouveau solde de ‘10$’.

Un peu plus clair ? Je l’espère, parce qu’il est maintenant l’heure de se plonger dans l’implémentation Flutter !

L’implémentation en Flutter avec flutter_redux

Pour expliciter l’implémentation de REDUX, nous allons créer ensemble une petite application pokedex, permettant d’afficher un pokemon, à l’aide d’un appel réseau :

Application pokedex

Le store

Comme vu dans la partie théorique, notre store va contenir l’ensemble des autres composants, et il est unique dans l’application. C’est donc à sa création que nous retrouverons l’injection de dépendances, la création des middlewares, reducers et du state initial.

void main() {​
 final repository = PokemonRepositoryImpl();​
 final store = Store<AppState>(​
   combineReducers<AppState>([​
     pokemonSuccesfullyFetchedReducer,​
     pokemonFetchedWithErrorReducer,​
     pokemonFetchingReducer,​
   ]),​
   middleware: [​
     PokemonMiddleware(repository),​
   ],​
   initialState: AppState.initial(),​
 );​
 runApp(MyApp(store));​
}

C’est donc directement dans notre fonction main que nous allons le créer, avant d’ensuite l’injecter dans notre application en entourant notre MaterialApp d’un StoreProvider :

class MyApp extends StatelessWidget {​
 final Store<AppState> store;​

 const MyApp(this.store, {super.key});​

 @override​
 Widget build(BuildContext context) {​

   return StoreProvider<AppState>(​
     store: store,​
     child: const MaterialApp(​
       title: 'Pokemon',​
       home: PokemonPage(),​
     ),​
   );​
 }​
}

Ainsi, tous les futurs enfants de la MaterialApp auront accès à ce store, et donc notamment les différentes pages.

Le state

Le rôle du state est de contenir toutes les données dont nous aurons besoin pour faire fonctionner notre application. Plus précisément nous auront un state global, contenant plein de sous-states, ou chaque sous-state correspond à une chaque feature ou un domaine fonctionnel​. Nous retrouverons donc une classe “mère” ayant différentes variables représentant ces sous states. Cette classe mère, ainsi que les sous states auront également un constructeur représentant l’état initial (à la création de l’application).

class AppState extends Equatable {​
 final PokemonState pokemonState;​

 const AppState({required this.pokemonState});​

 factory AppState.initial() => const AppState(​
     pokemonState: PokemonState(​
       index: 0,​
       status: Status.LOADING,​
       pokemon: null,​
     )​
 );​

 @override​
 List<Object?> get props => [pokemonState];​
}​
class PokemonState extends Equatable {​
 final int index;​
 final Status status;​
 final Pokemon? pokemon;​
​
 const PokemonState({​
   required this.index,​
   required this.status,​
   required this.pokemon,​
 });​
​
 @override​
 List<Object?> get props => [index, status, pokemon];​
}

💡Vous aurez peut être remarqué que nos state étendent la classe Equatable. Comme REDUX repose sur le fait d’écouter des mises à jour de state, il est important de pouvoir facilement tester l’égalité de deux states. C’est précisément le rôle de la librairie equatable.

Le middleware

Le rôle du middleware étant de se réveiller à l’apparition d’une action spécifique, puis d'appeler un repository, ou tout autre classe dont il aura besoin pour appliquer le processus censé être déclenché par cette action. Il sera donc implémenté sous la forme d’une classe, ayant pour paramètre les différents utilitaires nécessaires (ici, le repository), et contenant une seule méthode publique call (nous venant de la librairie flutter_redux), qui sera appelée à chaque fois qu’une action est dispatchée dans notre store.

class PokemonMiddleware extends MiddlewareClass<AppState> {​
 final PokemonRepository repository;​
​
 PokemonMiddleware(this.repository);​
​
 @override​
 Future<void> call(​
   Store<AppState> store,​
   dynamic action,​
   NextDispatcher next,​
 ) async {
   next(action); // <= Sert à propager l’action aux autres middlewares​
   if (action is FetchNextPokemonAction) {​
    final nextIndex = store.state.pokemonState.index + 1;​
    try {​
      final pokemon = await repository.getPokemon(nextIndex);​
      store.dispatch(ProcessFetchedPokemonSuccessAction(nextIndex, pokemon));​
    } catch (e) {​
      store.dispatch(ProcessFetchedPokemonErrorAction());​
    }​
   }
  }​
 }​
}

De manière séquentielle, notre middleware a le fonctionnement suivant :

  1. Nous appelons le NextDispatcher pour transférer l’action au reste du store (si un autre middleware est sensé être réveillé par cette action par exemple)
  2. Nous regardons si l’action actuelle est celle associée à notre middleware, si ce n’est pas le cas, nous ne ferons rien de plus.
  3. Nous appliquons notre logique :
    1. Nous incrémentons l’index du pokemon actuel
    2. Nous appelons le repository pour récupérer le pokemon correspondant à ce nouvel index
  4. Nous dispatchons une action à destination du reducer
    1. Success, avec des données si l’appel à réussi
    2. Error, sans données si l’appel a échoué

Le reducer

Contrairement à notre middleware, le reducer n’a pas de logique à appliquer, il doit simplement mettre à jour notre state si l’action est celle qui lui est associée. Il prendra donc la forme d’une fonction statique ayant pour arguments le state actuel et l’action actuelle, et retournant le nouveau state :

AppState pokemonFetchingReducer(​
 AppState currentState,​
 dynamic action,​
) {​
 if (action is FetchNextPokemonAction) {​
   return AppState(​
       pokemonState: PokemonState(​
     index: currentState.pokemonState.index,​
     pokemon: null,​
     status: Status.LOADING,​
   ));​
 }​
 return currentState;​
}

Le ViewModel

Vous vous rappelez le rôle de cette classe ? Non ? C’est normal, le concept de ViewModel n'existe pas dans “REDUX by the book”. C’est un classe qui vient de la librairie flutter_redux et dont le rôle est de transformer notre state en un objet d’affichage. Nous avons dit plus haut que nous voulions réveiller notre partie graphique à chaque changement de notre state, mais ce n’est pas tout à fait vrai. Nous voulons réveiller notre partie graphique à chaque changement qui nous intéresse de notre state. Si notre state change mais que cela n’impacte pas l’affichage, il n’y a aucun intérêt à réveiller notre partie graphique. C’est là que notre ViewModel entre en scène.

class PokemonViewModel extends Equatable {​
 final String? name;​
 final String? type;​
 final String? numero;​
 final bool isLoading;​
 final bool isError;​
 final String? imageUrl;​
 final void Function() loadPokemon;​
​
 const PokemonViewModel._({...});​

 factory PokemonViewModel.fromStore(​
  Store<AppState> store,​
 ) {
      final pokemonState = store.state.pokemonState;
      // ... Nous construisons ici notre view model …
    }​

 @override​
 List<Object?> get props => [...];​
}

Notre classe aura donc un seul constructeur public, qui prend simplement en argument le store, qui contient lui-même notre state.

💡Vous aurez surement remarqué que notre ViewModel ne contient que des types simples, et des données prêtes à être affichées à l’écran. En effet, si il y a une logique de présentation à appliquer, par exemple le formatage d’une date, ce sera le rôle du ViewModel de l’appliquer. Ainsi, la partie graphique ne sera pas surchargée par cette logique.

La page​

Maintenant que l’on a fait le tour de notre boucle REDUX, comment est-ce que l’on va pouvoir la brancher à notre page ? C’est le moment de s’accrocher, c’est vraiment super … facile ! Flutter_redux mêt à notre disposition le Widget StoreConnector, qu’il suffit de placer à l’endroit que vous voulez dans votre arbre de Widgets :

Container(
  child: StoreConnector<AppState, PokemonViewModel>(​
     distinct: true,​
     converter: (Store<AppState> store) {​
      return PokemonViewModel.fromStore(store);​
     },​
     onInitialBuild: (viewModel) {​
       viewModel.loadPokemon();​
     },​
     builder: (context, viewModel) {​
       if (viewModel.isLoading) {​
         return _Loading();​
       } else if (viewModel.isError) {​
         return _Error(viewModel);​
        } else {​
         return _Content(viewModel);​
       }​
     },​
    ),
),​

Et voilà, le tour est joué !

Les retombées de flutter_redux

Vous l’aurez sûrement remarqué, mais choisir REDUX aura un impact sur votre code. Beaucoup de vos classes auront une place prédéfinie dans le pattern d’architecture et utiliseront directement ou indirectement la librairie flutter_redux. Cet impact se traduira par différents bénéfices dont vous profiterez tout au long de votre développement mais également par certains coûts à différents niveaux de votre avancement.

Les bénéfices

  • Des responsabilités bien séparées entre les classes : La vue ne s’occupe que d’afficher des objets simples. Les middlewares orchestrent les appels et la logique métier. Les ViewModels font la glue entre le state et la vue. Chaque classe a son rôle bien délimité.
  • Flutter_redux est une bonne architecture sur étagère : La librairie fonctionne bien, est maintenue, documentée, et contient tous les outils nécessaires à son fonctionnement. Il ne reste qu’à suivre les conseils d’utilisation et à se familiariser avec.
  • Les tests sont faciles à écrire : parce que chaque classe a un rôle et seulement un, le découpage se prête très facilement à des tests unitaires. Il est également simple créer des tests pour vérifier le bon passage d’un state A à un state B lors du dispatch de l’action C.
  • Le pattern d’architecture REDUX vient du web : ce qui implique qu’il peut constituer une porte d’entrée pour des développeurs venant du monde du Web et non du mobile. Cela implique aussi que ce pattern profite également de la documentation et de la communauté Web.

Les coûts

  • Du code assez verbeux : le revers de la médaille pour des classes bien découpées, c’est aussi leur nombre, qui peut paraître lourd pour des fonctionnalités simples. Sans oublier que leurs noms ne sont ni courts, ni instinctifs 😄
  • Une courbe d’apprentissage un peu raide : nous l’avons évoqué plus haut, mais REDUX peut paraître un peu complexe au premier abord. Et il nécessite dans tous les cas un certain temps pour monter en compétence et pour être à l’aise avec ses concepts et son implémentation. Néanmoins, tous les patterns d’architecture nécessitent un apprentissage initial, et nous sommes convaincus qu’une bonne architecture apporte de nombreux bénéfices à la vie du projet.
  • Tout est chargé en mémoire dès le lancement de l’application : bien que jusqu’à présent je n’ai jamais vu d’impact sur les performances des applications sur lesquelles j’ai pu travailler, au moment de la création de notre application, et donc de notre store, nous allons créer toutes les classes dont nous aurons besoin (repository, helpers, managers etc…) et les garder en mémoire jusqu’à la destruction de notre application. Quand on n’est pas habitué, ça peut faire un peu peur, même si ce sont des classes “légères”.

REDUX, votre nouvelle architecture Flutter ?

Après cet article, vous êtes convaincus de tout miser sur REDUX pour votre prochaine application ? Je suis évidemment en accord avec cette décision ! REDUX vous permettra de mener à bien votre projet et je suis sûr que vous ne regretterez pas votre choix. Mais bon, je ne suis pas forcément très impartial sur le sujet. Je vais donc me limiter à vous conseiller REDUX si votre application …

  • … est de taille conséquente avec de nombreuses features​
  • … avec une équipe de plus de 4-5 développeur•e•s​
  • ... avec une longue durée de vie​
  • … avec une équipe à la séniorité variée​
  • … avec beaucoup de règles métier ​

Et bien que rien ne vous empêche d’utiliser REDUX, je pense que vous n’en tireriez pas le maximum si …

  • ... vous êtes dans une équipe où personne ne connaît ni n'a envie de connaître REDUX​
  • ... vous travaillez tout seul​
  • ... votre projet doit avancer très vite dès le premier jour