Riverpod par la pratique : intégration avec Flutter

Cet article à pour objectif de vous faire découvrir l'intégration de Riverpod dans une application Flutter.
Il fait suite au précédent article sur la découverte des bases et s'appuie sur ces notions pour en ajouter de nouvelles.
Le code source est disponible sur le GitLab Ippon et les exemples sur la GitLab page associée.

Intégration dans une application

Dans Flutter, tout est widget.
En suivant ce principe, Riverpod s'adapte à Flutter avec le widget ProviderScope dont la responsabilité est de partager les états qu'il contient à ses widgets enfants.
Plusieurs manières permettent d'accéder à ces états, le widget Consumer étant la solution la moins intrusive :

source : 01_consumer_example.dart

final helloProvider = Provider<String>((ref) => 'Hello world'); // <1>

class IntegrationExample extends StatelessWidget {
  const IntegrationExample({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return const ProviderScope( // <2>
      child: MyExample(),
    );
  }
}

class MyExample extends StatelessWidget {
  const MyExample({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return Consumer( // <3>
      builder: (_, WidgetRef ref, __) => Text(
        ref.watch(helloProvider), // <4>
        textDirection: TextDirection.ltr,
      ),
    );
  }
}

consumer

Un Provider est défini pour retourner 'Hello world' <1>.
Les états sont disponibles pour les widgets enfants à partir du parent ProviderScope <2>.
La méthode builder du Consumer <3> fournit en paramètre un WidgetRef permettant de surveiller le provider et afficher son état <4>.

Le ProviderScope vient encapsuler dans un widget le ProviderContainer pour le rendre accessible aux widgets enfants par l'intermédiaire d'un WidgetRef.
De la même manière que le ProviderRef, le WidgetRef est une façade pour le ProviderContainer et le fonctionnement attendu des méthodes read et watch est identique.

L'utilisation de la méthode watch indique que le widget sera reconstruit à chaque changement de l'état.
Étant donné que l'état du Provider <1> conservera toujours la même valeur, la méthode read aurait eu le même comportement que la méthode watch <4>.
Ce choix est néanmois motivé par la documentation officielle qui favorise le fonctionnement réactif de la méthode watch par rapport à la méthode read.

D'un point de vue conceptuel, à chaque fois qu'un widget utilise un Provider il en dévient dépendant.
Cette dépendance ne pose pas de problème sur le court terme mais peut s'avérer coûteuse sur le long terme.
À partir du moment où ces widgets sont utilisés il faut tenir compte de l'état des Providers dont ils dépendent.
Cette préoccupation est particulièrement présente à l'écriture des tests où chaque Provider doit être correctement initialisé.

Le widget Consumer est une manière simple d'accéder au WidgetRef mais vient alourdir l'écriture de la méthode build avec un widget supplémentaire.
Pour des cas plus complexes, Riverpod dispose de widgets spécifiques.

StatelessWidget et ConsumerWidget

L'autre solution proposée par Riverpod pour accéder au WidgetRef est de remplacer à la déclaration la classe étendue.
La classe ConsumerWidget vient remplacer StatelessWidget pour les widgets sans états :

source : 02_stateless_example.dart

final countServiceProvider = StateNotifierProvider<CountService, int>((ref) => CountService(42)); // <1>

class CountService extends StateNotifier<int> { // <2>
  CountService(int firstValue) : super(firstValue);

