Utiliser MobX avec Flutter

post-thumb

Dans ce post, nous allons comprendre comment faire la gestion d’état de l’application Flutter avec le framework Mobx. Rappelons nous que l'état (state) représente l’ensemble des données susceptible d’être modifiées pendant l’exécution de l’application.

Lorsqu’on développe une application, que l’on soit junior, intermédiaire ou senior, à un moment donné on se pose toujours la question de savoir comment allons-nous faire la gestion d’état de l’application. Par exemple, la gestion locale de l’état devient insuffisante lorsqu’un composant doit accéder à ou modifier l’état d’un autre composant.

Pour gérer l’état, le framework Mobx est un choix parmi tant d’autres.

A la base utilisé dans l’écoysystème Javascript, il a été porté en Dart et est aujourd’hui l’une des librairies les plus utilisés sur pub.dev

Contrairement à la plupart des librairies disponible pour la gestion d’état, Mobx s’appuie fortement sur la génération de code, ce qui permet une prise en main plus facile et laisse la place pour mieux se concentrer sur la valeur à ajouter pour notre application. Merci au boilerplate coding.

Pour mieux prendre en main Mobx avec Flutter, je vous propose de dérouler l’apprentissage sur un exemple concret d’application. Nous ferons l’exemple avec une simple application pour nous donner les informations sur le climat.

Nous n’allons pas mettre l’accent sur l’UI. Nous allons simuler des appels HTTP vers une API FakeWeatherAPI qui génére des climats aléatoires. Le modèle pour le climat sera Weather et contiendra la température et la ville associée.

Code source


Les sources de ce post sont disponibles ici.


weather.dart
 1import 'package:meta/meta.dart';
 2
 3class Weather {
 4  final String cityName;
 5  final double temperatureCelsius;
 6
 7  Weather({
 8    @required this.cityName,
 9    @required this.temperatureCelsius,
10  });
11}

Ajout des dépendances

L’utilisation de Mobx en Flutter se découpe en 3 librairies :

  • une librairie core
  • une librairie spécifique pour Flutter
  • la librairie dédiée à la génération de code.

Dans notre application en exemple , nous allons aussi utiliser la librairie provider qui va nous permettre de faire de l'injection de dépendance d’une manière un peu plus élégante. Ci-dessous , un extrait des dépendances dans le pubspec.yaml de notre project :


pubspec.yaml
 1dependencies:
 2  flutter:
 3    sdk: flutter
 4  # MobX core
 5  mobx: ^1.2.1+2
 6  # MobX Flutter specific
 7  flutter_mobx: ^1.1.0+2
 8  # Provider for Dependency Injection
 9  provider: ^4.3.2+2
10  # A Flutter package which provides a Search Widget 
11  # for selecting an option from a data list.
12  search_widget: 
13
14dev_dependencies:
15  flutter_test:
16    sdk: flutter
17  build_runner:
18  # Codegen for MobX code generation
19  mobx_codegen: ^1.1.0+1

A. Fonctionnement de MobX

L’implémentation qui se rapproche le plus du principe de Mobx , est celle du simple ChangeNotifier implémenté dans le SDK Flutter . On renseigne quelques champs dans une classe nommée Store. Les valeurs de ces champs sont en général connus sous le nom d'état ou state dans le jargon et sont mutables directement à l’intérieur du store. Dans ce sens , on peut facilement mettre à jour notre UI quand la valeur d’un champ change en l'observant.

Effectivement, les stores de MobX sont bien plus évolués que le ChangeNotifier mais le principe reste le même, contrairement au pattern BLoC ou Redux qui émettent des nouveaux états et non des mutations.

L’implémentation avec le ChangeNotifier peut laisser place à un plat de spaghetti, alors que Mobx veut apporter une certaine structure.

  • Les champs qui sont mutables sont marqués @observable
  • Il est possible d’implémenter de la logique dans le store pour qu’un champ observé change de valeur. Et sauvegarder le résultat dans une propriété marquée @computed
  • L’interface utilisateur peut déclencher ( trigger) une logique implémentée et changer l’état de l’application. Cependant, pour pouvoir modifier l’état , il faut que cette logique soit annotée @action

Le schéma ci-dessous illustre le workflow avec MobX:

Nous n’avons pas encore parlé de reactions. Ci-dessous le schéma officiel de Mobx et je vous invite aussi à lire la documentation officielle

B. Implémentation dans notre application


B-1. Création du WeatherStore

Le bout de code ci-dessous sert à appuyer la lecture que vous auriez éventuellement faite de la documentation officielle. Créons le store qui va gérer l’état de notre application. Dans un dossier stores, créons le fichier weather_store.dart


src/stores/weather_store.dart
 1import 'package:mobx/mobx.dart';
 2
 3part 'weather_store.g.dart';
 4
 5// Stores
 6// -------
 7
 8class WeatherStore = _WeatherStore with _$WeatherStore;
 9
10abstract class _WeatherStore with Store {
11  
12}

