Boucle de reconstruction infinie Riverpod
Problème
Les widgets Flutter utilisant Riverpod entrent dans une boucle de reconstruction infinie, causant :
- 50+ reconstructions de widgets en quelques secondes
- L'interface utilisateur devient non réactive
- Le même contenu s'affiche à répétition
- Le bug se manifeste principalement sur les appareils plus lents ou les connexions réseau faibles
Contexte / Conditions de déclenchement
Symptômes dans les logs :
⚠️ RAPID REBUILD #54! Only 15ms since last build
⚠️ RAPID REBUILD DETECTED! Only 6ms since last build
Symptômes rapportés par l'utilisateur :
- « Boucle de défilement infini » - la même vidéo/le même contenu continue d'apparaître
- « L'application gèle » ou devient non réactive
- « Fonctionne sur mon téléphone mais pas sur les appareils plus anciens »
Scénarios de déclenchement :
- Plusieurs providers asynchrones se terminent à des moments décalés
- Provider qui surveille l'état de la route/URL ET met à jour l'URL en build
- Utilisation de
ref.watch()ET ajout d'écouteurs manuels au même provider - Observations transitives : Widget observe A, A observe B, Widget observe aussi B
Analyse de la cause racine
Pattern 1 : Boucle de rétroaction de mise à jour d'URL
// MAUVAIS : Crée une boucle infinie
Widget build(BuildContext context) {
final pageContext = ref.watch(pageContextProvider); // Observe l'URL
final videos = ref.watch(videosProvider);
// Détecte le changement de position vidéo et « silencieusement » met à jour l'URL
if (currentVideoIndex != urlIndex) {
context.go('/home/$currentVideoIndex'); // Le changement d'URL déclenche une reconstruction !
}
}
Boucle : Les vidéos se réorganisent → URL mise à jour → pageContextProvider émet → reconstruction → les vidéos se réorganisent peut-être à nouveau → répétition
Pattern 2 : Redondance Watch + Listener
// MAUVAIS : La double-souscription cause des reconstructions doubles
final cache = ref.watch(cacheProvider); // Watch déclenche une reconstruction
cache.addListener(onCacheChanged); // L'écouteur DÉCLENCHE AUSSI une action
Pattern 3 : Dépendances d'observation transitive
// MAUVAIS : Double-observation de la même source
Widget build() {
ref.watch(pageContextProvider); // Observation #1
ref.watch(derivedProvider); // derivedProvider OBSERVE AUSSI pageContextProvider !
}
Pattern 4 : Chargement de provider asynchrone décalé
// PROBLÉMATIQUE sur les appareils lents : Chaque observation déclenche une reconstruction à la fin du provider
final a = ref.watch(asyncProviderA); // Se termine à T=100ms → reconstruction
final b = ref.watch(asyncProviderB); // Se termine à T=200ms → reconstruction
final c = ref.watch(asyncProviderC); // Se termine à T=350ms → reconstruction
final d = ref.watch(asyncProviderD); // Se termine à T=500ms → reconstruction
// Sur les appareils rapides : tous se terminent ~simultanément, 1-2 reconstructions
// Sur les appareils lents : fin échelonnée, 4+ reconstructions
Solution
Étape 1 : Audit de l'utilisation de ref.watch()
Pour chaque ref.watch() dans les méthodes build, demandez-vous :
- Ce provider change-t-il fréquemment ?
- Ai-je besoin de RECONSTRUIRE quand il change, ou seulement de RÉAGIR ?
- Suis-je aussi en train d'écouter manuellement ce provider ?
Méthodes Riverpod :
| Méthode | Comportement |
|---------|----------|
| ref.watch() | Souscrivez + RECONSTRUISEZ au changement |
| ref.read() | Lisez une fois, PAS de reconstruction |
| ref.listen() | Souscrivez + callback, PAS de reconstruction |
Étape 2 : Convertir les observations inutiles
// AVANT : Reconstructions à chaque changement
final videoService = ref.watch(videoServiceProvider);
// APRÈS : Lire une fois, utiliser un écouteur pour les réactions
final videoService = ref.read(videoServiceProvider);
ref.listen(videoServiceProvider, (prev, next) {
// Réagir aux changements sans reconstruire
if (next.hasNewVideos) refreshUI();
});
Étape 3 : Supprimer la redondance Watch + Listener
// AVANT : Double-souscription
final cache = ref.watch(cacheProvider);
cache.addListener(onCacheChanged);
// APRÈS : Choisir une approche
// Option A : Juste observer (si la reconstruction est nécessaire)
final cache = ref.watch(cacheProvider);
// Option B : Lire + écouter (si la reconstruction n'est pas nécessaire)
final cache = ref.read(cacheProvider);
ref.listen(cacheProvider, (_, __) => onCacheChanged());
Étape 4 : Casser les boucles de mise à jour d'URL
// AVANT : Mise à jour d'URL en build cause une boucle
if (currentVideoIndex != urlIndex) {
context.go('/home/$currentVideoIndex');
}
// APRÈS : Ne pas mettre à jour l'URL sur réorganisation du contenu
// Juste tracker la position avec PageController, pas l'URL
// OU utiliser l'ID du contenu dans l'URL au lieu de l'index : /home/video/abc123
Étape 5 : Lot de charge initiale
// AVANT : Observer chaque provider asynchrone (N reconstructions sur les appareils lents)
final a = ref.watch(asyncA);
final b = ref.watch(asyncB);
// APRÈS : Créer un provider combiné qui attend tous les éléments
@riverpod
Future<CombinedState> combinedState(Ref ref) async {
final a = await ref.watch(asyncA.future);
final b = await ref.watch(asyncB.future);
return CombinedState(a, b);
}
// Le Widget n'observe que le provider combiné (1 reconstruction)
Vérification
Après les corrections :
- Exécutez l'application sur un appareil lent ou utilisez la limitation du réseau
- Vérifiez les logs pour les avertissements de reconstruction - devrait voir ≤5 reconstructions au démarrage
- Faites défiler/naviguez et vérifiez que l'interface utilisateur reste réactive
- Aucun avertissement « RAPID REBUILD » dans les logs
Exemple : Correction complète
Avant (problématique) :
class _HomeScreenState extends ConsumerState<HomeScreen> {
@override
Widget build(BuildContext context) {
final pageContext = ref.watch(pageContextProvider);
final videos = ref.watch(videosProvider); // Observe aussi pageContextProvider en interne !
// Boucle de mise à jour d'URL
if (videos.currentIndex != pageContext.index) {
WidgetsBinding.instance.addPostFrameCallback((_) {
context.go('/home/${videos.currentIndex}');
});
}
return PageView(...);
}
}
Après (corrigé) :
class _HomeScreenState extends ConsumerState<HomeScreen> {
@override
Widget build(BuildContext context) {
// Observer uniquement les vidéos, lire le contexte de page
final pageContext = ref.read(pageContextProvider).requireValue;
final videos = ref.watch(videosProvider);
// Ne pas mettre à jour l'URL sur réorganisation - tracker avec PageController uniquement
// L'URL ne change que sur navigation utilisateur explicite
return PageView(...);
}
}
Notes
-
Ce bug est dépendant du timing - peut ne pas se reproduire sur les appareils rapides
-
Testez sur des appareils physiques avec limitation du réseau pour détecter les problèmes
-
Ajoutez la journalisation de détection de reconstruction pendant le développement :
static int _buildCount = 0; static DateTime? _lastBuild; @override Widget build(BuildContext context) { final now = DateTime.now(); if (_lastBuild != null && now.difference(_lastBuild!).inMilliseconds < 100) { Log.warning('RAPID REBUILD #${++_buildCount}!'); } _lastBuild = now; // ... }