  void increment() { // <3>
    state++;
  }
}

class StatelessExample extends StatelessWidget {
  const StatelessExample({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return const ProviderScope(
      child: Counter(),
    );
  }
}

class Counter extends ConsumerWidget { // <4>
  const Counter({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context, WidgetRef ref) { // <5>
    final currentValue = ref.watch(countServiceProvider); // <6>

    return Column(
      mainAxisSize: MainAxisSize.min,
      children: [
        Text("Current value: $currentValue"), // <7>
        HSpace.m,
        ElevatedButton(
          onPressed: () => _onIncrement(ref), // <8>
          child: const Text("Increment"),
        ),
      ],
    );
  }

  void _onIncrement(WidgetRef ref) {
    ref.read(countServiceProvider.notifier).increment(); // <9>
  }
}

stateless

Un StateNotifierProvider est déclaré <1> pour le service CountService <2>.
Ce service est un StateNotifier dont le state est un nombre qui peut être incrémenté en appelant la méthode increment <3>.
La classe ConsumerWidget remplace StatelessWidget <4> et ajoute le paramètre WidgetRef au niveau de la méthode build <5>.
Le WidgetRef est utilisé de la même manière qu'avec le Consumer, les modifications de l'état sont écoutées <6> pour être affichées <7>.
Le même WidgetRef est ensuite passé en paramètre de la méthode appelée lors d'un clique sur le bouton <8>.
Lors de cette méthode, la lecture du notifier présent dans le countServiceProvider donne accès au service pour appeler la méthode increment.

Remplacer la classe étendue par le Widget est une solution qui peut se révéler contraignante voire rédhibitoire pour certains développeurs.
Cependant, ce choix d'implémentation simplifie l'intégration de Riverpod en donnant accès directement accès au WidgetRef depuis la méthode build.
Comme pour le BuildContext, ce WidgetRef est à passer aux méthodes qui ont besoin d'accéder aux états.

StatefulWidget et ConsumerStatefulWidget

Le ConsumerWidget venant remplacer le StatelessWidget, c'est tout naturellement que le StatefulWidget dispose de sa propre implémentation :

source : 03_stateful_example.dart

final countServiceProvider = StateNotifierProvider<CountService, int>((ref) => CountService(42)); // <1>

class CountService extends StateNotifier<int> {
  CountService(int firstValue) : super(firstValue);

  void increment() {
    state++;
  }
}

class StatefulExample extends StatelessWidget {
  const StatefulExample({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return const ProviderScope(
      child: Counter(),
    );
  }
}

class Counter extends ConsumerStatefulWidget { // <2>
  const Counter({Key? key}) : super(key: key);

  @override
  ConsumerState<Counter> createState() => _CounterState(); // <3>
}

class _CounterState extends ConsumerState<Counter> { // <4>
  late int firstValue;

  @override
  void initState() {
    super.initState();
    firstValue = ref.read(countServiceProvider); // <5>
  }

  @override
  Widget build(BuildContext context) { // <6>
    final currentValue = ref.watch(countServiceProvider); // <7>

    return Column(
      mainAxisSize: MainAxisSize.min,
      children: [
        Text("First value: $firstValue"), // <8>
        HSpace.s,
        Text("Current value: $currentValue"),
        HSpace.m,
        ElevatedButton(
          onPressed: _onIncrement,
          child: const Text("Increment"),
        ),
      ],
    );
  }

  void _onIncrement() {
    ref.read(countServiceProvider.notifier).increment(); // <9>
  }
}

stateful

Le service et le provider associé sont identiques à l'exemple précédent <1>.
Au niveau du widget, la classe ConsumerStatefulWidget remplace StatefulWidget <2>.
Au niveau du state, la classe ConsumerState remplace ConsumerState à la déclaration <4> et à la création <3>.
Contrairement au ConsumerWidget, la signature de la méthode build reste identique <6> car le WidgetRef est devenu une propriété.
La première valeur du service est lue à l'initialisation du widget <5> puis ses modifications sont écoutées <7> pour être affichées <8>.
Le WidgetRef en propriété est utilisé pour lire le service et incrémenter l'état.

À la différence du StatelessWidget, le WidgetRef est directement accessible avec la propriété ref.
Ce choix d'implémentation donne accès aux états de n'importe où dans le widget en conservant un code lisible ; le WidgetRef n'étant plus passé de méthode en méthode.

Jusqu'à présent, les Providers étaient surveillés avec la méthode watch, dans le builder du Consumer ou dans la méthode build des widgets.
Dans les cas spécifiques où la surveillance est inutile le Provider est simplement lu avec la méthode read.
C'est par exemple le cas pour récupérer ponctuellement un état <5> ou appeler une fonction d'un notifier <9>.

Un lien de parenté

Le widget ProviderScope est un conteneur qui peut avoir en descendance un autre ProviderScope pour former un lien de parenté :

source : 04_parent_example.dart

final countServiceProvider = StateNotifierProvider<CountService, int>((ref) => CountService(42)); // <1>

class CountService extends StateNotifier<int> {
  CountService(int firstValue) : super(firstValue);

