Descripción general de Paging 3

En esta guía, se explica cómo implementar Paging 3 con Jetpack Compose, y se abarcan las implementaciones con y sin una base de datos de Room. La paginación es una estrategia para administrar grandes conjuntos de datos cargándolos y mostrándolos en fragmentos pequeños y fáciles de administrar, llamados páginas, en lugar de cargar todo a la vez.

Cualquier app que incluya un feed de desplazamiento infinito (como una cronología de redes sociales, un catálogo grande de productos de comercio electrónico o una bandeja de entrada de correo electrónico extensa) requiere una paginación de datos sólida. Dado que los usuarios suelen ver solo una pequeña parte de una lista y los dispositivos móviles tienen tamaños de pantalla limitados, no es eficiente cargar el conjunto de datos completo. Desperdicia recursos del sistema y puede provocar tirones o bloqueos de la app, lo que empeora la experiencia del usuario. Para resolver este problema, puedes usar la carga diferida. Si bien los componentes como LazyList en Compose controlan la carga diferida en el lado de la IU, cargar datos de forma diferida desde el disco o la red mejora aún más el rendimiento.

La biblioteca de Paging 3 es la solución recomendada para controlar la paginación de datos. Si migras desde Paging 2, consulta Migra a Paging 3 para obtener orientación.

Requisitos previos

Antes de continuar, familiarízate con lo siguiente:

  • Redes en Android (usamos Retrofit en este documento, pero Paging 3 funciona con cualquier biblioteca, como Ktor).
  • El kit de herramientas de la IU de Compose

Configura dependencias

Agrega las siguientes dependencias al archivo build.gradle.kts a nivel de la 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")
}

Define la clase Pager

La clase Pager es el punto de entrada principal para la paginación. Construye una transmisión reactiva de PagingData. Debes crear una instancia de Pager y reutilizarla en tu ViewModel.

El Pager requiere un PagingConfig para determinar cómo recuperar y presentar los datos.

// 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
)

Puedes implementar Pager de dos maneras: sin una base de datos (solo red) o con una base de datos (con Room).

Implementa sin una base de datos

Cuando no se usa una base de datos, se necesita un PagingSource<Key, Value> para controlar la carga de datos a pedido. En este ejemplo, la clave es Int y el valor es un Product.

Debes implementar dos métodos abstractos en tu PagingSource:

  • load: Es una función de suspensión que recibe LoadParams. Úsalo para recuperar datos para las solicitudes Refresh, Append o Prepend.

  • getRefreshKey: Proporciona la clave que se usa para volver a cargar los datos si se invalida el paginador. Este método calcula la clave según la posición de desplazamiento actual del usuario (state.anchorPosition).

En el siguiente ejemplo de código, se muestra cómo implementar la clase ProductPagingSource, que es necesaria para definir la lógica de recuperación de datos cuando se usa Paging 3 sin una base de datos local.

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)
        }
    }
}

En tu clase ViewModel, crea el 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)

Implementación con una base de datos

Cuando se usa Room, la base de datos genera la clase PagingSource automáticamente. Sin embargo, la base de datos no sabe cuándo recuperar más datos de la red. Para controlar esto, implementa un RemoteMediator.

El método RemoteMediator.load() proporciona el loadType (Append, Prepend o Refresh) y el estado. Devuelve un MediatorResult que indica si la operación se realizó correctamente o no, y si se llegó al final de la paginación.

@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)
        }
    }
}

En tu ViewModel, la implementación se simplifica significativamente porque Room controla la clase PagingSource:

val productPager = ProductRepository().fetchProducts().flow.cachedIn(viewModelScope)

Configuración de red

Los ejemplos anteriores se basan en un servicio de red. En esta sección, se proporciona la configuración de Retrofit y la serialización que se usan para recuperar datos del endpoint de api.example.com/products.

Clases de datos

En el siguiente ejemplo de código, se muestra cómo definir las dos clases de datos, ProductResponse y Product, que se usan con kotlinx.serialization para analizar la respuesta JSON paginada del servicio de red.

@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 = ""
)

Servicio de actualización

En el siguiente ejemplo de código, se muestra cómo definir la interfaz de servicio de Retrofit (ProductService) para la implementación solo de red, especificando el extremo (@GET("/products")) y los parámetros de paginación necesarios (limit) y (skip) que requiere la biblioteca de Paging 3 para recuperar páginas de datos.

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()

Consume datos en Compose

