Étude de cas : Feed à défilement infini
Objectif
Contexte : Un feed à défilement infini semble simple : charger plus de données au fil du scroll. Les contraintes de production sont strictes : après 30 minutes de scroll, votre ListView contient 2 000 widgets, la mémoire explose et l'OS termine votre app. Par ailleurs, si vous utilisez des numéros de page entiers et qu'un post est inséré en cours de scroll, chaque page suivante est décalée d'un et les utilisateurs voient des doublons.
Cet exemple démontre deux contrôles fondamentaux. La pagination par curseur élimine la dérive des décalages. La pagination fenêtrée avec maxPages impose une empreinte mémoire bornée tout en permettant la récupération de pages sur demande.
Fonctionnalités démontrées
InfiniteQueryBuilder: accumule les pages et exposefetchNextPage/fetchPreviousPagevia son controller- Pagination par curseur : résultats stables indépendamment des insertions côté serveur
- Gestion mémoire fenêtrée avec
maxPages: plafonne la fenêtre de pages en mémoire ; supprime les plus anciennes/récentes quand dépassé isFetchingNextPage+isFetchingPreviousPage: pilote le loader de fin de scroll sans masquer le contenu existantInfiniteData.flatten(): convertit la matrice de pages en liste plate pourSliverList- Création de post optimiste :
setInfiniteQueryDataprépose un nouveau post instantanément ; rollback en cas d'échec refetch(): pull-to-refresh qui refetche toutes les pages chargées en préservant le nombre de pagesInfiniteFailureavecpreviousData: les erreurs de chargement supplémentaire affichent un bouton de réessai sans effacer le feed
Points d'architecture
La couche UI est construite sur un CustomScrollView avec deux slivers : un CupertinoSliverRefreshControl pour le pull-to-refresh et un SliverList.builder pour les éléments du feed. InfiniteQueryBuilder encapsule la vue de scroll et possède le cycle de vie de l'observer.
FeedScreen
└── CustomScrollView
├── CupertinoSliverRefreshControl ← controller.refetch() au pull
└── SliverList.builder
├── items = data.flatten((p) => p.posts) ← liste plate de toutes les pages
├── [dernier élément]
│ ├── hasNextPage → LoadMoreTrigger ← déclenche controller.fetchNextPage()
│ └── isFetchingNextPage → PageLoader ← spinner en bas de liste
└── InfiniteFailure avec previousData → ErrorRetryBanner
InfiniteQueryBuilder est configuré avec un curseur String. Une chaîne vide signifie « commencer depuis le début » : le serveur renvoie les posts les plus récents et un nextCursor pointant vers le contenu plus ancien.
InfiniteQueryBuilder<FeedPage, String>(
queryKey: const ['feed'],
fetcher: FeedApi.getFeed,
options: InfiniteQueryOptions<FeedPage, String>(
initialPageParam: '', // '' = posts les plus récents
getNextPageParam: (last, _) => last.nextCursor, // posts plus anciens
getPreviousPageParam: (first, _) => first.previousCursor, // non nul uniquement après une éviction
maxPages: 3, // 3 pour voir l'éviction rapidement ; 8 à 10 en production
baseOptions: const QoraOptions(
staleTime: Duration(minutes: 2),
retryCount: 3,
),
),
builder: (context, state, controller) => FeedList(state: state, controller: controller),
)
maxPages borne l'InfiniteData gardé en mémoire. L'exemple utilise 3 pour que l'éviction soit visible après quelques pages ; à 20 posts par page, un feed de production utiliserait plutôt 8 à 10 (160 à 200 posts).
Détails d'implémentation
Pagination fenêtrée : défiler dans le temps
Quand l'utilisateur défile assez bas pour charger la page 4, maxPages: 3 provoque la suppression de la page 1 (les posts les plus récents) depuis InfiniteData. Le hasPreviousPage de l'état devient true. Si l'utilisateur défile à nouveau vers le haut, le SliverList le détecte et appelle controller.fetchPreviousPage(), qui refetche la page supprimée en utilisant getPreviousPageParam.
Ce mécanisme complet: suppression, détection et refetch est pourquoi la pagination par curseur est obligatoire avec maxPages. Les numéros de page entiers se briseraient ici : si 3 nouveaux posts étaient insérés pendant le scroll, refetcher "page 1" avec ?page=1&limit=20 retournerait des posts différents du premier chargement. Les curseurs ancrent à une position stable côté serveur.
void _onScroll() {
final pos = _scrollController.position;
// Déclencher la page suivante quand à moins de 300px du bas
if (pos.extentAfter < 300 &&
_latestState case InfiniteSuccess(:final hasNextPage, :final isFetchingNextPage)
when hasNextPage && !isFetchingNextPage) {
controller.fetchNextPage();
}
// Déclencher la page précédente quand à moins de 300px du haut
// (pertinent uniquement après qu'une éviction maxPages a supprimé la page 1)
if (pos.extentBefore < 300 &&
_latestState case InfiniteSuccess(:final hasPreviousPage, :final isFetchingPreviousPage)
when hasPreviousPage && !isFetchingPreviousPage) {
controller.fetchPreviousPage();
}
}
Création de post optimiste avec setInfiniteQueryData
Quand l'utilisateur crée un nouveau post, il doit apparaître en haut du feed instantanément, avant la réponse du serveur. Qora expose setInfiniteQueryData pour écrire directement dans le cache infini :
Future<void> _publish(String content) async {
final client = context.qora;
const feedKey = ['feed'];
// 1. Snapshot pour le rollback
final snapshot = client.getInfiniteQueryData<FeedPage, String?>(feedKey);
// 2. Préposition optimiste à la première page
if (snapshot != null) {
client.setInfiniteQueryData<FeedPage, String?>(
feedKey,
InfiniteData(
pages: [
FeedPage(
posts: [
Post.optimistic(
id: 'temp-${DateTime.now().millisecondsSinceEpoch}',
content: content,
author: currentUser,
),
...snapshot.pages.first.posts,
],
nextCursor: snapshot.pages.first.nextCursor,
previousCursor: null, // le post optimiste n'a pas encore de curseur stable
),
...snapshot.pages.skip(1),
],
pageParams: snapshot.pageParams,
),
);
}
try {
// 3. Déclencher la vraie mutation
await api.createPost(content);
// 4. Invalider : remplacer l'élément optimiste par la réponse serveur
// refetch() refetche toutes les pages courantes en préservant le nombre de pages
await controller.refetch();
} catch (error) {
// 5. Rollback : restaurer le snapshot exact pré-mutation
if (snapshot != null) {
client.setInfiniteQueryData(feedKey, snapshot);
}
_showError(error);
}
}
Le détail critique est l'étape 4 : appeler controller.refetch() plutôt que invalidateInfiniteQuery. refetch() refetche toutes les pages actuellement chargées (8 pages × 20 éléments = 160 posts revalidés en séquence), ce qui remplace le post optimiste avec ID temporaire par le vrai objet serveur. invalidateInfiniteQuery réinitialiserait l'état à InfiniteInitial et recommencerait depuis la page 1, perdant la position de scroll de l'utilisateur.
InfiniteFailure avec dégradation gracieuse
Les erreurs de chargement supplémentaire ne doivent jamais effacer le feed déjà chargé. InfiniteFailure préserve previousData: le dernier InfiniteData réussi, exactement pour ce cas :
case InfiniteFailure(:final error, :final previousData):
if (previousData != null) {
final posts = previousData.flatten((p) => p.posts);
return SliverList.builder(
itemCount: posts.length + 1, // +1 pour le footer d'erreur
itemBuilder: (context, i) {
if (i < posts.length) return PostTile(post: posts[i]);
// Footer d'erreur tout en bas
return _LoadMoreErrorBanner(
error: error,
onRetry: controller.fetchNextPage,
);
},
);
}
// Aucune donnée du tout : écran d'erreur complet
return SliverFillRemaining(child: ErrorScreen(error));
L'appel flatten
InfiniteData<FeedPage, String?> contient une List<FeedPage>: un élément par page chargée. flatten extrait la liste interne de chaque page et les concatène :
case InfiniteSuccess(:final data, :final hasNextPage, :final isFetchingNextPage):
final posts = data.flatten((page) => page.posts); // List<Post>
return SliverList.builder(
itemCount: posts.length + 1,
itemBuilder: (context, index) {
if (index < posts.length) return PostTile(post: posts[index]);
// Footer : spinner, déclencheur "charger plus" ou message fin de feed
if (isFetchingNextPage) return const _PageLoader();
if (hasNextPage) return _LoadMoreButton(onTap: controller.fetchNextPage);
return const _EndOfFeedBanner();
},
);
Code source
examples/ex_03_infinite_scroll/
├── lib/
│ ├── main.dart ← configuration QoraClient + QoraScope
│ ├── features/
│ │ └── feed/
│ │ ├── data/
│ │ │ └── feed_api.dart ← appels HTTP par curseur
│ │ ├── models/
│ │ │ ├── feed_page.dart ← { posts, nextCursor, previousCursor }
│ │ │ └── post.dart ← constructeur Post.optimistic()
│ │ └── ui/
│ │ ├── feed_screen.dart ← InfiniteQueryBuilder + logique de scroll
│ │ ├── post_tile.dart ← widget post individuel
│ │ ├── compose_sheet.dart ← création de post optimiste
│ │ └── widgets/
│ │ ├── page_loader.dart ← spinner en bas de liste
│ │ ├── load_more_error.dart ← bannière de réessai en cas d'échec de page
│ │ └── end_of_feed.dart ← bannière "Vous êtes à jour"
└── pubspec.yaml
Notes de performance
| Problème | Solution |
|---|---|
| Débordement mémoire lors de longues sessions | maxPages plafonne la fenêtre de pages ; les pages évincées sont refetchées à la demande |
| Jank de scroll sur de grandes listes | SliverList.builder ne rend que les éléments visibles ; l'objet InfiniteData lui-même est borné |
| Dérive de curseur lors d'insertions serveur | La pagination par curseur ancre chaque page à une position stable côté serveur |
Thundering-herd sur refetch() | Les pages sont refetchées séquentiellement, pas en parallèle |
| Feed périmé au retour sur l'onglet | staleTime: 2 minutes déclenche un refetch() en arrière-plan au re-focus de l'onglet via FlutterLifecycleManager |
Guides associés
- Guide des requêtes infinies:
InfiniteData,InfiniteQueryObserveren Dart pur, pagination fenêtrée et défilement bidirectionnel - InfiniteQueryBuilder: binding du widget Flutter
- Guide des mises à jour optimistes:
setQueryData,getQueryData,restoreQueryData - Référence API Infinite Query: référence complète des méthodes
Étude de cas : Mutations optimistes et rollback
Une implémentation complète de mise à jour optimiste : setQueryData pour un retour UI immédiat, restoreQueryData pour le rollback automatique en cas d'échec, et invalidateWhere pour la réconciliation serveur.
Étude de cas : Application Todo offline-first
Une application de todos qui continue de fonctionner sans réseau : données sauvegardées au démarrage, requêtes en pause hors-ligne, écritures mises en file et rejouées dans l'ordre à la reconnexion.