Paging 3 avec Room : garder une liste de transactions fluide sur Android en 2026
Comment paginer une liste de transactions adossée à Room avec Jetpack Paging 3 pour que des années de données restent fluides dans Compose — pas de RemoteMediator, pas de réseau, juste une base de données locale bien faite.
La liste de transactions d’une app de budget a l’air très bien dans une démo avec trente lignes. Elle cesse d’avoir l’air bien deux ans plus tard, quand un vrai utilisateur en a quatre mille et que faire défiler l’écran d’historique commence à saccader. La solution n’est pas un LIMIT plus grand, et ce n’est pas non plus “ajoutez juste un indicateur de chargement en bas” — c’est Jetpack Paging 3, câblé directement sur Room sans aucune couche réseau. Voici comment je l’ai construit pour Granyn, et les deux erreurs qui m’ont chacune coûté une journée avant que ça fonctionne correctement.
Pourquoi LazyColumn seul n’est pas le problème
La version naïve fonctionne comme dans chaque liste de chaque tutoriel : une requête Room, SELECT * FROM transactions ORDER BY date DESC, mappée vers un Flow<List<Transaction>>, collectée dans une LazyColumn. Le layout lazy de Compose est réellement paresseux en matière de composition et de rendu — il ne recompose pas les lignes hors écran. Le saccadement n’est donc pas un problème Compose. Il est en amont : Room doit matérialiser chacune de ces quatre mille lignes en objets, à chaque émission, chaque fois qu’une seule transaction change quelque part dans la table. Ajoutez une dépense et toute la liste se reconstruit. C’est le vrai coût, et aucune optimisation de rendu de liste n’y change quoi que ce soit.
Ce que Paging 3 apporte réellement ici
Paging 3 est généralement expliqué à travers son cas d’usage réseau — un RemoteMediator qui récupère des pages depuis une API et les met en cache localement. Ce n’est pas le sujet ici. Pour une app local-first, tout le pipeline vit dans Room : pas de médiateur, pas d’état réseau, pas de logique de nouvelle tentative. Room génère un PagingSource pour vous à partir d’une @Query qui renvoie PagingSource<Int, Transaction> au lieu de Flow<List<Transaction>> :
@Dao
interface TransactionDao {
@Query("SELECT * FROM transactions ORDER BY date DESC")
fun pagingSource(): PagingSource<Int, Transaction>
}
Ce simple changement de type constitue l’essentiel de l’intégration. Room sait déjà invalider un PagingSource quand la table sous-jacente change — le même mécanisme d’observation qui alimente les requêtes Flow — donc les pages se rafraîchissent automatiquement à chaque écriture sans que vous ayez à câbler quoi que ce soit de plus.
Construire le flux
Le PagingSource est enveloppé dans un Pager, qui possède la configuration de pagination — combien de lignes par page, jusqu’où précharger à l’avance :
class TransactionRepository(private val dao: TransactionDao) {
fun pagedTransactions(): Flow<PagingData<Transaction>> =
Pager(
config = PagingConfig(
pageSize = 40,
prefetchDistance = 20,
enablePlaceholders = true,
),
pagingSourceFactory = { dao.pagingSource() },
).flow
}
enablePlaceholders = true mérite d’être souligné : comme Room peut exécuter un SELECT COUNT(*) à moindre coût sur une table locale, Paging 3 peut dimensionner une barre de défilement avec précision et afficher des lignes d’espace réservé pour le contenu qui n’est pas encore chargé — un défilement rapide jusqu’au bas d’un historique de quatre mille lignes n’a pas besoin de charger ces quatre mille lignes au préalable. C’est un luxe que la pagination adossée au réseau n’a pas, et c’est la principale raison pour laquelle la pagination locale paraît instantanée d’une manière que les flux à défilement infini n’atteignent généralement pas.
Côté Compose
collectAsLazyPagingItems() transforme le Flow<PagingData<Transaction>> en quelque chose qu’une LazyColumn peut indexer directement :
@Composable
fun TransactionList(viewModel: TransactionViewModel) {
val transactions = viewModel.pagedTransactions.collectAsLazyPagingItems()
LazyColumn {
items(
count = transactions.itemCount,
key = transactions.itemKey { it.id },
) { index ->
val transaction = transactions[index]
if (transaction != null) {
TransactionRow(transaction)
} else {
TransactionRowPlaceholder()
}
}
}
}
itemKey compte davantage ici que dans une liste normale : sans clé stable, Compose identifie les lignes par leur position dans la liste, et insérer une nouvelle transaction en haut — ce qui arrive constamment dans une app de budget, puisque les nouvelles entrées sont triées en tête par date — décale l’identité de chaque ligne et déclenche une vague de recompositions inutiles.
L’erreur qui m’a coûté une journée : le filtrage
L’écran d’historique prend aussi en charge le filtrage par catégorie. Mon premier réflexe a été de garder un seul Pager actif et de filtrer le flux de PagingData en aval avec .filter { } sur chaque émission de PagingData. C’est une erreur, et c’est un piège suffisamment courant pour qu’il mérite d’être nommé directement : filtrer après coup passe quand même par la requête non filtrée, donc une catégorie avec cinq résultats sur quatre mille lignes peut parcourir la quasi-totalité de la table avant d’afficher cinq résultats, et le nombre d’espaces réservés est faux puisqu’il reflète le nombre de lignes de la table complète, pas celui de la table filtrée.
La solution consiste à faire du filtre une partie de la requête, et non une étape de post-traitement, et à reconstruire le Pager quand le filtre change :
@Query("""
SELECT * FROM transactions
WHERE (:categoryId IS NULL OR category_id = :categoryId)
ORDER BY date DESC
""")
fun pagingSource(categoryId: Long?): PagingSource<Int, Transaction>
val pagedTransactions: Flow<PagingData<Transaction>> = filterState
.flatMapLatest { categoryId -> repository.pagedTransactions(categoryId) }
.cachedIn(viewModelScope)
flatMapLatest annule l’ancien Pager et en démarre un nouveau adapté au nouveau filtre, de sorte que les espaces réservés, les comptages et le préchargement sont tous corrects pour la requête que l’utilisateur regarde réellement. cachedIn(viewModelScope) est ce qui survit aux changements de configuration — sans ça, faire pivoter l’écran relance la pagination depuis le début.
À retenir
Paging 3 sur Room n’a besoin d’aucune de la mécanique réseau avec laquelle on l’enseigne habituellement — pas de RemoteMediator, pas d’interface de nouvelle tentative pour LoadState, pas de cache hors ligne à réconcilier. Ce qui reste est réellement simple : un PagingSource issu d’une requête, un Pager avec sa configuration de préchargement, et collectAsLazyPagingItems() côté Compose. La seule chose à bien faire dès le départ, c’est de garder les filtres à l’intérieur de la requête plutôt qu’en aval de celle-ci — tout le reste en découle. Une liste de transactions qui reste réactive à quatre mille lignes n’est pas une fonctionnalité de performance qu’on ajoute plus tard ; c’est ce qui fait la différence entre une app qui fonctionne dans la démo et une app qui fonctionne encore après deux ans d’usage réel.
// À lire aussi
D’autres notes du journal
Les TypeConverters Room en 2026 : stocker enums, dates et listes sans corrompre votre schéma
Un guide pratique des TypeConverters Room sur Android — enums, Instant/LocalDate, et listes — ainsi que les erreurs qui transforment un converter en bug silencieux de corruption de données.
R8 et ProGuard pour Kotlin, Room et Compose : le crash qui n'arrive qu'en release
Pourquoi une app Android Room + Compose peut fonctionner parfaitement en debug et planter en production, et les règles keep R8/ProGuard précises qui l'évitent.
Les index de base de données Room en 2026 : trouver la requête réellement lente et la corriger
Un guide pratique pour indexer une base Room/SQLite sur Android — lire EXPLAIN QUERY PLAN, ajouter @Index sans deviner, et les erreurs qui neutralisent un index en silence.