C’est à peu comme ça le boilerplate avec MobX. Là, tu dois te demander, en Dart on traite une classe comme une variable ? La ligne 8 équivaut à la ligne ci-dessous, il s’agit de la syntaxe courte:

class WeatherStore extends _WeatherStore with _$WeatherStore {}

_$WeatherStore est un mixin qui sera généré lors du processus de génération de code avec la librairie nommée mobx_codegen. Pour déclencher la génération de code , il suffit de lancer la commande ci-dessous :


terminal
flutter packages pub run build_runner watch

Nous allons faire évoluer le store pour l’injecter la classe qui va simuler notre API. Par ce scénario, nous allons le rajouter dans le constructeur de notre store comme montré ci-dessous :


src/stores/weather_store.dart
1class WeatherStore extends _WeatherStore with _$WeatherStore {
2  WeatherStore(WeatherRepository weatherRepository) : super(weatherRepository);
3}
4
5abstract class _WeatherStore with Store {
6  final WeatherRepository _weatherRepository;
7
8  _WeatherStore(this._weatherRepository);
9}

B-2. Les champs observable

Evidemment les champs annotés @observable sont fait pour être observés 😀 . L’observation peut se faire de n’importe quel endroit, dans l'UI, la réaction et même dans le store. On voudra observer l’instance de l’objet lié au climat Weather pour mettre à jour l’UI ou renvoyer un message si une erreur se produit.


src/stores/weather_store.dart
 1abstract class _WeatherStore with Store {
 2  final WeatherRepository _weatherRepository;
 3
 4  _WeatherStore(this._weatherRepository);
 5
 6  @observable
 7  Weather weather;
 8
 9  @observable
10  String errorMessage;
11}

Pour une meilleure gestion du cycle de vie de notre application, nous allons utilisé des objets asynchrones Future , pour par exemple afficher une barre de progression lorsque l’utilisateur est en attente d’un résultat. Pour ça , nous allons utilisé ObservableFuture<Weather> qui n’est rien d’autre qu’un wrapper de l’objet Future mais qui permet d’observer les états pending, fulfilled ou rejected


src/stores/weather_store.dart
 1...
 2@observable
 3ObservableFuture<Weather> _weatherFuture;
 4
 5@observable
 6Weather weather;
 7
 8@observable
 9String errorMessage;
10...

Je fais le choix de rajouter un enum pour connaitre les états transitoires :


src/stores/weather_store.dart
1enum StoreState { initial, loading, loaded }

B-3. Les champs computed

On utilise l’annotation @computed pour définir un type spécial de propriété observable qui sera mise à jour chaque fois qu’un champ observable changera de valeur. Ce scénario est parfait pour l’énumération StoreState. Il devrait changer chaque fois que le statut de l’observable _weatherFuture change.


src/stores/weather_store.dart
 1@observable
 2ObservableFuture<Weather> _weatherFuture;
 3...
 4@computed
 5StoreState get state {
 6  // If the user has not yet searched for a weather forecast or there has been an error
 7  if (_weatherFuture == null ||
 8      _weatherFuture.status == FutureStatus.rejected) {
 9    return StoreState.initial;
10  }
11  // Pending Future means "loading"
12  // Fulfilled Future means "loaded"
13  return _weatherFuture.status == FutureStatus.pending
14      ? StoreState.loading
15      : StoreState.loaded;
16}

On pourrait se poser la question de savoir, quand est ce que la propriété annotée @computed sait qu’il faut se mettre à jour. C’est la magique de MobX ! Il ya un code généré qui nous masque cette complexité.


B-4. Les méthodes action

Si vous êtes familé avec l’API ChangeNotifier, vous savez qu’il faut appeler la méthode notifyListeners() pour notifier d’un changement d’état. Avec MobX, c’est la même approche, mais au lieu de le déclencher nous même, MobX le fait pour nous lorsqu’une méthode est annoté @action

La méthode getWeather est une action qui est déclenchée sur changement d’état.


