Avec RxDart
Vue d'ensemble
RxDart étend les streams Dart avec des opérateurs composables. Combiné à QoraClient.watchQuery, l'opérateur switchMap fournit un pattern déclaratif pour la commutation dynamique de clés de requête : l'abonnement actif est annulé atomiquement à chaque émission upstream, et un nouvel abonnement est ouvert pour la nouvelle clé.
Ce pattern remplace la gestion manuelle des abonnements dans les ViewModels et les notifiers d'état qui doivent répondre aux changements d'une valeur pilote (un ID sélectionné, un filtre, un index d'onglet).
Configuration
dependencies:
qora: ^1.3.1
rxdart: ^0.28.0
Gestion réactive des clés
Le pattern
import 'package:rxdart/rxdart.dart';
import 'package:qora/qora.dart';
class UserViewModel {
UserViewModel(this._client);
final QoraClient _client;
// Subject pilote: émet l'ID utilisateur actuellement sélectionné.
final _idSubject = BehaviorSubject<String>.seeded('1');
// Stream dérivé: commute l'abonnement watchQuery actif à chaque
// nouvel ID. L'abonnement précédent est annulé avant l'ouverture du suivant.
late final Stream<QoraState<User>> userState = _idSubject
.switchMap(
(id) => _client.watchQuery<User>(
key: ['users', id],
fetcher: () => api.getUser(id),
options: const QoraOptions(staleTime: Duration(minutes: 5)),
),
)
.doOnData((state) {
// Les effets de bord s'exécutent après chaque émission d'état, avant le rebuild du widget.
if (state case Success(:final data)) {
analytics.track('user_loaded', {'id': data.id});
}
});
// Avancer le subject pilote. switchMap gère le reste.
void selectUser(String id) => _idSubject.add(id);
void dispose() => _idSubject.close();
}
Binding du widget
class UserScreen extends StatefulWidget {
const UserScreen({super.key});
@override
State<UserScreen> createState() => _UserScreenState();
}
class _UserScreenState extends State<UserScreen> {
late final UserViewModel _vm;
late final StreamSubscription<QoraState<User>> _sub;
QoraState<User> _state = const Initial();
@override
void initState() {
super.initState();
_vm = UserViewModel(context.qora);
_sub = _vm.userState.listen((state) {
setState(() => _state = state);
});
}
@override
void dispose() {
_sub.cancel();
_vm.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return switch (_state) {
Initial() => const SizedBox.shrink(),
Loading(:final previousData) => previousData != null
? UserCard(previousData)
: const CircularProgressIndicator(),
Success(:final data) => Column(
children: [
UserCard(data),
Row(
children: [
TextButton(
onPressed: () => _vm.selectUser('1'),
child: const Text('Utilisateur 1'),
),
TextButton(
onPressed: () => _vm.selectUser('2'),
child: const Text('Utilisateur 2'),
),
],
),
],
),
Failure(:final error) => ErrorView(error),
};
}
}
Analyse du cycle de vie
Ce qui se passe lors d'un changement de clé
Quand _idSubject.add('2') est appelé, la séquence suivante s'exécute atomiquement :
switchMapappelle.cancel()sur leStreamSubscriptionverswatchQuery(key: ['users', '1'], ...).- Le runtime du générateur asynchrone Dart déclenche le bloc
finallyà l'intérieur dewatchQuery:// packages/dart: qora_client.dart, bloc finally de watchQuery } finally { depSub?.cancel(); // annuler l'abonnement de dépendance, le cas échéant localRefetchTimer?.cancel(); // annuler tout timer de polling actif entry.removeSubscriber(); // décrémenter CacheEntry._subscriberCount _scheduleGC(normalized); // démarrer le timer GC s'il n'y a plus d'abonnés } removeSubscriber()décrémenteCacheEntry._subscriberCountet appelletouch()(met à jourlastAccessedAt). Si le compteur atteint zéro,entry.isActivedevientfalse._scheduleGC(key)inspecteentry.isActive. Sifalse, il démarre unTimeravec une durée degcTime(défaut :Duration(minutes: 5)). Sientry.isActiveest toujourstrue, parce qu'une autre partie de l'app maintient un abonnement séparé à la même clé,_scheduleGCest un no-op.switchMapouvre un nouvel abonnement verswatchQuery(key: ['users', '2'], ...).addSubscriber()incrémente le_subscriberCountde la nouvelle entrée.
Pourquoi les données de l'ancienne clé survivent
Le timer GC se déclenche gcTime après l'annulation du dernier abonné, pas immédiatement. Pendant cette fenêtre :
- L'entrée de cache pour
['users', '1']reste en mémoire avec son dernier étatSuccess. - Si
selectUser('1')est rappelé avant le déclenchement du timer, un nouvel abonnementwatchQueryappelleaddSubscriber(),entry.isActiveredevienttrue, et_scheduleGCest un no-op, le timer est annulé s'il était déjà en cours. - Le prochain accès sert les données en cache immédiatement, puis revalide en arrière-plan si les données sont périmées.
Cela signifie que la navigation entre deux enregistrements utilisateurs avec staleTime: Duration(minutes: 5) résulte en au maximum une requête réseau par fenêtre de 5 minutes par clé, quel que soit le nombre de fois que l'utilisateur commute.
Re-vérification à l'expiration du GC
Le timer GC re-vérifie entry.isActive avant de supprimer l'entrée :
entry.gcTimer = Timer(config.defaultOptions.gcTime, () {
if (!entry.isActive) { // re-vérifier: un nouvel abonné a pu s'attacher
_cache.remove(key);
}
});
Un nouvel abonné qui s'attache après la planification du timer mais avant son déclenchement trouvera l'entrée intacte et empêchera sa suppression.
Référence des opérateurs
| Opérateur RxDart | Rôle dans ce pattern |
|---|---|
BehaviorSubject | Maintient la valeur de clé actuelle ; rejoue la dernière émission aux nouveaux listeners. |
switchMap | Annule l'abonnement watchQuery précédent à chaque émission ; ouvre un nouveau. |
doOnData | Exécute des effets de bord (journalisation, analytics) sur chaque état émis sans modifier le stream. |
debounceTime | Optionnel : débouce les changements de clé rapides (ex. saisie de recherche) pour éviter de lancer un fetch par frappe. |
distinctUnchanged | Optionnel : supprime le re-abonnement quand la même clé est émise deux fois consécutivement. |
Debouce des changements de clé rapides
Pour les saisies de recherche ou autres pilotes à haute fréquence, ajouter debounceTime avant switchMap :
late final Stream<QoraState<List<Product>>> searchResults = _querySubject
.debounceTime(const Duration(milliseconds: 300))
.distinct()
.switchMap(
(query) => _client.watchQuery<List<Product>>(
key: ['products', 'search', query],
fetcher: () => api.searchProducts(query),
),
);
switchMap annule toujours l'abonnement en cours à chaque émission, donc un debouce de 300 ms est suffisant pour éviter de lancer un fetch par caractère tout en préservant l'annulation instantanée de toute requête qui démarre.
Équivalent avec les hooks
Si qora_hooks est disponible, le même comportement s'exprime sans RxDart :
class UserScreen extends HookWidget {
const UserScreen({super.key});
@override
Widget build(BuildContext context) {
final selectedId = useState('1');
final state = useQuery<User>(
key: ['users', selectedId.value],
fetcher: () => api.getUser(selectedId.value),
options: const QoraOptions(staleTime: Duration(minutes: 5)),
);
// useQuery se ré-abonne automatiquement quand la clé change.
// L'abonnement précédent est annulé dans son nettoyage useEffect.
return switch (state) {
Initial() => const SizedBox.shrink(),
Loading(:final previousData) => previousData != null
? UserCard(previousData)
: const CircularProgressIndicator(),
Success(:final data) => UserCard(data),
Failure(:final error) => ErrorView(error),
};
}
}
useQuery utilise useEffect([Object.hashAll(key)]) pour annuler et se ré-abonner lors du changement de clé. Le cycle de vie GC est identique : le chemin d'annulation est le même bloc finally de watchQuery.
Utiliser le pattern RxDart dans les contextes non-HookWidget ou quand la logique de pilotage des clés réside dans une couche ViewModel en dehors de l'arbre de widgets.