Visão geral da Paging 3

Este guia explica como implementar a paginação 3 com o Jetpack Compose, abordando implementações com e sem um banco de dados do Room. A paginação é uma estratégia para gerenciar grandes conjuntos de dados carregando e mostrando-os em pequenos blocos gerenciáveis, chamados de páginas, em vez de carregar tudo de uma vez.

Qualquer app que tenha um feed de rolagem infinita (como uma linha do tempo de rede social, um grande catálogo de produtos de e-commerce ou uma caixa de entrada de e-mail extensa) exige uma paginação de dados robusta. Como os usuários geralmente só veem uma pequena parte de uma lista e os dispositivos móveis têm tamanhos de tela limitados, carregar o conjunto de dados inteiro não é eficiente. Isso desperdiça recursos do sistema e pode causar instabilidade ou congelamento do app, piorando a experiência do usuário. Para resolver isso, use o carregamento lento. Embora componentes como LazyList no Compose processem o carregamento lento no lado da interface, o carregamento lento de dados do disco ou da rede melhora ainda mais o desempenho.

A biblioteca Paging 3 é a solução recomendada para processar a paginação de dados. Se você estiver migrando da Paging 2, consulte Migrar para a Paging 3 para receber orientações.

Pré-requisitos

Antes de continuar, familiarize-se com o seguinte:

  • Rede no Android. Usamos o Retrofit neste documento, mas o Paging 3 funciona com qualquer biblioteca, como o Ktor.
  • O kit de ferramentas de interface do Compose.

Configurar dependências

Adicione as seguintes dependências ao arquivo build.gradle.kts no nível do 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")
}

Definir a classe Pager

A classe Pager é o ponto de entrada principal para a paginação. Ele cria um fluxo reativo de PagingData. Instancie o Pager e reutilize-o no seu ViewModel.

O Pager exige um PagingConfig para determinar como buscar e apresentar dados.

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

É possível implementar o Pager de duas maneiras: sem um banco de dados (somente rede) ou com um banco de dados (usando o Room).

Implementar sem um banco de dados

Quando você não usa um banco de dados, precisa de um PagingSource<Key, Value> para processar o carregamento de dados sob demanda. Neste exemplo, a chave é Int e o valor é um Product.

É necessário implementar dois métodos abstratos na sua PagingSource:

  • load: uma função de suspensão que recebe LoadParams. Use isso para buscar dados de solicitações Refresh, Append ou Prepend.

  • getRefreshKey: fornece a chave usada para recarregar dados se o pager for invalidado. Esse método calcula a chave com base na posição de rolagem atual do usuário (state.anchorPosition).

O exemplo de código a seguir mostra como implementar a classe ProductPagingSource, que é necessária para definir a lógica de busca de dados ao usar o Paging 3 sem um banco de dados 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)
        }
    }
}

Na classe ViewModel, crie o 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)

Implementar com um banco de dados

Ao usar o Room, o banco de dados gera automaticamente a classe PagingSource. No entanto, o banco de dados não sabe quando buscar mais dados da rede. Para processar isso, implemente um RemoteMediator.

O método RemoteMediator.load() fornece o loadType (Append, Prepend ou Refresh) e o estado. Ele retorna um MediatorResult indicando sucesso ou falha e se o fim da paginação foi atingido.

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

No seu ViewModel, a implementação é muito mais simples porque o Room processa a classe PagingSource:

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

Configuração da rede

Os exemplos anteriores dependem de um serviço de rede. Esta seção mostra a configuração do Retrofit e da serialização usada para buscar dados do endpoint api.example.com/products.

Classes de dados

O exemplo de código a seguir mostra como definir as duas classes de dados, ProductResponse e Product, que são usadas com kotlinx.serialization para analisar a resposta JSON paginada do serviço de rede.

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

Serviço de adaptação

O exemplo de código a seguir mostra como definir a interface de serviço do Retrofit (ProductService) para a implementação somente de rede, especificando o endpoint (@GET("/products")) e os parâmetros de paginação necessários (limit) e (skip) exigidos pela biblioteca Paging 3 para buscar páginas de dados.

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

Consumir dados no Compose

