Cet article a pour objectif de vous faire découvrir pas à pas la bibliothèque Riverpod à partir d'exemples d'utilisation.
Le code source est disponible sur le GitLab Ippon.
Il était une fois la gestion d’états
Riverpod est une bibliothèque de gestion d’états pour Flutter.
La "gestion d’états", ou "state management" en anglais, a pour responsabilité de mettre à disposition les différents objets qui constituent l'état de l'application.
La principale particularité de Flutter est que tout est widget.
La gestion d’états ne faisant pas exception, de nombreuses bibliothèques sont apparues proposant chacune leurs propres patterns et méthodes de fonctionnement.
Parmi les plus utilisées on peut citer BLoC, GetX, Provider et aujourd'hui c'est Riverpod qui nous intéresse.
Le créateur et principal contributeur de Riverpod, Remi Rousselet, n'en est pas à son premier ballon d'essai.
Reconnu au sein de la communauté Dart et Flutter, il est entre autre l'auteur de freezed (un générateur de code pour les data-classes et unions), flutter_hooks (une implémentation des hooks React) et Provider (une autre gestion d’états).
Cette dernière est le point de départ d'une réflexion plus ambitieuse sur la gestion d'états qui impliquera une réécriture complète pour aboutir à Riverpod.
Cet article fait référence à la bibliothèque Provider sous ces termes uniquement pour ne pas la confondre avec la classe Provider
présente dans Riverpod.
En route avec Riverpod !
Dès son installation, Riverpod se distingue de ses concurrentes par son découpage en plusieurs bibliothèques :
riverpod
contient le code principal, sans aucune adhérence.flutter_riverpod
contient le code spécifique pour le framework Flutter.hooks_riverpod
contient le code spécifique pour la bibliothèqueflutter_hooks
.
La bibliothèque flutter_riverpod
sera utilisée pour aborder l'ensemble des fondamentaux de Riverpod.
À l'heure de l'écriture de cet article, deux versions sont disponibles, une stable (1.0.3) et une en cours de développement (2.0.0-dev.9).
Dans un souci de pérennité, cette dernière sera utilisée tout au long de l'article.
La dépendance est à ajouter dans le fichier pubspec.yaml :
dependencies:
flutter_riverpod: ^2.0.0-dev.9
Une histoire de Provider
L'opération la plus élémentaire est de récupérer un état stocké dans un ProviderContainer
par l'intermédiaire d'un Provider
.
Le ProviderContainer
est un conteneur d'états.
Pour simplifier, c'est une Map
avec comme clefs les instances des providers et comme valeurs les états correspondants.
Utiliser des instances comme clef corrige la limitation de la bibliothèque Provider qui ne supporte qu'une valeur par classe.
Le Provider
est un moyen de récupérer un état présent dans un ProviderContainer
.
La déclaration d'un provider permet d'indiquer le type et la valeur d'initialisation de l'état auquel il correspond.
Déclarer un provider permet de typer, initialiser et récupérer un état.
La récupération d'un état est réalisée de la manière suivante :
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
final intProvider = Provider<int>((_) => 13); // <1>
void main() {
test('doit récupérer un état', () {
// given:
final container = ProviderContainer(); // <2>
addTearDown(container.dispose);
// expect:
expect(container.read(intProvider), equals(13)); // <3>
});
}
Le Provider
est instancié en tant que variable globale et son état est initialisé avec la valeur 13 <1>
.
L'instance du ProviderContainer
contient les états <2>
et permet de les récupérer en passant l'instance du Provider
en paramètre de sa méthode read
<3>
.
Déclarer le Provider
en tant que variable globale <1>
peut sembler une erreur de conception, mais il n'en est rien.
Il est avant tout immutable et ne contient pas un état, mais constitue un moyen de le récupérer.
La visibilité globale est alors un choix judicieux pour le rendre disponible n'importe où dans le code.
Une autre particularité du Provider
est de résoudre la principale erreur rencontrée par la bibliothèque Provider
, à savoir le ProviderNotFoundException
.
Cette erreur est levée quand un état est accédé alors qu'il n'a pas été encore initialisé.
Le provider étant responsable de l'initialisation de l'état, ce dernier sera systématiquement initialisé avant d'être récupéré.
Le contrat du
Provider
se limite à la récupération d'un état.
Les modifications internes de l’état ne sont pas notifiées pour favoriser l’utilisation d’objets immutables.
Plus généralement, l'immutabilité des objets échangés est, entre autres, une bonne pratique dans le développement événementiel, car elle limite les effets de bord.
La déclaration de ces objets peut se révéler fastidieuse en Dart et c'est ce que propose de simplifier la bibliothèque freezed.
Le cycle de vie des Providers
La récupération de son état n'est qu'une partie du cycle de vie d'un Provider
.
Le paramètre ProviderRef
, présent dans l'initialisation de chaque Provider
, propose par sa méthode onDispose
de définir une callback qui sera appelée pour libérer ses ressources.
source : circle_of_life_test.dart
void main() {
test('doit vérifier le cycle de vie du Provider', () {
const defaultValue = -1;
// given:
final container = ProviderContainer();
var intValue = defaultValue; // <1>
var disposed = false;
final intProvider = Provider<int>((ref) {
ref.onDispose(() => disposed = true);
return intValue = 13;
});
// expect:
expect(intValue, equals(defaultValue)); // <2>
expect(disposed, isFalse);
// when:
container.read(intProvider); // <3>
// then:
expect(intValue, equals(13)); // <4>
expect(disposed, isFalse);
// when:
container.dispose(); // <5>
// then:
expect(disposed, isTrue); // <6>
});
}
Le Provider
est déclaré au sein du test pour retenir son affectation d'état et sa libération de ressources <1>
.
Avant toute manipulation, sa valeur n'est toujours pas affectée <2>
.
Ce n'est qu'après avoir été lu <3>
que sa valeur est initialisée <4>
.
Les ressources du Provider
sont libérées <6>
avec celles du ProviderContainer
<5>
.
En plus de contenir les états, le
ProviderContainer
gère aussi leurs cycles de vie.
Un état ne sera initialisé qu'à partir du moment où il sera lu, reflétant la nature paresseuse (lazy) duProviderContainer
.
Pour des questions de consommation mémoire, il est parfois nécessaire d'écourter la vie d'un Provider
en libérant ses ressources dès qu'il n'est plus utilisé.
Ce comportement additionnel du Provider
est ajouté avec le modifier autodispose
:
Un modifier est une méthode supplémentaire appelée lors de la déclaration d'un
Provider
.
source : autodispose_test.dart
void main() {
test('doit vérifier que les ressources du Provider sont libérées', () async {
// given:
final container = ProviderContainer();
addTearDown(container.dispose);
var disposed = false; // 1
final provider = Provider.autoDispose<void>((ref) {
ref.onDispose(() => disposed = true);
});
// when:
container.read(provider); // <2>
// then:
expect(disposed, isFalse); // <3>
// when:
await Future.delayed(const Duration(milliseconds: 1)); // <4>
// then:
expect(disposed, isTrue); // <5>
});
}
Le Provider
est déclaré pour que ses ressources soient libérées dès qu'il n'est plus utilisé <1>
.
Après avoir été lu <2>
ses ressources ne sont pas instantanément libérées <3>
.
Ce n'est qu'après avoir lâché la main sur la boucle synchrone <4>
que la libération asynchrone de ses ressources est effective <5>
.
Un
Provider
déclaré avec le modifierautodispose
libère son état de manière asynchrone dès qu'il n'est plus utilisé.
Test, test, test and test
Pour homogénéiser l'écriture des tests et éviter d'éventuels oublis de libération de ressources, la déclaration des ProviderContainer
peut être factorisée comme le fait Riverpod avec la méthode createContainer.
Cette méthode sera par la suite utilisée dans les prochains tests de cet article.
import 'package:flutter_test/flutter_test.dart';
import 'package:riverpod/riverpod.dart';
ProviderContainer createContainer({
ProviderContainer? parent,
List<Override> overrides = const [],
List<ProviderObserver>? observers,
}) {
final container = ProviderContainer(
parent: parent,
overrides: overrides,
observers: observers,
);
addTearDown(container.dispose);
return container;
}
Étant donné que le ProviderContainer
contient les états, en instancier un nouveau pour chaque test permet de garantir leur isolation.
La variable overrides
passée à l'initialisation du ProviderContainer
va se révéler particulièrement utile pour préparer la situation initiale des tests.
La liste d'Override
s qu'elle contient vient surcharger le comportement du ProviderContainer
.
Ces Override
s sont retournés par les méthodes idoines du Provider
, overrideWithProvider
pour le remplacer et overrideWithValue
pour remplacer l'état récupéré.
final intProvider = Provider<int>((_) => 13); // <1>
final otherIntProvider = Provider<int>((_) => 13);
void main() {
test('doit surcharger le comportement des Provider', () {
// given:
final container = createContainer(
overrides: [
intProvider.overrideWithProvider(Provider<int>((_) => 42)), // <2>
otherIntProvider.overrideWithValue(42),
],
);
// expect:
expect(container.read(intProvider), 42); // <3>
expect(container.read(otherIntProvider), 42);
});
}
Deux Providers
sont déclarés pour retourner la valeur 13 <1>
.
Le comportement du ProviderContainer
est surchargé pour remplacer le premier Provider
et pour remplacer la valeur retournée par le second <2>
.
La valeur récupérée par les deux est maintenant de 42 <3>
.
Distinction entre états pour un Provider
Un même Provider
peut se voir confier plusieurs états en attribuant à chacun une clef.
Le modifier family
ajoute ce comportement à la déclaration du Provider
en définissant le type de la clef avec son générique :
final evenProvider = StateProvider.family<bool, int>((_, arg) => arg.isEven); // <1>
void main() {
test('doit distinguer chaque état dans un provider', () {
// given:
final container = createContainer();
// then:
expect(container.read(evenProvider(13)), isFalse); // <2>
expect(container.read(evenProvider(42)), isTrue);
});
}
Un Provider
est déclaré pour indiquer par ses états si la clef passée en paramètre est paire <1>
.
La lecture du Provider
se fait en passant cette clef et la valeur correspondante est retournée <2>
.
Pour simplifier, le modifier family
transforme le Provider
en Map
avec comme clefs son générique et comme valeurs les états correspondants.
Un
Provider
déclaré avec le modifierfamily
dispose d'autant d'états que de clef qui leur sont attribuées.
Changer d'état avec le StateProvider
Le Provider
est un moyen de récupérer un état, mais en aucun cas de le modifier.
Ce comportement a été confié au StateProvider
de la manière suivante :
source : state_provider_test.dart
final intProvider = StateProvider((_) => 13); // <1>
void main() {
test('doit modifier un état', () {
// given:
final container = createContainer();
// expect:
expect(container.read(intProvider), equals(13));
// when:
container.read(intProvider.notifier).state = 42; // <2>
// expect:
expect(container.read(intProvider), equals(42)); // <3>
});
}
Le StateProvider
est instancié de la même manière qu'un Provider
, en initialisant son état avec la valeur 13 <1>
.
Le notifier du StateProvider
est récupéré par l'intermédiaire du ProviderContainer
et son état est remplacé par la valeur 42 <2>
.
L'état du StateProvider
a bien été mis à jour avec cette nouvelle valeur <3>
.
Le contrat du
StateProvider
va de la récupération à la modification d'un état.
Les modifications sont réalisées en affectant un nouvel objet à la variablestate
dunotifier
.
Chacune de ces modifications est ensuite notifiée aux objets qui le surveillent.
Ce fonctionnement implique la création d'un nouvel objet pour indiquer un changement d'état et conforte l'utilisation d'objets immutables.
La mise à jour de l'état par le StateProvider
met en évidence un pattern récurrent chez Riverpod.
Étant donné que le ProviderContainer
propose la méthode read
, on aurait pu s'attendre qu'il propose son pendant, la méthode write
.
D'un point de vue conceptuel, l'unique interaction que partagent toutes les classes de Provider
avec le ProviderContainer
est de récupérer un état, d'où l'unique présence de la méthode read.
Cependant, chaque classe de Provider
dispose de son propre contrat et c'est par l'intermédiaire de providers additionnels que les comportements sont adaptés.
Dans le cas du StateProvider
, c'est le provider additionnel notifier
qui ajoute le changement de valeur.
Surveillance entre Providers
Un Provider
peut surveiller un autre Provider
dès son initialisation par l'intermédiaire de la méthode watch
du paramètre ProviderRef
:
final intProvider = StateProvider<int>((_) => 13); // <1>
final readProvider = Provider<int>((ref) => ref.read(intProvider));
final watchProvider = Provider<int>((ref) => ref.watch(intProvider));
void main() {
test('doit surveiller un état', () {
// given:
final container = createContainer();
// expect:
expect(container.read(readProvider), equals(13)); // <2>
expect(container.read(watchProvider), equals(13));
// when:
container.read(intProvider.notifier).state = 42; // <3>
// expect:
expect(container.read(readProvider), equals(13)); // <4>
expect(container.read(watchProvider), equals(42));
});
}
Le StateProvider
intProvider
est initialisé avec la valeur 13, readProvider
vient lire son état et watchProvider
le surveiller <1>
.
Lors de leurs premières lectures, l'état des readProvider
et watchProvider
sont identiques à celui du intProvider
<2>
.
L'état du intProvider
est modifié avec la valeur 42 <3>
et seulement watchProvider
prend en compte ce changement <4>
.
Le comportement des méthodes read
et watch
est identique lors de la première initialisation, l'état du intProvider
est récupéré pour être ensuite retourné.
C'est lors de la modification du intProvider
<3>
que les comportements divergent.
Le readProvider
ne se préoccupe pas de cette nouvelle valeur alors que la watchProvider
vient appeler de nouveau sa méthode d'initialisation <1>
pour mettre à jour son état en adéquation avec celui du intProvider
.
Un
Provider
peut écouter un autreProvider
dès son utilisation pour former un graphe de dépendances.
LeProviderRef
peut être considéré comme une façade auProviderContainer
.
Le comportement attendu des méthodesread
etwatch
est identique selon que l'on utilise unProviderContainer
dans le corps du test ou unProviderRef
dans l'initialisation duProvider
.
Ce fonctionnement en façade est illustré par le code ci-dessous tiré de Riverpod :
@override
T read<T>(ProviderBase<T> provider) {
_assertNotOutdated();
assert(!_debugIsRunningSelector, 'Cannot call ref.read inside a selector');
assert(_debugAssertCanDependOn(provider), '');
return _container.read(provider);
}
À noter que la surveillance vient créer un lien de dépendance entre les Provider
s et peut mener à l'apparition de dépendances circulaires :
source : circular_dependency_test.dart
final Matcher throwsProviderException = throwsA(const TypeMatcher<ProviderException>());
final Provider<int> provider = Provider<int>((ref) => ref.watch(otherProvider)); // <1>
final Provider<int> otherProvider = Provider<int>((ref) => ref.watch(provider));
void main() {
test('doit lever une exception suite à une dépendance circulaires', () {
// given:
final container = createContainer();
// expect:
expect(() => container.read(provider), throwsProviderException); // <2>
});
}
Une interdépendance est déclarée entre deux Provider
<1>
et à la lecture de l'un d'entre eux une exception est levée <2>
.
Riverpod dispose d'un mécanisme qui vient lever une exception quand une dépendance circulaire est détectée.
Le listener qui écoutait à l'oreille des Providers
Un autre moyen d'être notifié d'un changement d'état est de l'écouter avec la méthode listen
proposée par le ProviderContainer
.
Cette méthode prend en paramètre une callback qui sera appelée lors de chaque changement d'état en passant en paramètres l'ancienne et la nouvelle valeur.
final intProvider = StateProvider<int>((_) => 13); // <1>
final watchProvider = Provider<int>((ref) => ref.watch(intProvider));
void main() {
test('doit écouter un état', () {
const defaultValue = -1;
// given:
final container = createContainer();
var intValue = defaultValue; // <2>
container.listen<int>(intProvider, (_, next) => intValue = next);
var watchValue = defaultValue;
container.listen<int>(watchProvider, (_, next) => watchValue = next);
// expect:
expect(intValue, equals(defaultValue)); // <3>
expect(watchValue, equals(defaultValue));
// when:
container.read(intProvider.notifier).state = 42; // <4>
// then:
expect(intValue, equals(42)); // <5>
expect(watchValue, equals(defaultValue));
// when:
container.read(watchProvider); // <6>
// then:
expect(intValue, equals(42)); // <7>
expect(watchValue, equals(42));
});
}
Le StateProvider
intProvider
est initialisé avec la valeur 13 et watchProvider
surveille ses modifications <1>
.
Des listeners écoutent leurs changements d'état respectif pour stocker les nouvelles valeurs <2>
.
Sans aucune modification, ces valeurs écoutées conservent leurs valeurs par défaut <3>
.
Après la modification de l'état du intProvider
avec la valeur 42 <4>
seulement son listener a été notifié <5>
.
Ce n'est qu'après la lecture du watchProvider
que son listener est notifié <6>
.
Écouter n'est pas surveiller.
Écouter un provider avec la méthode listen
permet d'être notifié lors d'un changement d'état.
Ce changement d'état ne sera effectif qu'à partir du moment où il sera lue et non à partir du moment où il a été modifié, c'est un fonctionnement passif.
C'est pour cette raison que la valeur de watchValue
conserve sa valeur par défaut <6>
.
Surveiller un provider avec la méthode watch
vient lire le nouvel état à la suite d'un changement.
Ce changement d'état est effectif dès sa modification, c'est un fonctionnement actif.
Cependant, le fonctionnement paresseux du ProviderContainer
ne propagera le changement <7>
qu'à partir du moment où le Provider
sera lu <6>
.
Écouter est un fonctionnement passif alors que surveiller est un fonctionnement actif.
En plus d'être paresseux à l'initialisation des états, leProviderContainer
l'est également lors leurs modifications.
Devenir sélectif dans les changements d'états
Être notifié par tous les changements d'états peut mener à des pertes de performances.
Ce problème est résolu par la méthode select
des Provider
qui permet d'agréger leur état pour ne conserver que les valeurs utiles.
final intProvider = StateProvider((_) => 13); // <1>
final moduloProvider = Provider<int>((ref) => ref.watch(intProvider.select((state) => state % 10)));
void main() {
test('doit écouter le modulo 10', () {
// given:
final container = createContainer();
// and:
var called = 0;
container.listen(moduloProvider, (_, __) => called++); // <2>
// expect:
expect(container.read(moduloProvider), equals(3)); // <3>
expect(called, equals(0));
// when:
container.read(intProvider.notifier).state = 42; // <4>
// then:
expect(container.read(moduloProvider), equals(2)); // <5>
expect(called, equals(1));
// when:
container.read(intProvider.notifier).state = 22; // <6>
// expect:
expect(container.read(moduloProvider), equals(2)); // <7>
expect(called, equals(1));
});
}
Le StateProvider
intProvider
est initialisé avec la valeur 13 et le moduloProvider
vient l'écouter en sélectionnant uniquement le modulo de 10 de l'état <1>
.
Un compteur écoute le nombre de changements réalisés par moduloProvider
<2>
.
Initialement le modulo de 13 vaut 3 et aucun changement n'est encore réalisé <3>
.
Suite à la modification de la valeur de intProvider
par 42 <4>
, le modulo vaut 2 et un changement est ajouté au compteur <5>
.
La valeur du intProvider
est à nouveau modifiée avec la valeur 22 <6>
mais étant donné que son modulo est identique à celui de 42 aucune modification n'est apportée à l'état du moduloProvider
<7>
.
À noter que cet exemple n'a vocation qu'à présenter la théorie : l'utilisation du select
dans l'initialisation d'un provider ne présente aucun intérêt étant donné que le comportement est identique à celui d'un Provider
effectuant lui-même l'opération :
final moduloProvider = Provider<int>((ref) => ref.watch(intProvider) % 10);
Le select
prendra tout son intérêt lors de l'intégration avec Flutter afin de ne conserver que les données utiles à surveiller pour économiser les rebuild et gagner en performances.
Accéder au notifier avec le StateNotifierProvider
Jusqu'à présent les Provider
s ne donnaient accès qu'à un état, qu'il soit non modifiable avec un Provider
ou modifiable avec un StateProvider
par l'intermédiaire de son notifier
.
Le StateNotifierProvider
donne accès à ce notifier
pour permettre au développeur de l'enrichir avec de nouvelles méthodes :
final incrementProvider = StateNotifierProvider<IncrementNotifier, int>(
(ref) => IncrementNotifier(ref.watch(intProvider)),
);
class IncrementNotifier extends StateNotifier<int> {
IncrementNotifier(int value) : super(value);
void increment() {
state++;
}
}
Le StateNotifierProvider
est formé de deux composants : le contenant avec la classe StateNotifier
et le contenu avec sa variable state
.
Chacun dispose de son propre cycle de vie, celui de l'état étant dépendant de celui du notifier :
source : state_notifier_provider_test.dart
final intProvider = StateProvider<int>((ref) => 13); // <1>
final incrementProvider = StateNotifierProvider<IncrementNotifier, int>(
(ref) => IncrementNotifier(ref.watch(intProvider)),
);
class IncrementNotifier extends StateNotifier<int> {
IncrementNotifier(int value) : super(value);
void increment() { // <2>
state++;
}
}
void main() {
test('doit incrémenter un état', () {
// given:
final container = createContainer();
// expect:
expect(container.read(incrementProvider), equals(13)); // <3>
// when:
container.read(incrementProvider.notifier).increment(); // <4>
// then:
expect(container.read(incrementProvider), equals(14)); // <5>
// when:
container.read(intProvider.notifier).state = 42; // <6>
// then:
expect(container.read(incrementProvider), equals(42)); // <7>
});
}
Le StateProvider
intProvider
est initialisé avec la valeur 13 <1>
.
Il est écouté par le StateNotifierProvider
incrementProvider
dont le notifier IncrementNotifier
dispose d'une méthode pour incrémenter son état <2>
.
L'état récupéré par le incrementProvider
est bien celui du intProvider
<3>
.
L'appel à la méthode increment
<4>
fait passer la valeur du incrementProvider
de 13 à 14 <5>
.
Après avoir modifié l'état du intProvider
, le incrementProvider
est initialisé à nouveau et prend la valeur 42.
Le contrat du
StateNotifierProvider
va de la récupération à la modification de l'état.
La classeStateNotifier
est à étendre en indiquant le type de l'état en générique et de passer sa valeur initiale au constructeur parent.
L'état est stocké dans la variablestate
avec une visibilité limitée enprotected
pour conserver une implémentation étanche.
Les modifications sont réalisées en affectant un nouvel objet à la variablestate
.
Chacune de ces modifications est ensuite notifiée aux objets qui le surveillent.
ChangeNotifierProvider, le vilain petit canard
Jusqu'à présent les états se devaient d'être immutables mais, pour des questions de performances ou de conception, il est parfois nécessaire d'abandonner cette bonne pratique.
Le ChangeNotifierProvider
répond à ce cas de figure en laissant à la charge du développeur de notifier les changements apportés à l'état :
final incrementProvider = ChangeNotifierProvider<IncrementNotifier>(
(ref) => IncrementNotifier(13),
);
class IncrementNotifier extends ChangeNotifier {
IncrementNotifier(this.value);
int value;
void increment() {
value++; // <1> Modification de l'état
notifyListeners(); // <2> Notification de l'état
}
}
Comme le StateNotifierProvider
, il se compose d'un contenant avec le ChangeNotifier
mais cette fois-ci qui déclare lui-même son propre contenu.
Le contrat du
ChangeNotifierProvider
va de la récupération à la modification de l'état.
La classeChangeNotifier
est à étendre et les changements internes sont à notifier manuellement en appelant la méthodenotifyListeners
.
Les notifications sont ensuite transmises aux objets qui les surveillent.
source : change_notifier_provider_test.dart
final incrementProvider = ChangeNotifierProvider<IncrementNotifier>(
(ref) => IncrementNotifier(13), // <1>
);
class IncrementNotifier extends ChangeNotifier {
IncrementNotifier(this.number);
int number;
void increment() {
number++;
notifyListeners();
}
}
void main() {
test('doit incrémenter un état', () {
// given:
final container = createContainer();
// expect:
expect(container.read(incrementProvider.notifier).number, equals(13)); // <2>
// when:
container.read(incrementProvider).increment(); // <3>
// then:
expect(container.read(incrementProvider).number, equals(14)); // <4>
});
}
La classe IncrementNotifier
, un ChangeNotifier
avec une méthode increment
pour incrémenter sa propriété number
, est initialisé avec la valeur 13 <1>
.
La propriété number
de son notifier
dispose bien de la valeur 13.
Après avoir été incrémentée <3>
, la valeur prend la valeur 14 <4>
.
Aucune séparation n'étant faite entre le contenant et le contenu du ChangeNotifier
, il est retourné par le ProviderContainer
aussi bien en tant qu'état <2>
, qu'en tant que provider additionnel notifier
<4>
.
À noter que la classe ChangeNotifier
est initialement proposée par Flutter pour fournir un mécanisme d'écoute et de notification.
Le ChangeNotifierProvider
est donc parfois utile pour migrer d'anciennes applications utilisant le ChangeNotifier
comme gestion d'états.
Détour sur le Future avec le FutureProvider
Le Future
est mutable et utilise des callbacks alors que Riverpod prône l'immutabilité et utilise des états.
Ces deux conceptions, radicalement opposées, deviennent compatible en adaptant le Future
par un FutureProvider
.
Le FutureProvider
retourne l'interface[1] AsyncValue
dont les implémentations traduisent en états immutables les différentes étapes d'un Future
.
Le chargement, les callbacks de retour et d'erreur sont respectivement représentés par ses factories AsyncValue.loading
, AsyncValue.data
et AsyncValue.error
.
À cela vient s'ajouter la méthode statique AsyncValue.guard
pour adapter une fonction retournant un Future
en Future
d'AsyncValue
.
L'utilisation de lAsyncValue
est simplifiée par les méthodes de son extension AsyncValueX
, inspirée par les unions de freezed, en proposant un équivalent au pattern matching.
source : future_provider_test.dart
const duration = Duration(milliseconds: 100);
final asyncIntProvider = FutureProvider<int>( // <1>
(ref) => Future.delayed(duration, () => 13),
);
void main() {
test('doit consommer un futur', () async {
// given:
final container = createContainer();
// expect:
expect(container.read(asyncIntProvider), equals(const AsyncValue<int>.loading())); // <2>
// when:
await Future.delayed(duration + const Duration(milliseconds: 50)); // <3>
// then:
expect(container.read(asyncIntProvider), equals(const AsyncValue.data(13))); // <4>
});
}
Un FutureProvider
est instancié pour que son état prenne la valeur 13 après 100ms <1>
.
Tant que le Futur
n'a pas été résolu, aucune valeur n'est attribuée à l'état et il conserve la valeur AsyncLoading
<2>
.
Après avoir attendu 150ms <3>
, l'état devient un AsynData
avec pour valeur 13 <4>
.
Le flux et le StreamProvider
Le fonctionnement d'une Stream
est similaire à celui d'un Future
, à ceci près qu'elle peut retourner plusieurs valeurs durant son cycle de vie :
source : stream_provider_test.dart
const duration = Duration(milliseconds: 100);
final asyncIntProvider = StreamProvider<int>( // <1>
(ref) async* {
await Future.delayed(duration);
yield 13;
await Future.delayed(duration);
throw Error();
},
);
void main() {
test('doit consommer une stream', () async {
// given:
final container = createContainer();
// expect:
expect(container.read(asyncIntProvider), equals(const AsyncValue<int>.loading())); // <2>
// when:
await Future.delayed(duration + const Duration(milliseconds: 50)); // <3>
// then:
expect(container.read(asyncIntProvider), equals(const AsyncValue.data(13))); // <4>
// when:
await Future.delayed(duration); // <5>
// then:
expect(container.read(asyncIntProvider), isInstanceOf<AsyncError>()); // <6>
});
}
Une Stream
est déclarée pour retourner la valeur 13 suivie d'une erreur, le tout entrecoupé par un délai de 100 millisecondes <1>
.
Tant que la première valeur de la Stream
n'est pas retournée, l'état conserve la valeur AsyncLoading
<2>
.
Après avoir attendu 150ms <3>
, l'état devient un AsynData
avec pour valeur 13 <4>
.
Une exception est levée 100ms plus tard <5>
et l'état retourne une AsyncError
<6>
.
L'aventure ne fait que commencer...
Ainsi se termine ce premier article sur Riverpod.
Vous disposez maintenant des bases pour en comprendre les principaux mécanismes.
Le prochain article sera plus court et portera sur son intégration avec Flutter.
Les interfaces n'existent pas à proprement parler en Dart, ce sont des classes abstraites avec des méthodes abstraites dont l'interface implicite est implémentée. ↩︎