PagingSource: claves, carga y el flujo de datos
El corazón de la librería es una clase abstracta con dos métodos, y toda su dificultad se concentra en entender qué es una clave, por qué la clave de refresco se calcula de forma distinta a las demás y qué significa exactamente que una fuente quede invalidada. Esta lección implementa una fuente basada en desplazamiento y otra basada en cursor, explica la conversión entre posición de la lista y clave de página, y construye el objeto que orquesta las cargas y produce el flujo de datos paginados que consumirá la interfaz.
Una fuente de paginación es una respuesta a una pregunta muy concreta: dada una clave, devuélveme los elementos de esa página y dime cuáles son las claves de la página anterior y de la siguiente. Enunciado así parece trivial, y la implementación mínima cabe en veinte líneas. Lo que no es trivial, y donde se concentran casi todos los errores reales, es el sistema de claves: qué las genera, quién decide la primera, cómo se calcula la que hay que usar cuando el usuario rota el dispositivo a mitad de la lista, y qué ocurre con todas ellas cuando alguien inserta un elemento en el servidor mientras tú estabas en la página siete. La librería resuelve el mecanismo de carga y te deja a ti la responsabilidad de la coherencia de las claves, que es exactamente el reparto correcto: el mecanismo es genérico, la semántica de tu paginación es irreductiblemente tuya. Esta lección trata de esa semántica.
- Implementar
PagingSourcepara una API basada en desplazamiento y para otra basada en cursor. - Entender el papel del estado de paginación y calcular correctamente la clave de refresco.
- Distinguir invalidación de refresco y saber qué provoca cada una.
- Construir un
Pagery exponer elPagingDatacomo flujo desde el modelo de vista.
Dos métodos y un contrato
PagingSource se parametriza con dos tipos: el de la clave y el del valor. La clave identifica una página y su naturaleza depende por completo de la API con la que hablas —un número de página, un desplazamiento en elementos, una marca de tiempo, un cursor opaco—. El valor es el elemento de dominio que acaba en la lista.
El método de carga es suspendido, recibe unos parámetros que contienen la clave solicitada y el tamaño de página deseado, y devuelve un resultado que es o bien una página o bien un error. Devolver el error como valor, en lugar de propagar la excepción, es una decisión de diseño deliberada: permite que la librería lo transporte hasta el estado de carga de la interfaz y ofrezca una operación de reintento sobre esa misma carga concreta.
class ArticulosPagingSource(
private val api: ArticulosApi,
) : PagingSource<Int, Articulo>() {
override suspend fun load(
params: LoadParams<Int>,
): LoadResult<Int, Articulo> = try {
val pagina = params.key ?: PRIMERA_PAGINA
val respuesta = api.articulos(pagina = pagina, tamano = params.loadSize)
LoadResult.Page(
data = respuesta.items,
prevKey = if (pagina == PRIMERA_PAGINA) null else pagina - 1,
nextKey = if (respuesta.items.isEmpty()) null else pagina + 1,
)
} catch (e: IOException) {
LoadResult.Error(e)
} catch (e: HttpException) {
LoadResult.Error(e)
}
}
Hay cuatro detalles en esas líneas que conviene no pasar por alto. La clave nula significa carga inicial, y es responsabilidad tuya traducirla a la primera página real de tu API. Devolver null en la clave anterior o en la siguiente es la única forma de comunicar que no hay más en esa dirección, y de ahí saldrá el estado de fin de paginación en la interfaz. El tamaño solicitado no siempre coincide con el configurado: en la carga inicial la librería pide deliberadamente más para llenar la pantalla de golpe, así que hay que usar el valor recibido y no una constante. Y la captura de excepciones debe ser selectiva: capturar todo transforma un fallo de programación en un error de red mostrado al usuario, que es la peor forma posible de enterrar un defecto.
Deducir la clave siguiente de que la respuesta venga vacía funciona en la mayoría de las APIs pero no en todas. Si tu servidor puede devolver una página vacía intermedia porque filtra después de paginar, la lista se detendrá prematuramente. Cuando exista un campo explícito de total, de siguiente enlace o de hay más, úsalo siempre en lugar de la heurística.
Claves de desplazamiento y claves de cursor
La diferencia entre los dos grandes estilos de API no es cosmética y determina qué garantías puedes ofrecer.
Una API de desplazamiento acepta una posición numérica y un tamaño. Es simple y permite saltar a cualquier punto, pero tiene un defecto estructural bien conocido: si alguien inserta un elemento al principio mientras el usuario navega, todas las posiciones se desplazan, y el resultado es que el usuario ve un elemento repetido al pasar de página o se salta uno sin enterarse. Con volúmenes altos añade además un problema de rendimiento en el servidor, porque saltar cien mil filas obliga a la base de datos a recorrerlas.
Una API de cursor entrega junto a cada página una referencia opaca al último elemento devuelto, y la siguiente petición pide lo que hay después de esa referencia. Es inmune a las inserciones, es eficiente en el servidor porque se traduce en una comparación indexada, y a cambio pierde la capacidad de salto arbitrario: solo se puede avanzar desde donde estás.
class MensajesPagingSource(
private val api: MensajesApi,
private val canalId: String,
) : PagingSource<String, Mensaje>() {
override suspend fun load(
params: LoadParams<String>,
): LoadResult<String, Mensaje> = try {
val respuesta = api.mensajes(
canal = canalId,
despuesDe = params.key,
limite = params.loadSize,
)
LoadResult.Page(
data = respuesta.mensajes,
prevKey = null, // solo avanzamos
nextKey = respuesta.cursorSiguiente,
)
} catch (e: IOException) {
LoadResult.Error(e)
}
}
Clave por desplazamiento
Permite saltar a cualquier punto y calcular la clave de refresco con aritmética. Sufre duplicados y saltos si el conjunto cambia mientras se navega.
Clave por cursor
Estable frente a inserciones y barata en el servidor. No permite salto arbitrario, lo que complica el refresco desde el medio de la lista.
La clave de refresco y la invalidación
Aquí está la parte que casi nadie entiende a la primera. Cuando la lista debe reconstruirse —porque los datos cambiaron, porque el usuario tiró para refrescar o porque el proceso volvió a la vida— la librería no empieza necesariamente por la primera página: pregunta a tu fuente por qué clave debe usar para reanudar cerca de donde el usuario estaba. Ese es el cometido del segundo método.
Recibe un estado de paginación que contiene las páginas ya cargadas y, sobre todo, la posición de anclaje: el índice del último elemento al que la interfaz accedió, que es la mejor aproximación disponible a dónde está mirando el usuario. A partir de ahí hay que llegar a una clave.
override fun getRefreshKey(state: PagingState<Int, Articulo>): Int? {
val ancla = state.anchorPosition ?: return null
val pagina = state.closestPageToPosition(ancla) ?: return null
return pagina.prevKey?.plus(1) ?: pagina.nextKey?.minus(1)
}
La expresión final desconcierta hasta que se entiende su lógica. No se dispone de la clave de la página encontrada, porque una página guarda las claves de sus vecinas y no la suya propia. Así que se reconstruye desde cualquiera de las dos que sí existen, y se contemplan ambos casos porque en los extremos una de ellas es nula. Devolver null es perfectamente legítimo y significa recargar desde el principio, que es lo correcto cuando aún no hay ancla.
Con claves de cursor esta reconstrucción aritmética es imposible, y hay dos salidas honestas: devolver siempre null y aceptar que el refresco vuelve al principio, que en un hilo de mensajes suele ser justo lo que el usuario espera, o guardar el cursor de cada página en una estructura auxiliar indexada por identificador de elemento.
Invalidar significa declarar que esta instancia de la fuente ya no sirve. La librería la descarta, pide una nueva a la fábrica y ejecuta sobre ella una carga de refresco usando la clave calculada. Por eso la fábrica debe crear siempre una instancia nueva: reutilizar una invalidada produce una lista que nunca se actualiza. Room invalida sola cuando cambia una tabla observada; en una fuente propia, la invalidación la disparas tú.
flowchart TD
A[Cambio en los datos] --> B[Se invalida la PagingSource actual]
B --> C[La fabrica crea una instancia nueva]
C --> D[Se pide getRefreshKey con la posicion de anclaje]
D --> E{Hay ancla}
E -->|No| F[Carga inicial desde el principio]
E -->|Si| G[Carga de refresco cerca del usuario]
G --> H[La ventana se reconstruye sin perder el sitio]
style H fill:#a6e3a1,color:#11111bDel Pager al flujo
La última pieza es el orquestador. Recibe una configuración y una fábrica de fuentes, y expone un flujo de PagingData. La configuración es donde se afinan las tres magnitudes que gobiernan el comportamiento: el tamaño de página, la distancia de anticipación a la que se dispara la carga siguiente y el número máximo de elementos retenidos en memoria.
class ArticulosViewModel(api: ArticulosApi) : ViewModel() {
val articulos: Flow<PagingData<Articulo>> = Pager(
config = PagingConfig(
pageSize = 20,
prefetchDistance = 10,
maxSize = 200,
enablePlaceholders = false,
),
pagingSourceFactory = { ArticulosPagingSource(api) },
).flow.cachedIn(viewModelScope)
}
La llamada final no es opcional y merece una explicación. Sin ella, el flujo es frío: cada nueva recolección crea una fuente desde cero, de modo que un cambio de configuración o una simple recomposición que reinicie la recolección haría empezar de nuevo por la primera página. Al anclarlo al ámbito del modelo de vista, el flujo se vuelve compartido y con estado, sobrevive a la rotación y admite múltiples consumidores. Es también el punto exacto donde se traza la frontera: lo que va antes se calcula una vez, lo que va después se recalcula por consumidor.
El límite máximo de elementos es la característica que de verdad acota la memoria, y es opcional por una razón: al activarlo, las páginas alejadas se descartan y, si el usuario vuelve sobre ellas, se recargan. Debe ser al menos el doble de la distancia de anticipación más el tamaño de página, o la librería entrará en un ciclo de descartar y recargar lo mismo. Con marcadores de posición desactivados, además, ese descarte cambia el tamaño aparente de la lista, cosa que la interfaz debe tolerar.
Los marcadores de posición merecen su propio criterio. Activarlos exige que la fuente conozca el total de elementos y hace que la lista tenga desde el principio su tamaño final, con huecos que se rellenan; a cambio, la barra de desplazamiento es honesta y no hay saltos de posición. Desactivarlos es lo habitual cuando el total se desconoce, que es el caso de casi todas las APIs de cursor.
Conviene mirar de frente lo que realmente es una clave de paginación, porque el nombre sugiere algo mucho más inocente de lo que es. Un identificador de página parece un índice, una coordenada, un dato interno del mecanismo. No lo es. Una clave es una afirmación que tu programa hace sobre el estado de un sistema remoto en un instante pasado, y que pretende seguir siendo válida en un instante futuro. Cuando guardas que la página siguiente es la siete, lo que estás afirmando es que el orden, el filtrado y el contenido del conjunto en el servidor serán los mismos dentro de treinta segundos que lo eran cuando llegó la página seis. Esa afirmación es falsa por defecto en cualquier sistema con más de un escritor, y toda la fenomenología molesta de la paginación —el elemento duplicado al cambiar de página, el que desaparece sin haber sido leído, la lista que salta al volver de segundo plano— no son fallos de la librería sino la consecuencia observable de que esa promesa se rompió. De ahí se sigue el criterio que ordena todas las decisiones de esta lección: el desplazamiento numérico codifica una posición en una ordenación que el servidor puede alterar sin avisarte, mientras que el cursor codifica una identidad, y una identidad sigue significando lo mismo aunque el conjunto entero se reordene alrededor. Por eso las dos opciones no son equivalentes con distinta ergonomía, sino dos niveles distintos de compromiso con la verdad. La primera es cómoda porque finge que el mundo está quieto; la segunda es incómoda porque acepta que no lo está. Y el corolario práctico es que la elección de la clave no es un detalle de la capa de red que puedas delegar en quien escribió la API: es la decisión que determina qué anomalías verá tu usuario y cuáles no podrás arreglar después por mucho código que añadas encima.
- Implementa una fuente de desplazamiento contra una API real y comprueba que el tamaño solicitado en la carga inicial es mayor que el configurado.
- Provoca un error de red a mitad de la lista y verifica que el resultado de error llega a la interfaz sin derribar el flujo.
- Escribe
getRefreshKeyreconstruyendo la clave desde las vecinas, rota el dispositivo a mitad de la lista y comprueba que el usuario no vuelve al principio. - Convierte la misma pantalla a claves de cursor y razona qué has ganado y qué has perdido en el refresco.
- Fija un límite máximo de elementos y observa con el perfilador cómo el montículo deja de crecer al recorrer mil elementos.