Listas grandes parecem simples até o app começar a travar, repetir itens, perder posição de rolagem ou baixar milhares de registros de uma vez. Em apps Android reais, feeds, catálogos, históricos, chats, pedidos, notificações, resultados de busca e telas administrativas raramente cabem em uma única resposta HTTP. É nesse ponto que Paging 3 com Kotlin e Jetpack Compose deixa de ser detalhe de performance e vira parte importante da arquitetura.
O Paging 3 é a biblioteca oficial do Android Jetpack para carregar dados em páginas, combinar fontes locais e remotas, expor fluxo reativo com Flow<PagingData<T>> e integrar com listas Compose por meio de LazyPagingItems. Quando usado bem, ele reduz consumo de memória, melhora tempo de primeira renderização e cria uma experiência mais estável em conexões brasileiras comuns: 4G instável, Wi-Fi público, rede corporativa com proxy e dispositivos intermediários.
Este guia mostra quando usar Paging 3, como criar um PagingSource, como integrar com Jetpack Compose, quando usar RemoteMediator com Room, como tratar LoadState, quais erros evitar e como testar a camada de paginação. Se você já estudou Flow em Kotlin, Room Database, Android offline-first e WorkManager, o Paging 3 completa uma peça essencial para apps Android modernos.
Quando usar Paging 3?
Use Paging 3 quando a tela trabalha com uma coleção potencialmente grande ou dinâmica. Alguns exemplos comuns:
- feed de posts, produtos, notícias ou transações;
- busca com muitos resultados;
- histórico de pedidos, corridas, pagamentos ou mensagens;
- catálogo offline sincronizado em partes;
- lista de usuários, clientes, tarefas ou chamados;
- timeline ordenada por data, popularidade ou relevância.
Não use Paging 3 para listas pequenas e estáticas. Se a tela sempre exibe 10 categorias fixas, uma lista comum é mais simples. Também não vale complicar um formulário com paginação apenas porque a API suporta page e limit. A pergunta prática é: a lista pode crescer a ponto de afetar memória, rede, tempo de resposta ou experiência de rolagem? Se sim, Paging 3 merece entrar na arquitetura.
Dependências básicas
Em um app Android com Gradle Kotlin DSL, você normalmente adiciona as integrações de runtime e Compose:
dependencies {
implementation("androidx.paging:paging-runtime-ktx:3.3.6")
implementation("androidx.paging:paging-compose:3.3.6")
}
Confira a versão mais recente compatível com o seu projeto, especialmente se você usa Kotlin, Compose Compiler e Android Gradle Plugin em versões novas. Em projetos com Room, a integração costuma ficar ainda mais simples porque DAOs podem retornar PagingSource diretamente.
O modelo mental do Paging 3
O Paging 3 separa responsabilidades em três pontos principais:
PagingSource: sabe carregar uma página a partir de uma chave;Pager: configura tamanho da página, placeholders e fonte de dados;PagingData: fluxo de dados paginados consumido pela UI.
Em um caso simples, o repository expõe um Flow<PagingData<Item>>. O ViewModel aplica cache no viewModelScope. A tela Compose coleta esse fluxo com collectAsLazyPagingItems() e renderiza usando LazyColumn.
Essa separação combina bem com MVVM em Kotlin porque a UI não precisa conhecer detalhes de página, offset, cursor, retry HTTP ou banco local. Ela observa um estado paginado e reage a carregamento, erro e conteúdo vazio.
PagingSource com API remota
Um PagingSource recebe uma chave e devolve uma página. A chave pode ser número da página, offset, timestamp, cursor ou token enviado pela API. Para APIs tradicionais com page, o código fica assim:
class ProdutosPagingSource(
private val api: ProdutosApi,
private val termo: String,
) : PagingSource<Int, Produto>() {
override suspend fun load(params: LoadParams<Int>): LoadResult<Int, Produto> {
val pagina = params.key ?: 1
return try {
val resposta = api.buscarProdutos(
query = termo,
page = pagina,
pageSize = params.loadSize,
)
LoadResult.Page(
data = resposta.items,
prevKey = if (pagina == 1) null else pagina - 1,
nextKey = if (resposta.items.isEmpty()) null else pagina + 1,
)
} catch (erro: IOException) {
LoadResult.Error(erro)
} catch (erro: HttpException) {
LoadResult.Error(erro)
}
}
override fun getRefreshKey(state: PagingState<Int, Produto>): Int? {
return state.anchorPosition?.let { anchor ->
val paginaMaisProxima = state.closestPageToPosition(anchor)
paginaMaisProxima?.prevKey?.plus(1) ?: paginaMaisProxima?.nextKey?.minus(1)
}
}
}
O método getRefreshKey parece burocrático, mas é importante para manter a posição aproximada do usuário quando a lista é atualizada. Sem ele, refreshes podem voltar para o começo da lista de forma irritante.
Repository e ViewModel
O repository monta o Pager e esconde a implementação da UI:
class ProdutosRepository(
private val api: ProdutosApi,
) {
fun buscarProdutos(termo: String): Flow<PagingData<Produto>> {
return Pager(
config = PagingConfig(
pageSize = 20,
initialLoadSize = 40,
prefetchDistance = 5,
enablePlaceholders = false,
),
pagingSourceFactory = {
ProdutosPagingSource(api = api, termo = termo)
},
).flow
}
}
No ViewModel, use cachedIn(viewModelScope) para evitar recarregamento desnecessário quando a tela recompõe ou quando há múltiplos collectors:
class ProdutosViewModel(
private val repository: ProdutosRepository,
) : ViewModel() {
private val termoBusca = MutableStateFlow("")
val produtos: Flow<PagingData<Produto>> = termoBusca
.debounce(300)
.distinctUntilChanged()
.flatMapLatest { termo -> repository.buscarProdutos(termo) }
.cachedIn(viewModelScope)
fun atualizarBusca(novoTermo: String) {
termoBusca.value = novoTermo
}
}
Esse padrão é útil para busca porque cada termo cria uma nova fonte de paginação. flatMapLatest cancela a busca anterior quando o usuário digita algo novo, evitando corrida entre respostas antigas e novas.
Consumindo PagingData no Jetpack Compose
No Compose, a integração fica direta:
@Composable
fun ProdutosScreen(
viewModel: ProdutosViewModel,
) {
val produtos = viewModel.produtos.collectAsLazyPagingItems()
LazyColumn {
items(
count = produtos.itemCount,
key = produtos.itemKey { it.id },
) { index ->
val produto = produtos[index]
if (produto != null) {
ProdutoCard(produto = produto)
} else {
ProdutoPlaceholder()
}
}
}
}
Use key estável sempre que possível. Isso ajuda o Compose a preservar estado de itens, animações e posição de rolagem. Se você usa id vindo do backend, prefira esse identificador em vez do índice da lista.
Tratando loading, erro e lista vazia
Uma tela paginada precisa diferenciar três estados:
- carregamento inicial (
refresh); - carregamento no final da lista (
append); - erro inicial ou erro ao buscar a próxima página.
@Composable
fun ProdutosContent(produtos: LazyPagingItems<Produto>) {
when (val refresh = produtos.loadState.refresh) {
is LoadState.Loading -> LoadingTelaInteira()
is LoadState.Error -> ErroTelaInteira(
mensagem = refresh.error.message ?: "Não foi possível carregar os produtos.",
onRetry = { produtos.retry() },
)
is LoadState.NotLoading -> {
if (produtos.itemCount == 0) {
EmptyState(mensagem = "Nenhum produto encontrado.")
} else {
ListaProdutos(produtos)
}
}
}
}
Para append, o ideal é renderizar um item no rodapé:
if (produtos.loadState.append is LoadState.Loading) {
item { LoadingRodape() }
}
if (produtos.loadState.append is LoadState.Error) {
item { BotaoTentarNovamente(onClick = { produtos.retry() }) }
}
Essa diferença melhora bastante a experiência. Um erro na próxima página não deve apagar os itens já carregados. A lista pode continuar visível, com um retry discreto no rodapé.
Paging 3 com Room e RemoteMediator
Para apps offline-first, o padrão mais robusto é usar Room como fonte local e RemoteMediator para sincronizar rede e banco. A UI lê do banco. O mediator decide quando buscar mais dados e salvar localmente. Isso combina com a arquitetura descrita no guia de Android offline-first com Kotlin.
O DAO pode retornar PagingSource:
@Dao
interface ProdutoDao {
@Query("SELECT * FROM produtos ORDER BY nome ASC")
fun paginarProdutos(): PagingSource<Int, ProdutoEntity>
}
O repository usa o banco como fonte principal:
fun produtosOfflineFirst(): Flow<PagingData<Produto>> {
return Pager(
config = PagingConfig(pageSize = 30),
remoteMediator = ProdutosRemoteMediator(api, database),
pagingSourceFactory = { database.produtoDao().paginarProdutos() },
).flow.map { pagingData ->
pagingData.map { entity -> entity.toDomain() }
}
}
Esse desenho evita que a tela dependa diretamente da rede. Se o usuário abre o app sem conexão, os dados locais continuam aparecendo. Quando a rede volta, o mediator atualiza o banco e a lista reflete as mudanças automaticamente.
Tamanho de página, prefetch e placeholders
Não existe um tamanho perfeito de página. Comece com algo entre 20 e 50 itens e ajuste medindo tempo de resposta, tamanho do payload e custo de renderização. Páginas muito pequenas aumentam overhead de rede. Páginas grandes demais atrasam o primeiro carregamento e podem causar jank em dispositivos modestos.
prefetchDistance define quando o Paging começa a buscar a próxima página antes de o usuário chegar ao fim. Um valor baixo economiza rede, mas pode mostrar loading com frequência. Um valor alto deixa a rolagem mais fluida, mas pode baixar dados que o usuário nunca verá.
enablePlaceholders só faz sentido quando você sabe o tamanho total da lista e quer reservar espaço para itens ainda não carregados. Em muitos feeds e buscas, false é mais previsível.
Erros comuns em projetos Kotlin
O primeiro erro é recriar o Pager a cada recomposição. O Pager deve ficar no repository ou no ViewModel, não dentro de um @Composable comum. Compose pode recompor muitas vezes; paginação não deve reiniciar por causa disso.
O segundo erro é esquecer cachedIn(viewModelScope). Sem cache, mudanças de configuração e novos collectors podem disparar novas cargas desnecessárias.
O terceiro erro é tratar todo erro como tela cheia. Se o erro acontece no append, preserve a lista e ofereça retry no rodapé.
O quarto erro é misturar filtros sem invalidar corretamente a fonte. Se busca, ordenação ou categoria mudam, crie um novo fluxo de paginação com flatMapLatest ou invalide o PagingSource de forma explícita.
O quinto erro é ignorar testes. Paginação parece visual, mas boa parte da lógica está em PagingSource, repository e mediator. Isso pode ser testado na JVM.
Testando PagingSource
Um teste simples valida se a primeira página retorna os dados esperados:
@Test
fun `deve carregar primeira pagina de produtos`() = runTest {
val api = FakeProdutosApi(
produtos = listOf(
Produto(id = "1", nome = "Café"),
Produto(id = "2", nome = "Chá"),
),
)
val source = ProdutosPagingSource(api = api, termo = "")
val resultado = source.load(
PagingSource.LoadParams.Refresh(
key = null,
loadSize = 20,
placeholdersEnabled = false,
),
)
assertEquals(
PagingSource.LoadResult.Page(
data = api.produtos,
prevKey = null,
nextKey = 2,
),
resultado,
)
}
Em mediators, teste cenários de refresh, append, erro HTTP, banco vazio e banco já populado. Para fluxos com PagingData, use ferramentas do próprio Paging e, quando fizer sentido, combine com estratégias de teste já abordadas no guia de testes Android com Kotlin, Compose e Maestro.
Como isso ajuda carreira e produto
Paging 3 aparece com frequência em apps usados em produção porque resolve um problema que quase toda empresa tem: listas grandes com rede imperfeita. Para carreira Android, saber explicar PagingSource, RemoteMediator, LoadState, Room e Compose em conjunto demonstra maturidade além do CRUD básico.
Para produto, a vantagem é concreta: menos travamento, menos dados baixados sem necessidade, primeira tela mais rápida e experiência melhor quando o usuário navega por catálogos, históricos ou feeds extensos. Em times backend, esse mesmo cuidado força contratos de API melhores, com paginação consistente por cursor, metadados claros e respostas previsíveis. Se você trabalha também no servidor, vale comparar esse desenho com APIs Kotlin em Ktor ou com alternativas de alta performance em Go para backend.
Conclusão
Paging 3 não é apenas uma biblioteca para “carregar mais itens”. Ele organiza uma parte crítica da experiência Android: como dados grandes entram na tela sem desperdiçar rede, memória ou paciência do usuário. Em Kotlin, a combinação de Flow, PagingData, Room, RemoteMediator e Jetpack Compose cria uma arquitetura moderna e testável para listas grandes.
Comece simples com PagingSource quando a API remota já resolve o caso. Evolua para Room e RemoteMediator quando a experiência precisa ser offline-first. Trate LoadState com cuidado, preserve itens já carregados em erros de próxima página e mantenha o Pager fora da recomposição. Esses detalhes separam uma lista que “funciona no demo” de uma tela pronta para produção em 2026.