Saltar al contenido
Todas las entradas

Paging 3 con Room: manteniendo fluida una lista de transacciones creciente en Android en 2026

Cómo paginar una lista de transacciones respaldada por Room con Jetpack Paging 3 para que años de datos se mantengan fluidos en Compose — sin RemoteMediator, sin red, solo una base de datos local bien hecha.

MFKAPPS 5 min de lectura

La lista de transacciones de una app de presupuesto se ve bien en una demo con treinta filas. Deja de verse bien dos años después, cuando un usuario real tiene cuatro mil y desplazarse por la pantalla de historial empieza a entrecortarse. La solución no es un LIMIT más grande, y tampoco es “simplemente añade un spinner de carga al final” — es Jetpack Paging 3, conectado directamente a Room sin capa de red alguna. Así es como lo construí para Granyn, y los dos errores que me costaron un día cada uno antes de hacerlo bien.

Por qué LazyColumn por sí solo no es el problema

La versión ingenua funciona como toda lista en todo tutorial: una consulta de Room, SELECT * FROM transactions ORDER BY date DESC, mapeada a un Flow<List<Transaction>>, recolectada en un LazyColumn. El layout perezoso de Compose es genuinamente perezoso respecto a composición y renderizado — no recompone filas fuera de pantalla. Así que el entrecortamiento no es un problema de Compose. Está más arriba: Room tiene que materializar cada una de esas cuatro mil filas en objetos, en cada emisión, cada vez que una sola transacción cambia en cualquier parte de la tabla. Añade un gasto y toda la lista se reconstruye. Ese es el costo real, y ninguna optimización de renderizado de listas lo toca.

Lo que Paging 3 realmente aporta aquí

Paging 3 suele explicarse a través de su caso de uso con red — un RemoteMediator que obtiene páginas de una API y las cachea localmente. No es de eso de lo que se trata esto. Para una app local-first, todo el pipeline vive en Room: sin mediador, sin estado de red, sin lógica de reintento. Room genera un PagingSource por ti a partir de una @Query que devuelve PagingSource<Int, Transaction> en lugar de Flow<List<Transaction>>:

@Dao
interface TransactionDao {
    @Query("SELECT * FROM transactions ORDER BY date DESC")
    fun pagingSource(): PagingSource<Int, Transaction>
}

Ese único cambio de tipo es la mayor parte de la integración. Room ya sabe cómo invalidar un PagingSource cuando la tabla subyacente cambia — el mismo mecanismo de observación que impulsa las consultas Flow — así que las páginas se refrescan automáticamente en cada escritura sin que tengas que conectar nada extra.

Construyendo el stream

El PagingSource se envuelve en un Pager, que posee la configuración de paginación — cuántas filas por página, cuánto anticipar la precarga:

class TransactionRepository(private val dao: TransactionDao) {
    fun pagedTransactions(): Flow<PagingData<Transaction>> =
        Pager(
            config = PagingConfig(
                pageSize = 40,
                prefetchDistance = 20,
                enablePlaceholders = true,
            ),
            pagingSourceFactory = { dao.pagingSource() },
        ).flow
    }

Vale la pena señalar enablePlaceholders = true: como Room puede ejecutar SELECT COUNT(*) de forma barata en una tabla local, Paging 3 puede dimensionar una barra de desplazamiento con precisión y mostrar filas placeholder para contenido que aún no ha cargado — un desplazamiento rápido hasta el final de un historial de cuatro mil filas no necesita cargar cuatro mil filas primero. Es un lujo que la paginación respaldada por red no tiene, y es la razón principal por la que la paginación local se siente instantánea de una forma en que los feeds de scroll infinito normalmente no lo hacen.

El lado de Compose

collectAsLazyPagingItems() convierte el Flow<PagingData<Transaction>> en algo que un LazyColumn puede indexar directamente:

@Composable
fun TransactionList(viewModel: TransactionViewModel) {
    val transactions = viewModel.pagedTransactions.collectAsLazyPagingItems()

    LazyColumn {
        items(
            count = transactions.itemCount,
            key = transactions.itemKey { it.id },
        ) { index ->
            val transaction = transactions[index]
            if (transaction != null) {
                TransactionRow(transaction)
            } else {
                TransactionRowPlaceholder()
            }
        }
    }
}

itemKey importa más aquí que en una lista normal: sin una clave estable, Compose identifica las filas por posición en la lista, e insertar una transacción nueva arriba del todo — algo que ocurre constantemente en una app de presupuesto, ya que las entradas nuevas se ordenan arriba por fecha — desplaza la identidad de cada fila y desencadena una ola de recomposición innecesaria.

El error que me costó un día: el filtrado

La pantalla de historial también admite filtrar por categoría. Mi primer instinto fue mantener un único Pager vivo y filtrar el stream de PagingData río abajo con .filter { } en cada emisión de PagingData. Eso está mal, y es una trampa lo bastante común como para nombrarla directamente: filtrar después del hecho sigue paginando sobre la consulta sin filtrar, así que una categoría con cinco coincidencias de cuatro mil filas puede paginar por la mayor parte de la tabla antes de mostrar cinco resultados, y el conteo de placeholders es incorrecto porque refleja el número de filas de toda la tabla, no el de la tabla filtrada.

La solución es hacer que el filtro forme parte de la consulta, no un paso de posprocesamiento, y reconstruir el Pager cuando el filtro cambia:

@Query("""
    SELECT * FROM transactions
    WHERE (:categoryId IS NULL OR category_id = :categoryId)
    ORDER BY date DESC
""")
fun pagingSource(categoryId: Long?): PagingSource<Int, Transaction>
val pagedTransactions: Flow<PagingData<Transaction>> = filterState
    .flatMapLatest { categoryId -> repository.pagedTransactions(categoryId) }
    .cachedIn(viewModelScope)

flatMapLatest cancela el Pager anterior y arranca uno nuevo ajustado al nuevo filtro, de modo que los placeholders, los conteos y la precarga son todos correctos para la consulta que el usuario está viendo realmente. cachedIn(viewModelScope) es lo que sobrevive a los cambios de configuración — sin él, rotar la pantalla reinicia la paginación desde cero.

La conclusión

Paging 3 sobre Room no necesita nada de la maquinaria de red con la que normalmente se enseña — sin RemoteMediator, sin UI de reintento de LoadState, sin caché offline que reconciliar. Lo que queda es genuinamente simple: un PagingSource a partir de una consulta, un Pager con configuración de precarga, y collectAsLazyPagingItems() en el lado de Compose. Lo único que vale la pena hacer bien desde el principio es mantener los filtros dentro de la consulta en lugar de río abajo de ella — todo lo demás se deriva de eso. Una lista de transacciones que se mantiene fluida con cuatro mil filas no es una característica de rendimiento que añades después; es la diferencia entre una app que funciona en la demo y una que sigue funcionando dos años después de uso real.