Depois de configurar o Pager, você poderá mostrar os dados na interface.

  1. Coletar o fluxo: use collectAsLazyPagingItems() para converter o fluxo em um objeto de itens de paginação lenta com reconhecimento de estado.

    val productPagingData = mainViewModel.productPager.collectAsLazyPagingItems()
    

    O objeto LazyPagingItems resultante fornece contagens de itens e acesso indexado, permitindo que ele seja consumido diretamente pelo método LazyColumn para renderizar os itens da lista.

  2. Vincular a LazyColumn: transmita os dados a uma lista LazyColumn. Se você estiver migrando da lista RecyclerView, talvez já saiba como usar withLoadStateHeaderAndFooter para mostrar ícones de carregamento ou botões de repetição de erro na parte de cima ou de baixo da lista.

    No Compose, não é necessário um adaptador especial para isso. Você pode conseguir o mesmo comportamento adicionando condicionalmente um bloco item {} antes ou depois do bloco items {} principal, reagindo diretamente aos estados de carregamento prepend (cabeçalho) e append (rodapé).

    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 mais informações sobre como os recursos do Compose permitem mostrar coleções de itens de forma eficaz, consulte Listas e grades.

Processar estados de carregamento

O objeto PagingData integra informações sobre o estado de carregamento. É possível usar isso para mostrar indicadores de carregamento ou mensagens de erro para diferentes estados (refresh, append ou prepend).

Para evitar recomposições desnecessárias e garantir que a interface só reaja a transições significativas no ciclo de vida de carregamento, filtre as observações de estado. Como o loadState é atualizado com frequência com mudanças internas, a leitura direta para mudanças de estado complexas pode causar travamentos.

Para otimizar isso, use snapshotFlow para observar o estado e aplique operadores de fluxo, como a propriedade distinctUntilChangedBy. Isso é especialmente útil ao mostrar estados vazios ou acionar efeitos colaterais, como um Snackbar de erro:

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

Ao verificar o estado de atualização para mostrar um spinner de carregamento em tela cheia, use derivedStateOf para evitar recomposições desnecessárias.

Além disso, se você estiver usando um RemoteMediator (como na implementação do banco de dados Room anterior), inspecione explicitamente o estado de carregamento da fonte de dados subjacente (loadState.source.refresh) em vez da propriedade de conveniência loadState.refresh. A propriedade de conveniência pode informar que a busca de rede foi concluída antes que o banco de dados termine de adicionar os novos itens à interface. A verificação do source garante que a interface esteja totalmente sincronizada com o banco de dados local, evitando que o carregador desapareça muito cedo.

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

Você também pode verificar LoadState.Error para mostrar botões de nova tentativa ou mensagens de erro ao usuário. Recomendamos usar LoadState.Error porque ele expõe a exceção subjacente e ativa a função retry() integrada para recuperação do usuário.

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

Testar a implementação

Testar a implementação da paginação garante que os dados sejam carregados corretamente, que as transformações sejam aplicadas conforme o esperado e que a interface reaja adequadamente às mudanças de estado. A biblioteca Paging 3 oferece um artefato de teste dedicado (androidx.paging:paging-testing) para simplificar esse processo.

Primeiro, adicione a dependência de teste ao arquivo build.gradle:

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

Testar a camada de dados

Para testar seu PagingSource diretamente, use TestPager. Essa utilidade processa a mecânica subjacente da Paging 3 e permite verificar de forma independente casos extremos, como carregamentos iniciais (atualização), anexação ou pré-anexação de dados sem precisar de uma configuração completa do 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)
}

Testar a lógica e as transformações de ViewModel

Se o ViewModel aplicar transformações de dados (como operações .map) ao fluxo PagingData, teste essa lógica usando asPagingSourceFactory e asSnapshot().

A extensão asPagingSourceFactory converte uma lista estática em um PagingSource, facilitando a simulação da camada de repositório. A extensão asSnapshot() coleta o fluxo PagingData em um List padrão do Kotlin, permitindo que você execute asserções padrão nos dados 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)
}

Testes de UI para verificar estados e recomposições

Ao testar a interface, verifique se os componentes do Compose renderizam os dados corretamente e reagem aos estados de carregamento de maneira adequada. É possível transmitir PagingData estático usando PagingData.from() e flowOf() para simular fluxos de dados. Além disso, você pode usar um SideEffect para rastrear as contagens de recomposição durante os testes e garantir que os componentes do Compose não sejam recompostos sem necessidade.

O exemplo a seguir demonstra como simular um estado de carregamento, fazer a transição para um estado carregado e verificar os nós da interface e a contagem de recomposições:

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