Panoramica di Paging 3

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 riceve LoadParams. Utilizzalo per recuperare i dati per le richieste Refresh, Append o Prepend.

  • 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.

  1. 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 LazyPagingItems risultante fornisce i conteggi degli elementi e l'accesso indicizzato, consentendo di utilizzarlo direttamente con il metodo LazyColumn per il rendering degli elementi dell'elenco.

  2. Bind to LazyColumn: passa i dati a un elenco LazyColumn. Se esegui la migrazione da un elenco RecyclerView, potresti avere familiarità con l'utilizzo di withLoadStateHeaderAndFooter per 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 blocco items {} principale, reagendo direttamente agli stati di caricamento di prepend (intestazione) e append (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"
    }
}