  void increment() {
    state++;
  }
}

final randomProvider = Provider<int>((ref) => Random().nextInt(10)); // <2>

final multiplyByRandomProvider = Provider( // <3>
      (ref) => ref.watch(countServiceProvider) * ref.watch(randomProvider),
  dependencies: [
    countServiceProvider,
    randomProvider,
  ],
);

class ParentExample extends StatelessWidget {
  const ParentExample({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return ProviderScope( // <4>
      child: Column(
        mainAxisSize: MainAxisSize.min,
        children: [
          const Counter(), // <5>
          HSpace.xl,
          ProviderScope( // <6>
            overrides: [
              countServiceProvider.overrideWithValue(CountService(13)), // <7>
            ],
            child: const Counter(), // <8>
          ),
        ],
      ),
    );
  }
}

class Counter extends ConsumerStatefulWidget {
  const Counter({Key? key}) : super(key: key);

  @override
  ConsumerState<Counter> createState() => _CounterState();
}

class _CounterState extends ConsumerState<Counter> {
  late int firstValue;

  @override
  void initState() {
    super.initState();
    firstValue = ref.read(countServiceProvider); // <9>
  }

  @override
  Widget build(BuildContext context) {
    final currentValue = ref.watch(countServiceProvider); // <10>
    final random = ref.watch(randomProvider);
    final multiplyByTwo = ref.watch(multiplyByRandomProvider);

    return Column(
      mainAxisSize: MainAxisSize.min,
      children: [
        Text("First value: $firstValue"),
        HSpace.s,
        Text("Current value: $currentValue"),
        HSpace.s,
        Text("Multiplied by $random: $multiplyByTwo"),
        HSpace.m,
        ElevatedButton(
          onPressed: _onIncrement,
          child: const Text("Increment"),
        )
      ],
    );
  }

  void _onIncrement() {
    ref.read(countServiceProvider.notifier).increment(); // <11>
  }
}

parent_provider_scope

Le service et le provider associé sont identiques à l'exemple précédent <1>.
Un chiffre aléatoire est retourné par le Provider randomProvider <2>.
Le Provider multiplyByRandomProvider <3> vient écouter le provider countServiceProvider et retourne sa valeur multipliée par celle du randomProvider.
Un premier ProviderScope est déclaré <4> avec un Counter associé <5>.
Un second ProviderScope est déclaré <6> en tant que widget enfant du précédent avec également un Counter associé <8>.
Ce second ProviderScope vient surcharger la valeur du counterServiceProvider <7>.
Comme précédemment, le widget Counter vient lire <9>, surveiller <10> et incrémenter <11> le counterServiceProvider.

Les deux widgets Counters partagent le même état du Provider randomProvider <2>.
Ce n'est qu'à partir du moment où le ProviderScope surcharge le countServiceProvider <7> que chacun dispose de sa propre valeur.
Le provider multiplyByRandomProvider dépendant de countServiceProvider dispose également de son propre état.
Cette dépendance a été indiqué lors de la déclaration du multiplyByRandomProvider <3> pour que Riverpod en ait conscience.
Il en résulte deux Counter qui interagissent avec des états indépendants avec un multiplicateur commun.

L'aventure continue

Maintenant que toutes les bases sont posées vous devriez être en mesure de comprendre et d'utiliser Riverpod dans vos applications.

Zelda sword