Ce guide explique comment implémenter Paging 3 avec Jetpack Compose, en couvrant les implémentations avec et sans base de données Room. La pagination est une stratégie de gestion des grands ensembles de données. Elle consiste à les charger et à les afficher par petits blocs gérables, appelés pages, plutôt que de tout charger en même temps.
Toute application comportant un flux à défilement infini (comme un fil d'actualité sur les réseaux sociaux, un grand catalogue de produits d'e-commerce ou une boîte de réception d'e-mails volumineuse) nécessite une pagination robuste des données. Étant donné que les utilisateurs ne consultent généralement qu'une petite partie d'une liste et que les appareils mobiles ont une taille d'écran limitée, le chargement de l'ensemble de l'ensemble de données n'est pas efficace. Cela gaspille les ressources système et peut entraîner des saccades ou des blocages d'application, ce qui nuit à l'expérience utilisateur. Pour résoudre ce problème, vous pouvez utiliser le chargement différé. Alors que des composants tels que LazyList dans Compose gèrent le chargement différé côté UI, le chargement différé des données à partir du disque ou du réseau améliore encore les performances.
La bibliothèque Paging 3 est la solution recommandée pour gérer la pagination des données. Si vous migrez depuis Paging 2, consultez Migrer vers Paging 3 pour obtenir de l'aide.
Prérequis
Avant de continuer, familiarisez-vous avec les points suivants :
- Mise en réseau sur Android (nous utilisons Retrofit dans ce document, mais Paging 3 fonctionne avec n'importe quelle bibliothèque, comme Ktor).
- Kit d'interface utilisateur Compose.
Configurer des dépendances
Ajoutez les dépendances suivantes au fichier build.gradle.kts au niveau de l'application.
dependencies {
val paging_version = "3.4.0"
// Paging Compose
implementation("androidx.paging:paging-compose:$paging_version")
// Networking dependencies used in this guide
val retrofit = "3.0.0"
val kotlinxSerializationJson = "1.9.0"
val retrofitKotlinxSerializationConverter = "1.0.0"
val okhttp = "4.12.0"
implementation("com.squareup.retrofit2:retrofit:$retrofit")
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:$kotlinxSerializationJson")
implementation("com.jakewharton.retrofit:retrofit2-kotlinx-serialization-converter:$retrofitKotlinxSerializationConverter")
implementation(platform("com.squareup.okhttp3:okhttp-bom:$okhttp"))
implementation("com.squareup.okhttp3:okhttp")
implementation("com.squareup.okhttp3:logging-interceptor")
}
Définir la classe Pager
La classe Pager est le principal point d'entrée pour la pagination. Il construit un flux réactif de PagingData. Vous devez instancier Pager et le réutiliser dans votre ViewModel.
Pager nécessite un PagingConfig pour déterminer comment extraire et présenter les données.
// Configs for pagination
val PAGING_CONFIG = PagingConfig(
pageSize = 50, // Items requested from data source
enablePlaceholders = false,
initialLoadSize = 50,
prefetchDistance = 10 // Items from the end that trigger the next fetch
)
Vous pouvez implémenter Pager de deux manières : sans base de données (réseau uniquement) ou avec une base de données (à l'aide de Room).
Implémenter sans base de données
Lorsque vous n'utilisez pas de base de données, vous avez besoin d'un PagingSource<Key, Value> pour gérer le chargement des données à la demande. Dans cet exemple, la clé est Int et la valeur est Product.
Vous devez implémenter deux méthodes abstraites dans votre PagingSource :
load: fonction de suspension qui reçoitLoadParams. Utilisez-le pour récupérer les données des requêtesRefresh,AppendouPrepend.getRefreshKey: fournit la clé utilisée pour recharger les données si le pager est invalidé. Cette méthode calcule la clé en fonction de la position de défilement actuelle de l'utilisateur (state.anchorPosition).
L'exemple de code suivant montre comment implémenter la classe ProductPagingSource, qui est nécessaire pour définir la logique de récupération des données lorsque vous utilisez Paging 3 sans base de données locale.
class ProductPagingSource : PagingSource<Int, Product>() {
override fun getRefreshKey(state: PagingState<Int, Product>): Int {
// This is called when the Pager needs to load new data after invalidation
// (for example, when the user scrolls quickly or the data stream is
// manually refreshed).
// It tries to calculate the page key (offset) that is closest to the
// item the user was last viewing (`state.anchorPosition`).
return ((state.anchorPosition ?: 0) - state.config.initialLoadSize / 2).coerceAtLeast(0)
}
override suspend fun load(params: LoadParams<Int>): LoadResult<Int, Product> {
return when (params) {
// Case 1: The very first load or a manual refresh. Start from
// offset 0.
is LoadParams.Refresh<Int> -> {
fetchProducts(0, params.loadSize)
}
// Case 2: User scrolled to the end of the list. Load the next
// 'page' using the stored key.
is LoadParams.Append<Int> -> {
fetchProducts(params.key, params.loadSize)
}
// Case 3: Loading backward. Not supported in this
// implementation.
is LoadParams.Prepend<Int> -> LoadResult.Invalid()
}
}
// Helper function to interact with the API service and map the response
// into a [LoadResult.Page] or [LoadResult.Error].
private suspend fun fetchProducts(key: Int, limit: Int): LoadResult<Int, Product> {
return try {
val response = productService.fetchProducts(limit, key)
LoadResult.Page(
data = response.products,
prevKey = null,
nextKey = (key + response.products.size).takeIf { nextKey ->
nextKey < response.total
}
)
} catch (e: Exception) {
// Captures network failures or JSON parsing errors to display
// in the UI.
LoadResult.Error(e)
}
}
}
Dans votre classe ViewModel, créez Pager :
val productPager = Pager(
// Configuration: Defines page size, prefetch distance, and placeholders.
config = PAGING_CONFIG,
// Initial State: Start loading data from the very first index (offset 0).
initialKey = 0,
// Factory: Creates a new instance of the PagingSource whenever the
// data is invalidated (for example, calling pagingSource.invalidate()).
pagingSourceFactory = { ProductPagingSource() }
).flow.cachedIn(viewModelScope)
Implémenter avec une base de données
Lorsque vous utilisez Room, la base de données génère automatiquement la classe PagingSource.
Toutefois, la base de données ne sait pas quand récupérer d'autres données à partir du réseau. Pour gérer cela, implémentez un RemoteMediator.
La méthode RemoteMediator.load() fournit le loadType (Append, Prepend ou Refresh) et l'état. Elle renvoie un MediatorResult indiquant la réussite ou l'échec, et si la fin de la pagination a été atteinte.
@OptIn(ExperimentalPagingApi::class)
@OptIn(ExperimentalPagingApi::class)
class ProductRemoteMediator : RemoteMediator<Int, Product>() {
override suspend fun load(
loadType: LoadType,
state: PagingState<Int, Product>
): MediatorResult {
return try {
// Get the count of loaded items to calculate the skip value
val skip = when (loadType) {
LoadType.REFRESH -> 0
LoadType.PREPEND -> return MediatorResult.Success(endOfPaginationReached = true)
LoadType.APPEND -> {
InMemoryDatabaseProvider.INSTANCE.productDao().getCount()
}
}
val response = productService.fetchProducts(
state.config.pageSize,
skip
)
InMemoryDatabaseProvider.INSTANCE.productDao().apply {
insertAll(response.products)
}
MediatorResult.Success(
endOfPaginationReached = response.skip + response.limit >= response.total
)
} catch (e: Exception) {
MediatorResult.Error(e)
}
}
}
Dans votre ViewModel, l'implémentation est considérablement simplifiée, car Room gère la classe PagingSource :
val productPager = ProductRepository().fetchProducts().flow.cachedIn(viewModelScope)
Configuration du réseau
Les exemples précédents s'appuient sur un service réseau. Cette section fournit la configuration Retrofit et de sérialisation utilisée pour extraire les données du point de terminaison api.example.com/products.
Classes de données
L'exemple de code suivant montre comment définir les deux classes de données, ProductResponse et Product, qui sont utilisées avec kotlinx.serialization pour analyser la réponse JSON paginée du service réseau.
@Serializable
data class ProductResponse(
val products: List<Product>,
val total: Int,
val skip: Int,
val limit: Int
)
@Serializable
data class Product(
val id: Int,
var title: String = "",
// ... other fields (description, price, etc.)
val thumbnail: String = ""
)
Service de modernisation
L'exemple de code suivant montre comment définir l'interface de service Retrofit (ProductService) pour l'implémentation réseau uniquement, en spécifiant le point de terminaison (@GET("/products")) et les paramètres de pagination nécessaires (limit) et (skip) requis par la bibliothèque Paging 3 pour extraire les pages de données.
interface ProductService {
@GET("/products")
suspend fun fetchProducts(
@Query("limit") limit: Int,
@Query("skip") skip: Int
): ProductResponse
}
// Setup logic (abbreviated)
val jsonConverter = Json { ignoreUnknownKeys = true }
val retrofit = Retrofit.Builder()
.baseUrl("https://api.example.com")
.addConverterFactory(jsonConverter.asConverterFactory("application/json".toMediaType()))
// ... client setup
.build()
Utiliser des données dans Compose
Une fois votre Pager configuré, vous pouvez afficher les données dans votre UI.
Collectez le flux : utilisez
collectAsLazyPagingItems()pour convertir le flux en objet d'éléments de pagination différée avec état.val productPagingData = mainViewModel.productPager.collectAsLazyPagingItems()L'objet
LazyPagingItemsrésultant fournit des nombres d'éléments et un accès indexé, ce qui lui permet d'être directement consommé par la méthodeLazyColumnpour afficher les éléments de la liste.Associer à
LazyColumn: transmettez les données à une listeLazyColumn. Si vous migrez à partir de la listeRecyclerView, vous connaissez peut-être l'utilisation dewithLoadStateHeaderAndFooterpour afficher des indicateurs de chargement ou des boutons de nouvelle tentative d'erreur en haut ou en bas de votre liste.Dans Compose, vous n'avez pas besoin d'adaptateur spécial pour cela. Vous pouvez obtenir exactement le même comportement en ajoutant conditionnellement un bloc
item {}avant ou après votre blocitems {}principal, en réagissant directement aux états de chargementprepend(en-tête) etappend(pied de page).LazyColumn { // --- HEADER (Equivalent to loadStateHeader) --- // Reacts to 'prepend' states when scrolling towards the top if (productPagingData.loadState.prepend is LoadState.Loading) { item { Box(modifier = Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) { CircularProgressIndicator(modifier = Modifier.padding(16.dp)) } } } if (productPagingData.loadState.prepend is LoadState.Error) { item { ErrorHeader(onRetry = { productPagingData.retry() }) } } // --- MAIN LIST ITEMS --- items(count = productPagingData.itemCount) { index -> val product = productPagingData[index] if (product != null) { UserPagingListItem(product = product) } } // --- FOOTER (Equivalent to loadStateFooter) --- // Reacts to 'append' states when scrolling towards the bottom if (productPagingData.loadState.append is LoadState.Loading) { item { Box(modifier = Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) { CircularProgressIndicator(modifier = Modifier.padding(16.dp)) } } } if (productPagingData.loadState.append is LoadState.Error) { item { ErrorFooter(onRetry = { productPagingData.retry() }) } } }
Pour en savoir plus sur la façon dont les fonctionnalités de Compose vous permettent d'afficher efficacement des collections d'éléments, consultez Listes et grilles.
Gérer les états de chargement
L'objet PagingData intègre des informations sur l'état de chargement. Vous pouvez l'utiliser pour afficher des indicateurs de chargement ou des messages d'erreur pour différents états (refresh, append ou prepend).
Pour éviter les recompositions inutiles et vous assurer que l'UI ne réagit qu'aux transitions significatives du cycle de vie du chargement, vous devez filtrer vos observations d'état. Étant donné que loadState est fréquemment mis à jour avec des modifications internes, le lire directement pour des changements d'état complexes peut entraîner des saccades.
Vous pouvez optimiser cela en utilisant snapshotFlow pour observer l'état et en appliquant des opérateurs Flow tels que la propriété distinctUntilChangedBy. Cela est particulièrement utile pour afficher des états vides ou déclencher des effets secondaires, comme une snackbar d'erreur :
val snackbarHostState = remember { SnackbarHostState() }
LaunchedEffect(productPagingData.loadState) {
snapshotFlow { productPagingData.loadState }
// Filter out updates that don't change the refresh state
.distinctUntilChangedBy { it.refresh }
// Only react when the state is an Error
.filter { it.refresh is LoadState.Error }
.collect { loadState ->
val error = (loadState.refresh as LoadState.Error).error
snackbarHostState.showSnackbar(
message = "Data failed to load: ${error.localizedMessage}",
actionLabel = "Retry"
)
}
}
Lorsque vous vérifiez l'état d'actualisation pour afficher un indicateur de chargement en plein écran, utilisez derivedStateOf pour éviter les recompositions inutiles.
De plus, si vous utilisez un RemoteMediator (comme dans l'implémentation de la base de données Room ci-dessus), inspectez explicitement l'état de chargement de la source de données sous-jacente (loadState.source.refresh) plutôt que la propriété pratique loadState.refresh. La propriété de commodité peut indiquer que la récupération du réseau est terminée avant que la base de données n'ait fini d'ajouter les nouveaux éléments à l'UI. La vérification de source garantit que l'UI est entièrement synchronisée avec la base de données locale, ce qui empêche le programme de chargement de disparaître trop tôt.
// Safely check the refresh state for a full-screen spinner
// without triggering unnecessary recompositions
val isRefreshing by remember {
derivedStateOf { productPagingData.loadState.source.refresh is LoadState.Loading }
}
if (isRefreshing) {
// Show UI for refreshing (for example, full screen spinner)
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
CircularProgressIndicator()
}
}
Vous pouvez également rechercher LoadState.Error pour afficher des boutons de nouvelle tentative ou des messages d'erreur à l'utilisateur. Nous vous recommandons d'utiliser LoadState.Error, car il expose l'exception sous-jacente et active la fonction retry() intégrée pour la récupération par l'utilisateur.
if (refreshState is LoadState.Error) {
val e = refreshState as LoadState.Error
// This composable should ideally replace the entire list if the initial load
// fails.
ErrorScreen(
message = "Data failed to load: ${e.error.localizedMessage}",
onClickRetry = { productPagingData.retry() }
)
}
Tester l'implémentation
Tester l'implémentation de la pagination permet de s'assurer que les données se chargent correctement, que les transformations sont appliquées comme prévu et que l'UI réagit correctement aux changements d'état. La bibliothèque Paging 3 fournit un artefact de test dédié (androidx.paging:paging-testing) pour simplifier ce processus.
Commencez par ajouter la dépendance de test à votre fichier build.gradle :
testImplementation("androidx.paging:paging-testing:$paging_version")
Tester la couche de données
Pour tester directement votre PagingSource, utilisez TestPager. Cet utilitaire gère les mécanismes sous-jacents de Paging 3 et vous permet de vérifier indépendamment les cas extrêmes, tels que les chargements initiaux (actualisation), l'ajout ou la pré-insertion de données sans avoir besoin d'une configuration Pager complète.
@Test
fun testProductPagingSource() = runTest {
val pagingSource = ProductPagingSource(mockApiService)
// Create a TestPager to interact with the PagingSource
val pager = TestPager(
config = PAGING_CONFIG,
pagingSource = pagingSource
)
// Trigger an initial load
val result = pager.refresh() as PagingSource.LoadResult.Page
// Assert the data size and edge cases like next/prev keys
assertEquals(50, result.data.size)
assertNull(result.prevKey)
assertEquals(50, result.nextKey)
}
Tester la logique et les transformations ViewModel
Si votre ViewModel applique des transformations de données (telles que des opérations .map) au flux PagingData, vous pouvez tester cette logique à l'aide de asPagingSourceFactory et asSnapshot().
L'extension asPagingSourceFactory convertit une liste statique en PagingSource, ce qui simplifie la simulation de la couche de dépôt. L'extension asSnapshot() collecte le flux PagingData dans un List Kotlin standard, ce qui vous permet d'exécuter des assertions standards sur les données transformées.
@Test
fun testViewModelTransformations() = runTest {
// 1. Mock your initial data using asPagingSourceFactory
val mockProducts = listOf(Product(1, "A"), Product(2, "B"))
val pagingSourceFactory = mockProducts.asPagingSourceFactory()
// 2. Pass the mocked factory to your ViewModel or Pager
val pager = Pager(
config = PagingConfig(pageSize = 10),
pagingSourceFactory = pagingSourceFactory
)
// 3. Apply your ViewModel transformations (for example, mapping to a UI
// model)
val transformedFlow = pager.flow.map { pagingData ->
pagingData.map { product -> product.title.uppercase() }
}
// 4. Extract the data as a List using asSnapshot()
val snapshot: List<String> = transformedFlow.asSnapshot(this)
// 5. Verify the transformation
assertEquals(listOf("A", "B"), snapshot)
}
Tests d'UI pour vérifier les états et les recompositions
Lorsque vous testez l'UI, vérifiez que vos composants Compose affichent les données correctement et réagissent aux états de chargement de manière appropriée. Vous pouvez transmettre des PagingData statiques à l'aide de PagingData.from() et flowOf() pour simuler des flux de données.
De plus, vous pouvez utiliser un SideEffect pour suivre le nombre de recompositions pendant vos tests et vous assurer que vos composants Compose ne se recomposent pas inutilement.
L'exemple suivant montre comment simuler un état de chargement, passer à un état chargé et vérifier à la fois les nœuds d'UI et le nombre de recompositions :
@get:Rule
val composeTestRule = createComposeRule()
@Test
fun testProductList_loadingAndDataStates() {
val context = InstrumentationRegistry.getInstrumentation().targetContext
// Create a MutableStateFlow to emit different PagingData states over time
val pagingDataFlow = MutableStateFlow(PagingData.empty<Product>())
var recompositionCount = 0
composeTestRule.setContent {
val lazyPagingItems = pagingDataFlow.collectAsLazyPagingItems()
// Track recompositions of this composable
SideEffect { recompositionCount++ }
ProductListScreen(lazyPagingItems = lazyPagingItems)
}
// 1. Simulate initial loading state
pagingDataFlow.value = PagingData.empty(
sourceLoadStates = LoadStates(
refresh = LoadState.Loading,
prepend = LoadState.NotLoading(endOfPaginationReached = false),
append = LoadState.NotLoading(endOfPaginationReached = false)
)
)
// Verify that the loading indicator is displayed
composeTestRule.onNodeWithTag("LoadingSpinner").assertIsDisplayed()
// 2. Simulate data loaded state
val mockItems = listOf(
Product(id = 1, title = context.getString(R.string.product_a_title)),
Product(id = 2, title = context.getString(R.string.product_b_title))
)
pagingDataFlow.value = PagingData.from(
data = mockItems,
sourceLoadStates = LoadStates(
refresh = LoadState.NotLoading(endOfPaginationReached = false),
prepend = LoadState.NotLoading(endOfPaginationReached = false),
append = LoadState.NotLoading(endOfPaginationReached = false)
)
)
// Wait for the UI to settle and verify the items are displayed
composeTestRule.waitForIdle()
composeTestRule.onNodeWithText(context.getString(R.string.product_a_title)).assertIsDisplayed()
composeTestRule.onNodeWithText(context.getString(R.string.product_b_title)).assertIsDisplayed()
// 3. Verify recomposition counts
// Assert that recompositions are within expected limits (for example,
// initial composition + updating to load state + updating to data state)
assert(recompositionCount <= 3) {
"Expected less than or equal to 3 recompositions, but got $recompositionCount"
}
}