El problema sin Paging — por qué importa
Imaginá una app de e-commerce con 50.000 productos en la base de datos. El approach ingenuo:
// Sin paginación — el camino al OOM
fun getProductos(): Flow<List<Producto>> = dao.getAll()
// Carga los 50.000 productos en memoria
// El RecyclerView mantiene referencias a todos los ViewHolders
// El usuario solo ve 10 a la vez pero la app tiene 50.000 en RAM
Los problemas son reales y graduales: la primera pantalla es lenta porque espera los 50.000 items antes de mostrar nada, el consumo de memoria crece hasta que el sistema mata la app, y los filtros y búsquedas son lentos porque operan sobre toda la colección en memoria.
La solución conceptual es simple: cargar solo la página actual y la siguiente. Paging 3 es la implementación oficial de Jetpack para hacer eso, integrada con Room, Retrofit, RecyclerView y Compose.
Arquitectura de Paging 3 — los componentes
Paging 3 tiene tres capas que se corresponden con las capas de Clean Architecture:
- PagingSource (capa de datos): define cómo obtener una página de datos. Es el único lugar donde sabés si los datos vienen de Room, de una API, o de donde sea.
- Pager + Flow<PagingData> (ViewModel): conecta la fuente de datos con la UI. El ViewModel expone un Flow que emite páginas de datos a medida que se necesitan.
- PagingDataAdapter (UI): un Adapter especial que recibe PagingData, aplica DiffUtil automáticamente y solicita nuevas páginas cuando el usuario se acerca al final.
# El flujo completo:
#
# Usuario scrollea hacia abajo
# ↓
# PagingDataAdapter detecta que faltan items
# ↓
# Solicita la siguiente página al Pager
# ↓
# Pager llama a PagingSource.load()
# ↓
# PagingSource obtiene los datos (Room/API)
# ↓
# Los datos llegan como PagingData al Flow
# ↓
# El Adapter los inserta con animación automática
Setup
// build.gradle (app)
dependencies {
val paging_version = "3.3.0"
implementation("androidx.paging:paging-runtime-ktx:$paging_version")
// Para Compose:
implementation("androidx.paging:paging-compose:$paging_version")
// Para testing:
testImplementation("androidx.paging:paging-testing:$paging_version")
}
PagingSource — la fuente de datos paginada
El PagingSource define cómo cargar una página. Implementá load() que recibe un LoadParams con la clave de la página y el tamaño solicitado, y retorna un LoadResult:
// PagingSource para una API REST con paginación por número de página
class ProductosPagingSource(
private val api: ProductoApi,
private val categoria: String?
) : PagingSource<Int, Producto>() {
// Int = tipo de la clave (número de página)
// Producto = tipo de los items
override suspend fun load(params: LoadParams<Int>): LoadResult<Int, Producto> {
// La clave de la primera página — si es null, empezamos desde 1
val pagina = params.key ?: 1
return try {
val respuesta = api.getProductos(
page = pagina,
pageSize = params.loadSize, // Paging sugiere cuántos items cargar
categoria = categoria
)
LoadResult.Page(
data = respuesta.items.map { it.toDomain() },
// Página anterior — null si es la primera
prevKey = if (pagina == 1) null else pagina - 1,
// Página siguiente — null si llegamos al final
nextKey = if (respuesta.items.isEmpty()) null else pagina + 1
)
} catch (e: IOException) {
LoadResult.Error(e) // Paging maneja el retry automáticamente
} catch (e: HttpException) {
LoadResult.Error(e)
}
}
// Clave para reiniciar desde la página donde el usuario estaba
// Paging la usa al hacer refresh o al volver de otra pantalla
override fun getRefreshKey(state: PagingState<Int, Producto>): Int? {
return state.anchorPosition?.let { anchorPosition ->
val anchorPage = state.closestPageToPosition(anchorPosition)
anchorPage?.prevKey?.plus(1) ?: anchorPage?.nextKey?.minus(1)
}
}
}
params.loadSize no siempre es pageSizeEn la primera carga, Paging puede pedir initialLoadSize (por default 3x el pageSize) para llenar la pantalla rápidamente. En las cargas siguientes usa el pageSize configurado. Tu API debe soportar tamaños de página variables, o podés ignorar params.loadSize y usar siempre tu tamaño fijo.
ViewModel — el Flow de PagingData
@HiltViewModel
class ProductosViewModel @Inject constructor(
private val api: ProductoApi
) : ViewModel() {
// El estado del filtro actual
private val categoriaActual = MutableStateFlow<String?>(null)
// El Flow de PagingData — se recrea cuando cambia el filtro
val productos: Flow<PagingData<Producto>> = categoriaActual
.flatMapLatest { categoria ->
Pager(
config = PagingConfig(
pageSize = 20, // items por página
prefetchDistance = 5, // cargar la siguiente página cuando faltan 5 items
enablePlaceholders = false, // no mostrar placeholders mientras carga
initialLoadSize = 40 // primera carga trae más items (2x pageSize)
),
pagingSourceFactory = {
// Nueva instancia por cada paginación — obligatorio
ProductosPagingSource(api, categoria)
}
).flow
}
// cachedIn mantiene los datos cuando la UI se recrea (rotación)
.cachedIn(viewModelScope)
fun filtrarPorCategoria(categoria: String?) {
categoriaActual.value = categoria
}
}
cachedIn es obligatorioSin cachedIn(viewModelScope), cada nuevo colector (por ejemplo, al rotar el dispositivo) reinicia la paginación desde cero. Con cachedIn, los datos se mantienen en el ViewModel y la UI se restaura instantáneamente.
PagingDataAdapter — el adapter especial
PagingDataAdapter es como ListAdapter pero para PagingData. Maneja DiffUtil automáticamente y notifica a Paging cuando necesita más páginas:
class ProductosAdapter(
private val onClick: (Producto) -> Unit
) : PagingDataAdapter<Producto, ProductoViewHolder>(ProductoDiffCallback()) {
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ProductoViewHolder {
return ProductoViewHolder.create(parent)
}
override fun onBindViewHolder(holder: ProductoViewHolder, position: Int) {
// getItem() puede retornar null si enablePlaceholders = true
getItem(position)?.let { holder.bind(it, onClick) }
}
}
class ProductoDiffCallback : DiffUtil.ItemCallback<Producto>() {
override fun areItemsTheSame(old: Producto, new: Producto) = old.id == new.id
override fun areContentsTheSame(old: Producto, new: Producto) = old == new
}
// En el Fragment — colectar PagingData:
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
val adapter = ProductosAdapter { producto ->
findNavController().navigate(
ProductosFragmentDirections.actionToDetalle(producto.id)
)
}
binding.recyclerView.adapter = adapter
viewLifecycleOwner.lifecycleScope.launch {
viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
viewModel.productos.collectLatest { pagingData ->
// submitData suspende hasta que el PagingData es procesado
adapter.submitData(pagingData)
}
}
}
}
LoadState — manejar los estados de carga
Paging expone el estado de carga en tres posiciones: refresh (carga inicial o refresh completo), prepend (carga al scrollear hacia arriba) y append (carga al scrollear hacia abajo). Podés observarlos para mostrar progress indicators y errores:
// Observar el estado de carga en el Fragment
viewLifecycleOwner.lifecycleScope.launch {
viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
adapter.loadStateFlow.collectLatest { loadStates ->
// Estado de la carga inicial / refresh
when (val refresh = loadStates.refresh) {
is LoadState.Loading -> {
binding.progressBar.isVisible = true
binding.recyclerView.isVisible = false
binding.errorView.isVisible = false
}
is LoadState.NotLoading -> {
binding.progressBar.isVisible = false
binding.recyclerView.isVisible = true
// Si la lista está vacía después de cargar:
binding.emptyView.isVisible = adapter.itemCount == 0
}
is LoadState.Error -> {
binding.progressBar.isVisible = false
binding.errorView.isVisible = true
binding.tvError.text = refresh.error.message
}
}
// Mostrar loader al final de la lista (cargando más items)
binding.loadMoreProgress.isVisible = loadStates.append is LoadState.Loading
}
}
}
// Retry cuando hay un error
binding.btnRetry.setOnClickListener {
adapter.retry() // Paging reintenta la última página fallida
}
LoadStateAdapter — footer de carga automático
Paging incluye soporte para un adapter separado que aparece al final de la lista durante la carga:
// Adapter para el footer de carga/error
class LoadingStateAdapter(
private val retry: () -> Unit
) : LoadStateAdapter<LoadingStateViewHolder>() {
override fun onCreateViewHolder(parent: ViewGroup, loadState: LoadState) =
LoadingStateViewHolder.create(parent, retry)
override fun onBindViewHolder(holder: LoadingStateViewHolder, loadState: LoadState) =
holder.bind(loadState)
}
// Conectar al adapter principal:
binding.recyclerView.adapter = adapter.withLoadStateFooter(
footer = LoadingStateAdapter { adapter.retry() }
)
Paginación con Room — sin PagingSource manual
Si los datos vienen de Room, no necesitás escribir un PagingSource — Room lo genera automáticamente con la anotación @Query que retorna PagingSource:
// En el DAO — Room genera el PagingSource automáticamente
@Dao
interface ProductoDao {
@Query("SELECT * FROM productos ORDER BY nombre ASC")
fun getProductosPaginados(): PagingSource<Int, ProductoEntity>
@Query("SELECT * FROM productos WHERE categoria = :cat ORDER BY nombre ASC")
fun getProductosPorCategoria(cat: String): PagingSource<Int, ProductoEntity>
}
// En el repositorio:
class ProductoRepositoryImpl @Inject constructor(
private val dao: ProductoDao
) : ProductoRepository {
override fun getProductosPaginados(categoria: String?): Flow<PagingData<Producto>> {
val pagingSourceFactory = if (categoria != null) {
{ dao.getProductosPorCategoria(categoria) }
} else {
{ dao.getProductosPaginados() }
}
return Pager(
config = PagingConfig(pageSize = 20),
pagingSourceFactory = pagingSourceFactory
).flow.map { pagingData ->
pagingData.map { entity -> entity.toDomain() }
// .map() en PagingData transforma cada item
}
}
}
Room invalida el PagingSource automáticamenteCuando inserting o actualizás datos en Room, el PagingSource actual se invalida y Paging hace un refresh automático. La UI se actualiza sin que tengas que hacer nada explícito — el mismo comportamiento reactivo de un Flow de Room, pero paginado.
RemoteMediator — offline-first con Paging
El patrón más poderoso de Paging 3: Room como fuente de verdad, la API como fuente de datos remota. El usuario ve datos inmediatamente (desde Room) y en background se sincroniza con la API.
@OptIn(ExperimentalPagingApi::class)
class ProductosRemoteMediator(
private val api: ProductoApi,
private val database: AppDatabase
) : RemoteMediator<Int, ProductoEntity>() {
override suspend fun load(
loadType: LoadType,
state: PagingState<Int, ProductoEntity>
): MediatorResult {
val pagina = when (loadType) {
LoadType.REFRESH -> {
// Refresh completo — empezar desde la primera página
1
}
LoadType.PREPEND -> {
// Scrollear hacia arriba — no hay páginas anteriores en este ejemplo
return MediatorResult.Success(endOfPaginationReached = true)
}
LoadType.APPEND -> {
// Scrollear hacia abajo — calcular la siguiente página
val ultimaRemoteKey = database.remoteKeyDao().getLastRemoteKey()
ultimaRemoteKey?.nextPage
?: return MediatorResult.Success(endOfPaginationReached = true)
}
}
return try {
val respuesta = api.getProductos(page = pagina, pageSize = state.config.pageSize)
val endOfPagination = respuesta.items.isEmpty()
database.withTransaction {
if (loadType == LoadType.REFRESH) {
// Limpiar datos viejos en refresh
database.productoDao().deleteAll()
database.remoteKeyDao().deleteAll()
}
// Guardar la clave de paginación remota
database.remoteKeyDao().insert(
RemoteKey(nextPage = if (endOfPagination) null else pagina + 1)
)
// Guardar los items en Room
database.productoDao().insertAll(
respuesta.items.map { it.toEntity() }
)
}
MediatorResult.Success(endOfPaginationReached = endOfPagination)
} catch (e: IOException) {
MediatorResult.Error(e)
} catch (e: HttpException) {
MediatorResult.Error(e)
}
}
}
// En el repositorio — combinar Room + RemoteMediator:
@OptIn(ExperimentalPagingApi::class)
fun getProductosPaginados(): Flow<PagingData<Producto>> {
return Pager(
config = PagingConfig(pageSize = 20),
remoteMediator = ProductosRemoteMediator(api, database),
pagingSourceFactory = { database.productoDao().getProductosPaginados() }
).flow.map { pagingData ->
pagingData.map { it.toDomain() }
}
}
Separadores entre items
Paging 3 tiene soporte nativo para insertar separadores entre grupos de items — por ejemplo, una letra del alfabeto entre grupos de nombres, o una fecha entre mensajes:
// En el ViewModel — insertar separadores con insertSeparators()
val productos: Flow<PagingData<ProductoUiItem>> = repository
.getProductosPaginados()
.map { pagingData ->
pagingData.map { ProductoUiItem.Item(it) }
}
.map { pagingData ->
pagingData.insertSeparators { antes, despues ->
if (despues == null) return@insertSeparators null // fin de la lista
if (antes == null) return@insertSeparators ProductoUiItem.Header(
despues.item.nombre.first().uppercaseChar().toString()
)
// Insertar separador cuando cambia la primera letra
val letraAntes = antes.item.nombre.first().uppercaseChar()
val letraDespues = despues.item.nombre.first().uppercaseChar()
if (letraAntes != letraDespues) {
ProductoUiItem.Header(letraDespues.toString())
} else null
}
}
.cachedIn(viewModelScope)
// Sealed class para los tipos de items:
sealed class ProductoUiItem {
data class Item(val producto: Producto) : ProductoUiItem()
data class Header(val letra: String) : ProductoUiItem()
}
// El Adapter maneja los dos viewTypes:
class ProductosConHeaderAdapter : PagingDataAdapter<ProductoUiItem, RecyclerView.ViewHolder>(
object : DiffUtil.ItemCallback<ProductoUiItem>() {
override fun areItemsTheSame(old: ProductoUiItem, new: ProductoUiItem) =
(old is ProductoUiItem.Item && new is ProductoUiItem.Item && old.producto.id == new.producto.id) ||
(old is ProductoUiItem.Header && new is ProductoUiItem.Header && old.letra == new.letra)
override fun areContentsTheSame(old: ProductoUiItem, new: ProductoUiItem) = old == new
}
) {
companion object {
private const val TYPE_ITEM = 0
private const val TYPE_HEADER = 1
}
override fun getItemViewType(position: Int) = when (getItem(position)) {
is ProductoUiItem.Item -> TYPE_ITEM
is ProductoUiItem.Header -> TYPE_HEADER
null -> TYPE_ITEM
}
// onCreateViewHolder y onBindViewHolder manejan los dos tipos...
}
Testing con Paging 3
// La librería paging-testing incluye TestPager
// para testear PagingSource sin un RecyclerView real
class ProductosPagingSourceTest {
private val fakeApi = FakeProductoApi()
@Test
fun `load primera pagina retorna items correctos`() = runTest {
val pagingSource = ProductosPagingSource(fakeApi, categoria = null)
val resultado = pagingSource.load(
PagingSource.LoadParams.Refresh(
key = null,
loadSize = 20,
placeholdersEnabled = false
)
)
assertThat(resultado).isInstanceOf(PagingSource.LoadResult.Page::class.java)
val page = resultado as PagingSource.LoadResult.Page
assertThat(page.data).hasSize(20)
assertThat(page.prevKey).isNull() // primera página
assertThat(page.nextKey).isEqualTo(2)
}
@Test
fun `load ultima pagina retorna nextKey null`() = runTest {
fakeApi.configurarUltimaPagina()
val pagingSource = ProductosPagingSource(fakeApi, categoria = null)
val resultado = pagingSource.load(
PagingSource.LoadParams.Append(key = 5, loadSize = 20, placeholdersEnabled = false)
)
val page = resultado as PagingSource.LoadResult.Page
assertThat(page.nextKey).isNull() // fin de la paginación
}
// Con TestPager — simular el scroll completo:
@Test
fun `paginacion completa retorna todos los items`() = runTest {
val pagingSource = ProductosPagingSource(fakeApi, categoria = null)
val pager = TestPager(PagingConfig(pageSize = 20), pagingSource)
val primeraPagina = pager.refresh()
assertThat(primeraPagina).isInstanceOf(PagingSource.LoadResult.Page::class.java)
val segundaPagina = pager.append()
assertThat(pager.getLastLoadedPage()?.data).hasSize(20)
}
}