Questa guida spiega come implementare Paging 3 con Jetpack Compose, illustrando le implementazioni con e senza un database Room. La paginazione è una strategia per gestire grandi set di dati caricandoli e visualizzandoli in blocchi piccoli e gestibili, chiamati pagine, anziché caricare tutto contemporaneamente.
Qualsiasi app che includa un feed a scorrimento continuo (come una cronologia dei social media, un ampio catalogo di prodotti di e-commerce o una casella di posta estesa) richiede una paginazione dei dati efficace. Poiché gli utenti in genere visualizzano solo una piccola
parte di un elenco e i dispositivi mobili hanno dimensioni dello schermo limitate, il caricamento
dell'intero set di dati non è efficiente. Spreca le risorse di sistema e può causare jank o
blocchi delle app, peggiorando l'esperienza utente. Per risolvere il problema, puoi utilizzare il caricamento
differito. Mentre componenti come LazyList in Compose gestiscono il caricamento differito
sul lato UI, il caricamento differito dei dati dal disco o dalla rete migliora ulteriormente
le prestazioni.
La libreria Paging 3 è la soluzione consigliata per la gestione della paginazione dei dati. Se esegui la migrazione da Paging 2, consulta la pagina Eseguire la migrazione a Paging 3 per indicazioni.
Prerequisiti
Prima di procedere, acquisisci familiarità con quanto segue:
- Networking su Android (in questo documento utilizziamo Retrofit, ma Paging 3 funziona con qualsiasi libreria, ad esempio Ktor).
- Il toolkit UI Compose.
Configurare le dipendenze
Aggiungi le seguenti dipendenze al file build.gradle.kts a livello di app.
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")
}
Definisci la classe Pager
La classe Pager è il punto di accesso principale per la paginazione. Costruisce un
flusso reattivo di PagingData. Devi creare un'istanza di Pager e riutilizzarla
all'interno di ViewModel.
Pager richiede un PagingConfig per determinare come recuperare e presentare
i dati.
// 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
)
Puoi implementare Pager in due modi: senza un database (solo rete) o con un database (utilizzando Room).
Implementare senza un database
Quando non utilizzi un database, hai bisogno di un PagingSource<Key, Value> per gestire
il caricamento dei dati on demand. In questo esempio, la chiave è Int e il valore è un
Product.
Devi implementare due metodi astratti in PagingSource:
load: una funzione di sospensione che riceveLoadParams. Utilizzalo per recuperare i dati per le richiesteRefresh,AppendoPrepend.getRefreshKey: fornisce la chiave utilizzata per ricaricare i dati se il pager viene invalidato. Questo metodo calcola la chiave in base alla posizione di scorrimento corrente dell'utente (state.anchorPosition).
Il seguente esempio di codice mostra come implementare la classe ProductPagingSource, necessaria per definire la logica di recupero dei dati quando si utilizza Paging 3 senza un database 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)
}
}
}
Nel tuo corso ViewModel, crea l'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)
Implementare con un database
Quando utilizzi Room, il database genera automaticamente la classe PagingSource.
Tuttavia, il database non sa quando recuperare altri dati dalla rete. Per
gestire questo problema, implementa un RemoteMediator.
Il metodo RemoteMediator.load() fornisce loadType (Append, Prepend o Refresh) e lo stato. Restituisce un MediatorResult che indica l'esito positivo o negativo dell'operazione e se è stata raggiunta la fine della paginazione.
@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)
}
}
}
Nel tuo ViewModel, l'implementazione è notevolmente semplificata perché Room
gestisce la classe PagingSource:
val productPager = ProductRepository().fetchProducts().flow.cachedIn(viewModelScope)
Configurazione rete
Gli esempi precedenti si basano su un servizio di rete. Questa sezione fornisce la configurazione di
Retrofit e serializzazione utilizzata per recuperare i dati dall'endpoint
api.example.com/products.
Classi di dati
Il seguente esempio di codice mostra come definire le due classi di dati, ProductResponse e Product, utilizzate con kotlinx.serialization per analizzare la risposta JSON paginata dal servizio di rete.
@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 = ""
)
Servizio di retrofit
Il seguente esempio di codice mostra come definire l'interfaccia del servizio Retrofit
(ProductService) per l'implementazione solo di rete, specificando
l'endpoint (@GET("/products")) e i parametri di paginazione necessari (limit)
e (skip) richiesti dalla libreria Paging 3 per recuperare le pagine di dati.
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()
Utilizzare i dati in Compose
Dopo aver configurato Pager, puoi visualizzare i dati nella tua UI.
Raccogli il flusso: utilizza
collectAsLazyPagingItems()per convertire il flusso in un oggetto di elementi di paginazione pigra sensibile allo stato.val productPagingData = mainViewModel.productPager.collectAsLazyPagingItems()L'oggetto
LazyPagingItemsrisultante fornisce i conteggi degli elementi e l'accesso indicizzato, consentendo di utilizzarlo direttamente con il metodoLazyColumnper il rendering degli elementi dell'elenco.Bind to
LazyColumn: passa i dati a un elencoLazyColumn. Se esegui la migrazione da un elencoRecyclerView, potresti avere familiarità con l'utilizzo diwithLoadStateHeaderAndFooterper visualizzare indicatori di caricamento o pulsanti di ripetizione degli errori nella parte superiore o inferiore dell'elenco.In Compose non è necessario un adattatore speciale. Puoi ottenere lo stesso comportamento aggiungendo in modo condizionale un blocco
item {}prima o dopo il bloccoitems {}principale, reagendo direttamente agli stati di caricamento diprepend(intestazione) eappend(piè di pagina).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() }) } } }
Per saperne di più su come le funzionalità di Compose ti consentono di visualizzare in modo efficace raccolte di elementi, consulta Elenchi e griglie.
Gestire gli stati di caricamento
L'oggetto PagingData integra le informazioni sullo stato di caricamento. Puoi utilizzare questo
per mostrare indicatori di caricamento o messaggi di errore per diversi stati (refresh,
append o prepend).
Per evitare ricomposizioni non necessarie e garantire che la UI reagisca solo a
transizioni significative nel ciclo di vita del caricamento, devi filtrare le osservazioni
dello stato. Poiché loadState viene aggiornato frequentemente con modifiche interne,
leggerlo direttamente per modifiche complesse dello stato può causare interruzioni.
Puoi ottimizzare questo aspetto utilizzando snapshotFlow per osservare lo stato e applicando
operatori di flusso come la proprietà distinctUntilChangedBy. Ciò è particolarmente
utile quando vengono visualizzati stati vuoti o vengono attivati effetti collaterali, ad esempio una snackbar
di errore:
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"
)
}
}
Quando controlli lo stato di aggiornamento per mostrare un indicatore di caricamento a schermo intero, utilizza
derivedStateOf per evitare ricomposizioni non necessarie.
Inoltre, se utilizzi un RemoteMediator (come nell'implementazione del database Room precedente), ispeziona esplicitamente lo stato di caricamento dell'origine dati sottostante (loadState.source.refresh) anziché la proprietà loadState.refresh di convenienza. La proprietà di convenienza potrebbe segnalare che il
recupero di rete è completato prima che il database abbia terminato di aggiungere i nuovi elementi
alla UI. Il controllo di source garantisce che la UI sia completamente sincronizzata con
il database locale, impedendo al caricatore di scomparire troppo presto.
// 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()
}
}
Puoi anche controllare LoadState.Error per visualizzare i pulsanti di riprova o i messaggi di errore
all'utente. Ti consigliamo di utilizzare LoadState.Error perché espone
l'eccezione sottostante e attiva la funzione retry() integrata per il recupero
dell'utente.
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() }
)
}
Testare l'implementazione
Il test dell'implementazione della paginazione garantisce che i dati vengano caricati correttamente,
che le trasformazioni vengano applicate come previsto e che la UI reagisca correttamente alle modifiche dello stato. La libreria Paging 3 fornisce un artefatto di test dedicato
(androidx.paging:paging-testing) per semplificare questa procedura.
Innanzitutto, aggiungi la dipendenza di test al file build.gradle:
testImplementation("androidx.paging:paging-testing:$paging_version")
Testare il livello dati
Per testare direttamente il tuo PagingSource, utilizza TestPager. Questa utilità
gestisce i meccanismi sottostanti di Paging 3 e ti consente di verificare in modo indipendente
i casi limite, ad esempio i caricamenti iniziali (aggiornamento), l'aggiunta o l'anteposizione di dati
senza richiedere una configurazione Pager completa.
@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)
}
Testa la logica e le trasformazioni di ViewModel
Se il tuo ViewModel applica trasformazioni dei dati (ad esempio operazioni .map) al flusso PagingData, puoi testare questa logica utilizzando asPagingSourceFactory e asSnapshot().
L'estensione asPagingSourceFactory converte un elenco statico in un
PagingSource, semplificando la simulazione del livello del repository. L'estensione
asSnapshot() raccoglie lo stream PagingData in un List Kotlin standard, consentendoti di eseguire asserzioni standard sui dati trasformati.
@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)
}
Test dell'interfaccia utente per verificare gli stati e le ricomposizioni
Quando testi la UI, verifica che i componenti Compose eseguano il rendering dei dati
correttamente e reagiscano in modo appropriato agli stati di caricamento. Puoi trasmettere PagingData statici utilizzando PagingData.from() e flowOf() per simulare gli stream di dati.
Inoltre, puoi utilizzare un SideEffect per monitorare i conteggi di ricomposizione durante
i test per assicurarti che i componenti Compose non vengano ricomposti inutilmente.
L'esempio seguente mostra come simulare uno stato di caricamento, passare a uno stato di caricamento e verificare sia i nodi UI sia il conteggio delle ricomposizioni:
@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"
}
}