Después de configurar tu Pager, puedes mostrar los datos en tu IU.

  1. Recopila el flujo: Usa collectAsLazyPagingItems() para convertir el flujo en un objeto de elementos de paginación diferida que reconoce el estado.

    val productPagingData = mainViewModel.productPager.collectAsLazyPagingItems()
    

    El objeto LazyPagingItems resultante proporciona recuentos de elementos y acceso indexado, lo que permite que el método LazyColumn lo consuma directamente para renderizar los elementos de la lista.

  2. Vincular a LazyColumn: Pasa los datos a una lista de LazyColumn. Si migras desde la lista RecyclerView, es posible que ya sepas cómo usar withLoadStateHeaderAndFooter para mostrar indicadores de carga o botones de reintento de error en la parte superior o inferior de la lista.

    En Compose, no necesitas un adaptador especial para esto. Puedes lograr el mismo comportamiento si agregas de forma condicional un bloque item {} antes o después del bloque items {} principal, y reaccionas directamente a los estados de carga prepend (encabezado) y append (pie de página).

    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() })
            }
        }
    }
    

Para obtener más información sobre cómo las funciones de Compose te permiten mostrar de manera eficaz colecciones de elementos, consulta Listas y cuadrículas.

Cómo controlar los estados de carga

El objeto PagingData integra información sobre el estado de carga. Puedes usarlo para mostrar indicadores de carga o mensajes de error para diferentes estados (refresh, append o prepend).

Para evitar recomposiciones innecesarias y garantizar que la IU solo reaccione a transiciones significativas en el ciclo de vida de carga, debes filtrar tus observaciones de estado. Dado que loadState se actualiza con frecuencia con cambios internos, leerlo directamente para cambios de estado complejos puede causar interrupciones.

Puedes optimizar esto con snapshotFlow para observar el estado y aplicar operadores de Flow, como la propiedad distinctUntilChangedBy. Esto es particularmente útil cuando se muestran estados vacíos o se activan efectos secundarios, como un Snackbar de error:

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"
            )
        }
}

Cuando verifiques el estado de actualización para mostrar un spinner de carga de pantalla completa, usa derivedStateOf para evitar recomposiciones innecesarias.

Además, si usas un RemoteMediator (como en la implementación de la base de datos de Room anterior), inspecciona de forma explícita el estado de carga de la fuente de datos subyacente (loadState.source.refresh) en lugar de la propiedad de conveniencia loadState.refresh. La propiedad de conveniencia podría informar que la recuperación de la red se completó antes de que la base de datos terminara de agregar los elementos nuevos a la IU. Verificar el source garantiza que la IU esté completamente sincronizada con la base de datos local, lo que evita que el cargador desaparezca demasiado pronto.

// 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()
    }
}

También puedes verificar si hay LoadState.Error para mostrar botones de reintento o mensajes de error al usuario. Te recomendamos que uses LoadState.Error porque expone la excepción subyacente y habilita la función retry() integrada para la recuperación del usuario.

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() }
   )
}

Prueba tu implementación

Probar la implementación de la paginación garantiza que los datos se carguen correctamente, que las transformaciones se apliquen según lo previsto y que la IU reaccione de forma adecuada a los cambios de estado. La biblioteca de Paging 3 proporciona un artefacto de prueba dedicado (androidx.paging:paging-testing) para simplificar este proceso.

Primero, agrega la dependencia de prueba a tu archivo build.gradle:

testImplementation("androidx.paging:paging-testing:$paging_version")

Prueba la capa de datos

Para probar tu PagingSource directamente, usa TestPager. Esta utilidad controla los mecanismos subyacentes de Paging 3 y te permite verificar de forma independiente los casos extremos, como las cargas iniciales (actualización), la anexión o la anteposición de datos sin necesidad de una configuración completa de Pager.

@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)
}

Prueba la lógica y las transformaciones de ViewModel

Si tu ViewModel aplica transformaciones de datos (como operaciones .map) al flujo PagingData, puedes probar esta lógica con asPagingSourceFactory y asSnapshot().

La extensión asPagingSourceFactory convierte una lista estática en un PagingSource, lo que simplifica la simulación de la capa del repositorio. La extensión asSnapshot() recopila el flujo PagingData en un List estándar de Kotlin, lo que te permite ejecutar aserciones estándar en los datos transformados.

@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)
}

Pruebas de IU para verificar estados y recomposiciones

Cuando pruebes la IU, verifica que tus componentes de Compose rendericen los datos correctamente y reaccionen a los estados de carga de manera adecuada. Puedes pasar PagingData estáticos con PagingData.from() y flowOf() para simular flujos de datos. Además, puedes usar un SideEffect para hacer un seguimiento de los recuentos de recomposición durante las pruebas y asegurarte de que tus componentes de Compose no se recompongan de forma innecesaria.

En el siguiente ejemplo, se muestra cómo simular un estado de carga, realizar la transición a un estado cargado y verificar tanto los nodos de la IU como el recuento de recomposición:

@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"
    }
}