src/stores/weather_store.dart
 1abstract class _WeatherStore with Store {
 2  ...
 3
 4  @action
 5  Future getWeather(String cityName) async {
 6    try {
 7      // Reset the possible previous error message.
 8      errorMessage = null;
 9      // Fetch weather from the repository and wrap the regular Future into an observable.
10      // This _weatherFuture triggers updates to the computed state property.
11      _weatherFuture =
12          ObservableFuture(_weatherRepository.fetchWeather(cityName));
13      // ObservableFuture extends Future - it can be awaited and exceptions will propagate as usual.
14      weather = await _weatherFuture;
15    } on NetworkError {
16      errorMessage = "Couldn't fetch weather. Is the device online?";
17    }
18  }

C. Manipulation de l’UI

En utilisant MobX, l’UI travaille principalement avec 2 objets :

  • un widget de type Observer pour pouvoir reconstruire l’arbre des widgets affichés
  • les réactions pour éxecuter de la logique UI comme par exemple affiché un objet de type SnackBar à l’écran.

Grace à la librairie provider, nous allons préparer l’objet nous permettant d’injecter le WeatherStore dans l’application :


src/providers/stores_provider.dart
 1  ...
 2
 3class StoresProvider extends StatelessWidget {
 4  final Widget child;
 5
 6  StoresProvider({@required this.child});
 7
 8  @override
 9  Widget build(BuildContext context) {
10    return MultiProvider(
11      providers: [
12        Provider<WeatherStore>(create: (_) => WeatherStore(FakeWeatherAPI())),
13      ],
14      child: child,
15    );
16  }
17}

Par simplicité, dans le main.dart de l’application , nous allons définir la classe StoresProvider comme root des noeuds de widgets de l’application pour résoudre l’injection de dépendance.


main.dart
 1  ...
 2
 3void main() => runApp(MyApp());
 4
 5class MyApp extends StatelessWidget {
 6  @override
 7  Widget build(BuildContext context) {
 8    return StoresProvider(
 9          child: MaterialApp(
10        title: 'Material App',
11        home:WeatherSearchPage(),
12      ),
13    );
14  }
15}

On manipule le store dans la page WeatherSearchPage. On recupère une instance grace à l’API provider.


src/pages/weather_search_page.dart
 1class _WeatherSearchPageState extends State<WeatherSearchPage> {
 2  WeatherStore _weatherStore;
 3
 4  @override
 5  void didChangeDependencies() {
 6    super.didChangeDependencies();
 7    _weatherStore ??= Provider.of<WeatherStore>(context);
 8  }
 9
10  ...
11}

C-1. Une réation dans le SnackBar

Les reactions permettent de déclencher une fonction lorsque un champ observé est mis à jour. Les scénarios les plus fréquents sont les mises à jour de l’UI ( animation , affichage de barre de progression, dialogue d’alerte ). Dans Mobx, il y a plusieurs types de réactions, et dans l’application nous allons utiliser la plus simple nommé juste reaction

Ci-dessous , la logique d’utilisation dans la page weather_search_page. Dès qu’on récupère l’instance de WeatherStore dans la méthode didChangeDependencies(), on crée une reaction qui renvoie un objet de type ReactionDisposer qui est sauvegardé dans la liste _disposers.

La bonne pratique constite à nettoyer cette liste dans le dispose de la page , ajouter une clé _scaffoldKey au Scaffold utilisé pour construire notre page.


src/pages/weather_search_page.dart
 1class _WeatherSearchPageState extends State<WeatherSearchPage> {
 2  WeatherStore _weatherStore;
 3  List<ReactionDisposer> _disposers;
 4  // For showing a SnackBar
 5  GlobalKey<ScaffoldState> _scaffoldKey = GlobalKey();
 6
 7  @override
 8  void didChangeDependencies() {
 9    super.didChangeDependencies();
10    _weatherStore ??= Provider.of<WeatherStore>(context);
11    _disposers ??= [
12      reaction(
13        // Tell the reaction which observable to observe
14        (_) => _weatherStore.errorMessage,
15        // Run some logic with the content of the observed field
16        (String message) {
17          _scaffoldKey.currentState.showSnackBar(
18            SnackBar(
19              content: Text(message),
20            ),
21          );
22        },
23      ),
24    ];
25  }
26
27  @override
28  void dispose() {
29    _disposers.forEach((d) => d());
30    super.dispose();
31  }
32
33  @override
34  Widget build(BuildContext context) {
35    return Scaffold(
36      key: _scaffoldKey,
37      appBar: AppBar(
38        title: Text("Recherche un climat"),
39      ),
40      ...
41    );
42  }
43...
44}

C-2. un Observer pour reconstruire l’UI

Pas grand chose à dire , pour l’objet Observer. Il renvoie un Widget et s’exécute chaque fois qu’il ya un changement d’état.

Nous allons observé la propriété state. Ce qui nous permettra de savoir l’état global de l’application (initial , en cours de chargement ou chargée ).


src/pages/weather_search_page.dart
 1  @override
 2  Widget build(BuildContext context) {
 3    return Scaffold(
 4      key: _scaffoldKey,
 5      appBar: AppBar(
 6        title: Text("Recherche un climat"),
 7      ),
 8      body: Container(
 9        padding: EdgeInsets.symmetric(vertical: 16),
10        alignment: Alignment.center,
11        child: Observer(
12          builder: (_) {
13            switch (_weatherStore.state) {
14              case StoreState.initial:
15                return buildInitialInput();
16              case StoreState.loading:
17                return buildLoading();
18              case StoreState.loaded:
19                return buildColumnWithData(_weatherStore.weather);
20            }
21          },
22        ),
23      ),
24    );
25  }
26  

D. Conclusion, mise en place de MobX

Avec ce post , vous avez le détail pour gérer l’état de votre application avec MobX. Il ya beaucoup de framework pour la gestion de l’état de votre application en Flutter. Chaque développeur a un peu sa préférence. Pour personnellement j’aime bien l’approche de BLoC, mais contrairement à MobX on peut se retrouver à gérer beaucoup de fichiers.

La génération de code qui masque la petite complicité du framework Mobx est un peu plus.

Code source


Les sources de ce post sont disponibles ici.