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 recebeLoadParams. Use isso para buscar dados de solicitaçõesRefresh,AppendouPrepend.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.
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
LazyPagingItemsresultante fornece contagens de itens e acesso indexado, permitindo que ele seja consumido diretamente pelo métodoLazyColumnpara renderizar os itens da lista.Vincular a
LazyColumn: transmita os dados a uma listaLazyColumn. Se você estiver migrando da listaRecyclerView, talvez já saiba como usarwithLoadStateHeaderAndFooterpara 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 blocoitems {}principal, reagindo diretamente aos estados de carregamentoprepend(cabeçalho) eappend(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"
}
}