Paginación con Paging 3
Al terminar: Servir la lista por páginas desde commonMain sin escribir el paginador a mano.
PokeAPI tiene 1.351 Pokémon. Pedirlos todos de golpe son mil trescientas cincuenta y una peticiones de detalle y una lista que tarda minutos en aparecer. Al terminar esta lección la app servirá esa lista de veinte en veinte, desde commonMain, y quien escriba la pantalla no tendrá que llevar ningún contador.
Lo que se monta son dos ficheros pequeños —de dónde sale cada página y quién las pide—, más la librería que hace el resto. Los tests que comprueban que todo esto funciona están en la lección siguiente, Probar la paginación.
Las rutas de esta lección son las de la vista Project, la que enseña las carpetas tal como están en el disco. Si el panel de la izquierda todavía dice «Android», cámbialo con el selector de arriba del panel.
Paso 1 · Las cinco cosas que no vas a escribir
Este paso es solo idea y un ejemplo para leer: no toca el proyecto. Los ficheros empiezan en el paso 2.
Paginar a mano parece un bucle con un contador. Son cinco cosas, y las cinco son bugs conocidos: el contador de desplazamiento, detectar el final de la lista, el estado de «cargando más», los reintentos y la deduplicación (no pedir dos veces la misma página cuando el usuario sube y baja rápido).
Lo que la app sí pone es dónde se corta cada página. PokeAPI pagina por desplazamiento (offset: cuántos elementos saltarse desde el principio), no por cursor (una marca que el servidor da en cada respuesta para continuar desde ahí). Así que la clave de una página es simplemente cuántos elementos hay antes de ella.
Primero, a mano: lo que Paging te ahorra
Paginar a mano parece fácil: un contador que sube y una función «pídeme más». Este ejemplo es una biblioteca con 45 libros y esa función, y sirve para ver el primer problema. Es para leer, no lo escribas en el proyecto:
class ListaAMano(private val libros: List<String>) {
private var desde = 0 // el contador de desplazamiento
val enPantalla = mutableListOf<String>() // lo que ya se ve
suspend fun pedirMas() {
val inicio = desde // desde dónde se pide esta vez
println("piden 10 desde el $inicio")
delay(100) // la «red» tarda un poco
val pagina = libros.drop(inicio).take(10) // los 10 libros que tocan
enPantalla += pagina
desde = inicio + pagina.size // el contador sube cuando llega la respuesta
}
}
fun main() = runBlocking {
val lista = ListaAMano((1..45).map { "Libro $it" })
launch { lista.pedirMas() } // la pantalla avisa dos veces seguidas:
launch { lista.pedirMas() } // «ya casi llegas al final»
delay(300)
println("en pantalla: ${lista.enPantalla.size} libros; distintos: ${lista.enPantalla.distinct().size}")
}Salida real:
piden 10 desde el 0
piden 10 desde el 0
en pantalla: 20 libros; distintos: 10Las dos peticiones salieron antes de que llegara ninguna respuesta, así que las dos pidieron «desde el 0» y la lista enseña los diez primeros libros dos veces. Se arregla con una bandera cargando que diga «ya hay una petición en marcha». Y eso es solo el primero de los problemas: faltan saber cuándo se acabó la lista, qué hacer si la red falla y cómo reintentar. Ese trabajo ya está hecho en la librería.
Una fuente de páginas, con una biblioteca
Antes de escribir la de los Pokémon, la misma idea con datos que no tienen nada que ver con el proyecto: una biblioteca con 45 libros. Tiene tres piezas nuevas, y las tres se explican en el comentario:
PagingSource<Key, Value>es la clase que sabe sacar una página.Keyes el tipo de la clave que identifica cada página (aquí unInt: cuántos libros hay antes) yValue, lo que contiene cada página (aquí un texto por libro).loades la función que Paging llama cada vez que necesita una página. Recibeparams:params.keyes la clave de la página que piden (nullla primera vez) yparams.loadSize, cuántos elementos quieren.LoadResult.Pagees la respuesta.LoadResultes el tipo del resultado yPagees una de sus formas (la otra,Error, la verás en el paso 3), por eso se escribe con el punto. Lleva los datos de la página y dos claves,prevKey(la página de antes) ynextKey(la de después). Unnullen una clave significa «no hay».
Es un ejemplo para leer, no lo escribas en el proyecto:
// Una biblioteca con 45 libros: «Libro 1», «Libro 2»… La clave de cada página
// es cuántos libros hay antes de ella (el desplazamiento).
class LibrosPagingSource : PagingSource<Int, String>() { // clave: Int, contenido: String
private val todos = (1..45).map { "Libro $it" } // los 45 libros
// Paging llama aquí cada vez que quiere una página.
override suspend fun load(params: LoadParams<Int>): LoadResult<Int, String> {
val desde = params.key ?: 0 // la primera vez no hay clave: desde el 0
val hasta = minOf(desde + params.loadSize, todos.size) // sin pasar del último libro
println("piden ${params.loadSize} desde el $desde")
val pagina = todos.subList(desde, hasta) // los libros de esta página
return LoadResult.Page(
data = pagina,
prevKey = null, // esta biblioteca solo avanza
nextKey = if (hasta == todos.size) null else hasta, // null: no hay más páginas
)
}
// Dónde empezar si hay que recargar. Aquí, siempre desde el principio.
override fun getRefreshKey(state: PagingState<Int, String>): Int? = null
}
// Cómo se recorre. Va dentro de un test (`runTest`, el que ya conoces de las
// corrutinas): así `asSnapshot`, que espera, puede ejecutarse.
@Test
fun recorreLosLibros() = runTest {
// El `Pager` une la fuente con una configuración: páginas de 10, y avisar
// cuando queden 2 libros por delante del final de lo cargado.
val pager = Pager(PagingConfig(pageSize = 10, prefetchDistance = 2, initialLoadSize = 10)) { LibrosPagingSource() }
// `asSnapshot` hace de pantalla: baja hasta el libro 45 (índice 44) y devuelve lo que ha visto.
val enPantalla = pager.flow.asSnapshot { scrollTo(index = 44) }
println("en pantalla: ${enPantalla.size} libros, del «${enPantalla.first()}» al «${enPantalla.last()}»")
}Salida real:
piden 10 desde el 0
piden 10 desde el 10
piden 10 desde el 20
piden 10 desde el 30
piden 10 desde el 40
en pantalla: 45 libros, del «Libro 1» al «Libro 45»Nadie escribió un bucle. Paging fue pidiendo páginas conforme asSnapshot bajaba, y después de la que empieza en el 40 no pidió más: ahí hasta llegó a 45, así que su nextKey fue null. (initialLoadSize = 10 hace que la primera carga también sea de diez; qué pasa si no se pone, lo verás en la lección siguiente.)
Paging también guarda el estado de cada carga en un LoadState: cargando, terminado o con error. Aún no lo necesitas; lo usarás en El puente de paginación para iOS, donde hay que dibujar esos tres casos.
Paso 2 · Instala Paging (dos artefactos)
Objetivo: que shared pueda paginar desde commonMain y que sus tests puedan recorrer la lista como lo haría una pantalla.
1 · Qué instalas y por qué ahora. Paging 3 es la librería de AndroidX que pagina listas. Se reparte en varios paquetes (en Gradle se llaman artefactos): paging-common (la lógica, sin nada de Android), paging-testing (ayudas para probarla) y paging-compose (el adaptador que conecta la lista con la pantalla de Android, que se instala con la pantalla en La lista en Android). Aquí van los dos primeros, porque el paso 3 ya escribe una clase que hereda de PagingSource.
2 · La versión, en [versions]. Un solo número para toda la familia de Paging. Abre gradle/libs.versions.toml y añade al final de [versions], antes de [libraries]:
paging = "3.5.1"3 · Las librerías, en [libraries]. Al final de la sección, antes de [plugins]:
# La lógica de paginación: PagingSource, Pager, PagingData. Vale en todas las plataformas.
androidx-paging-common = { module = "androidx.paging:paging-common", version.ref = "paging" }
# asSnapshot: recorre un flujo de páginas en un test, como haría la pantalla.
androidx-paging-testing = { module = "androidx.paging:paging-testing", version.ref = "paging" }4 · Las dependencias, cada una en su sitio. Abre shared/build.gradle.kts y, dentro de commonMain.dependencies { … }, justo debajo de api(libs.koin.core), añade la línea. Es api y no implementation (la diferencia está en La primera dependencia): PagingData aparece en la firma pública del ViewModel, así que forma parte de lo que shared ofrece hacia fuera.
// api: PagingData asoma en la cara pública del ViewModel.
api(libs.androidx.paging.common)En el mismo fichero, dentro de commonTest.dependencies { … }, debajo de las que ya hay:
// Solo para tests, y en commonTest: corre en las tres plataformas.
implementation(libs.androidx.paging.testing)5 · Sync Now, y comprueba. Pulsa Sync Now; sin rojo. Después, en la Terminal de Android Studio (cd ~/Pokedex-KMP):
./gradlew :shared:dependencies --configuration iosSimulatorArm64CompileKlibraries | grep paging+--- androidx.paging:paging-common:3.5.1
| \--- androidx.paging:paging-common-iossimulatorarm64:3.5.1Una línea tuya, y Gradle ha escogido la variante del simulador. Dos errores típicos:
- Un alias mal escrito.
Unresolved reference 'paging'enbuild.gradle.kts: compara con el catálogo, leyendo los guiones como puntos. - Sin
paging-commonencommonMain. Sync pasa, y falla al compilar el paso 3:Unresolved reference 'paging'.en cadaimport androidx.paging…yUnresolved reference 'PagingData'más abajo. Es el síntoma de una dependencia que no está en el source set donde se usa.
Para el resto, la caja «Si Sync sale en rojo» de Los ficheros de Gradle de tu proyecto.
Paso 3 · De dónde salen las páginas
El archivo. PokemonPagingSource.kt es la fuente de las páginas: cuando Paging pide «la que empieza en X», esta clase se lo pide al repositorio. Va en commonMain, en un paquete nuevo feature.list.
Créalo. En la vista Project, clic derecho sobre la carpeta shared → New → File, con la ruta desde la carpeta del módulo:
src/commonMain/kotlin/com/sebastiannarvaez/pokedex/feature/list/PokemonPagingSource.ktEs la misma clase de los libros, con el repositorio en lugar de la lista. Escríbela entera:
package com.sebastiannarvaez.pokedex.feature.list
import androidx.paging.PagingSource
import androidx.paging.PagingState
import com.sebastiannarvaez.pokedex.data.PokemonRepository
import com.sebastiannarvaez.pokedex.domain.Pokemon
import kotlinx.coroutines.CancellationException
// De dónde salen las páginas. Paging le pregunta «dame la página que empieza
// en X» y esta clase se lo pide al repositorio.
// `PagingSource<Int, Pokemon>`: la clave de cada página es un `Int` (el
// desplazamiento: cuántos Pokémon hay antes) y lo que trae cada página, `Pokemon`.
internal class PokemonPagingSource(
private val repository: PokemonRepository,
) : PagingSource<Int, Pokemon>() {
// Paging la llama cada vez que necesita una página.
override suspend fun load(params: LoadParams<Int>): LoadResult<Int, Pokemon> {
// La primera vez no hay clave (`null`): se empieza en el 0.
val offset = params.key ?: 0
// Cuántos Pokémon se piden esta vez: lo decide Paging, no nosotros.
val limit = params.loadSize
// `try` devuelve un valor: la página si todo va bien, el error si algo falla.
return try {
val pokemon = repository.page(limit = limit, offset = offset)
LoadResult.Page(
data = pokemon,
// La lista solo avanza: nunca se pide una página anterior. `null`
// le dice a Paging que por arriba no hay nada que cargar.
prevKey = null,
// Página siguiente. Si ésta ha venido corta, se acabó la lista: `null`
// apaga la petición de la siguiente y Paging deja de pedir.
nextKey = if (pokemon.size < limit) null else offset + limit,
)
} catch (e: CancellationException) {
// Que se cancele la carga (el usuario se va de la pantalla) no es un
// fallo de red: se relanza para que no acabe como un error de la lista.
throw e
} catch (e: Throwable) {
// Paging convierte esto en `LoadState.Error`, que la pantalla pinta con su
// botón de reintentar. No se traga nada.
LoadResult.Error(e)
}
}
// Dónde empezar al recargar (tirar para refrescar, reintentar tras un fallo):
// siempre desde el principio. Como la lista solo avanza y no carga hacia
// atrás, empezar a mitad dejaría fuera todo lo anterior.
override fun getRefreshKey(state: PagingState<Int, Pokemon>): Int? = null
}Tres decisiones marcan casi todo el comportamiento:
nextKey = nullcuando la página viene corta. Eso es lo que apaga la petición siguiente. Sin esa condición, Paging seguiría pidiendo páginas vacías hasta el fin de los tiempos.prevKey = nullygetRefreshKeydevolviendonull. La lista solo avanza: nunca carga hacia atrás. Por eso al recargar vuelve siempre al principio, y la lista queda completa, sin huecos. (Una recarga que empezara a mitad dejaría fuera todo lo de antes, porque nadie lo pediría.)- El
catchdevuelveLoadResult.Error, no relanza. La pantalla necesita un estado con botón de reintentar, no una corrutina tumbada. La única excepción es la cancelación, que sí se relanza. Cómo se convierte eseThrowableen un mensaje útil llega más adelante, en Errores que se pueden contar, tras probar la paginación en Probar la paginación.
Paso 4 · Quién pide las páginas
El archivo. PokemonListViewModel.kt crea el Pager, la pieza que une la fuente con una configuración y ofrece el flujo de páginas a la pantalla. Es el ViewModel de la lista, como el de inicio de El ViewModel compartido. Clic derecho sobre shared → New → File:
src/commonMain/kotlin/com/sebastiannarvaez/pokedex/feature/list/PokemonListViewModel.ktpackage com.sebastiannarvaez.pokedex.feature.list
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import androidx.paging.Pager
import androidx.paging.PagingConfig
import androidx.paging.PagingData
import androidx.paging.cachedIn
import com.sebastiannarvaez.pokedex.data.PokemonRepository
import com.sebastiannarvaez.pokedex.domain.Pokemon
import kotlinx.coroutines.flow.Flow
// El ViewModel de la lista. `internal constructor` significa que solo se puede
// construir dentro del módulo `shared` (el repositorio que recibe también es
// `internal`); la clase es pública, así que las apps la usan sin construirla
// ellas: la crea Koin.
class PokemonListViewModel internal constructor(
// Sin `val`: es solo un parámetro del constructor, no una propiedad de la clase.
// La lambda de `pagingSourceFactory` lo recuerda mientras haga falta.
repository: PokemonRepository,
) : ViewModel() {
// El flujo de páginas que la pantalla recoge. Cada emisión es un `PagingData`.
val pokemon: Flow<PagingData<Pokemon>> = Pager(
config = PagingConfig(
// Veinte por página: cada una cuesta una petición de listado más una
// por Pokémon, así que subirlo multiplica el tráfico.
pageSize = PAGE_SIZE,
// Cuántos quedan por delante antes de pedir la siguiente página.
prefetchDistance = 5,
// Sin huecos reservados para lo que aún no se ha cargado.
enablePlaceholders = false,
),
// Cómo crear la fuente de páginas; Paging la llama cuando la necesita
// (por ejemplo, al refrescar).
pagingSourceFactory = { PokemonPagingSource(repository) },
// `.flow` es el flujo de `PagingData`; `cachedIn` lo guarda mientras
// viva el ViewModel (explicado justo debajo).
).flow.cachedIn(viewModelScope)
// Lo que va dentro de `companion object` se llama con el nombre de la clase,
// sin crear ninguna instancia: `PokemonListViewModel.PAGE_SIZE`.
companion object {
// `const`: el valor se fija al compilar.
const val PAGE_SIZE = 20
}
}Lo nuevo de este fichero, por partes:
PagingDataes el paquete que la pantalla recibe: lo cargado hasta ahora, listo para pintar. Cada vez que se refresca la lista llega uno nuevo.PagingConfigson los ajustes del carrusel.pageSizees cuántos elementos tiene una página;prefetchDistance, cuántos elementos antes del final de lo cargado se pide la siguiente página;enablePlaceholders = false, que no se reserven huecos vacíos para lo que aún no ha llegado. (initialLoadSize, el tamaño de la primera carga, se queda con su valor por defecto; en la lección siguiente verás qué cuesta eso.)pagingSourceFactoryes una función que fabrica la fuente. Paging la llama cada vez que necesita una nueva, por ejemplo después de un refresco.
Traducido: submitData es lo que hacen los adaptadores de pantalla (en Android, paging-compose; en iOS, el presentador —una clase compartida que escucha el flujo y se lo da a SwiftUI— de El puente de paginación para iOS) para recibir cada PagingData. Sin cachedIn, cada vez que alguien recoge el flujo se carga la lista desde cero, y si el mismo PagingData se entrega dos veces falla con Attempt to collect twice from pageEventFlow … Did you forget to call Flow<PagingData<*>>.cachedIn(coroutineScope)?. Con cachedIn, todos comparten una sola carga.
Al grafo. Abre shared/src/commonMain/kotlin/com/sebastiannarvaez/pokedex/di/Modules.kt (en la vista Project, o con Navigate → File…). Debajo de viewModelOf(::HomeViewModel), añade la línea y el import con Option+Enter.
// El ViewModel de la lista; Koin le da el PokemonRepository que ya está registrado.
viewModelOf(::PokemonListViewModel)Compila: ./gradlew :shared:jvmTest. El test del grafo (ModulesTest) sigue en verde. Ojo con lo que garantiza: comprueba las piezas registradas con constructor público, pero no mira los constructores internal, como el de este ViewModel.
Punto de control
- Sync Now sin rojo, y
./gradlew :shared:dependencies --configuration iosSimulatorArm64CompileKlibraries | grep pagingimprime las dos líneas depaging-common(la segunda, coniossimulatorarm64). ./gradlew :shared:jvmTesttermina enBUILD SUCCESSFULcon los mismos tests de antes: 22 en la JVM con tus ejercicios (16 sin ellos). Todavía no has escrito ninguno de la lista, yModulesTestsigue en verde conPokemonListViewModelregistrado. (Los «ejercicios» de las lecciones suman tests: cuenta los de los que hayas hecho, así que si te saltaste alguno tendrás menos, y nunca menos que los «sin ellos».)./gradlew :androidApp:assembleDebugtermina enBUILD SUCCESSFUL(la app no cambia en esta lección).- El catálogo tiene
paging = "3.5.1"y dos librerías de Paging;paging-commonesapiencommonMainypaging-testingestá encommonTest. PokemonPagingSourcedevuelvenextKey = nullcuando la página viene corta,prevKey = nullsiempre, yLoadResult.Errorcuando el repositorio lanza.PokemonListViewModeltermina en.cachedIn(viewModelScope).
Comprueba lo que entendiste
5 preguntas · necesitas 4 para completar la lección
1.¿Qué apaga la petición de la página siguiente en
PokemonPagingSource?2.¿Por qué
loadcaptura el error y devuelveLoadResult.Erroren vez de dejar que la excepción suba?3.¿Qué hace
cachedIn(viewModelScope)y por qué no se puede quitar?4.PokeAPI pagina por desplazamiento. En
PokemonPagingSource, ¿qué es la clave (params.key) de una página?5.En
PokemonPagingSource,prevKeyes siemprenullygetRefreshKeydevuelvenull. ¿Qué consecuencia tiene al tirar para refrescar a mitad de la lista?