El repositorio con dos fuentes
Al terminar: Decidir cuándo la copia caduca, montar el repositorio con el Pager que lee de la base y cambiar el ViewModel de la lista.
El mediador ya sabe llenar la base, pero la lista de la app sigue pidiendo las páginas directamente a la red. Esta lección las conecta: al terminar, la pantalla lee solo de la base, el mediador la llena cuando hace falta, y la copia que se guarda decide por sí sola cuándo está demasiado vieja y hay que pedirla otra vez.
Hay tres movimientos: un initialize() en el mediador para decidir cuándo vale la copia, un repositorio que une las dos fuentes con un Pager (y su línea en Koin), y un ViewModel que se queda en tres líneas, con la retirada de la fuente de páginas de red, que ya no se usa. Los tests de todo esto están en Los tests del repositorio, y lo verás funcionar en el móvil, con Logcat y en modo avión, en La lista sin red en Android. Aquí solo comprobarás que siguen en verde los tests de la JVM y de Android host; los de iOS se rompen a propósito, como verás en el paso 3.
Para saber que no rompes nada: ./gradlew :shared:jvmTest :shared:testAndroidHostTest :shared:iosSimulatorArm64Test da 62 / 40 / 42 tests (JVM / Android host / iOS) al empezar, sin contar los de la carpeta ejercicios.
El concepto · ¿Cuándo vale la copia?
Con la lista leyendo de la base, aparece una pregunta nueva: cada vez que abres la app, ¿hay que ir a la red? Si siempre vas, la app abre despacio y gasta datos para traer lo mismo que ya tenía. Si nunca vas, la lista puede quedarse vieja para siempre. La respuesta razonable es una regla: la copia vale un tiempo.
Ese tiempo se llama caducidad de la copia, como la fecha de la leche. Mientras la copia no ha caducado, se usa tal cual y no se toca la red. Cuando ha caducado (o cuando nunca hubo copia), se pide de nuevo. Para decidirlo hace falta saber cuándo se guardó: eso es el campo actualizadoEn de la tabla de claves de La base como fuente de verdad, el que ya escribe el mediador.
Paging deja decidirlo con una función del mediador que se llama una sola vez, al abrir la lista: initialize(). Devuelve una de dos respuestas (InitializeAction):
SKIP_INITIAL_REFRESH: «salta el primer refresco». Se enseña lo guardado y no se pide nada.LAUNCH_INITIAL_REFRESH: «lanza el primer refresco». Se pide la lista otra vez desde el principio.
Ejemplo comentado, con un catálogo de libros
La misma regla con datos que no tienen nada que ver con el proyecto: el catálogo de una librería guardado en el móvil. Es para leer, no lo escribas en el proyecto.
Una pieza es nueva: inWholeMilliseconds pasa una Duration a milisegundos, que es en lo que se guardan las fechas. El enum class (aquí, las dos respuestas posibles) es el mismo del ejemplo de las frutas de El RemoteMediator: la red llena la base.
import kotlin.time.Duration
import kotlin.time.Duration.Companion.hours
enum class AlAbrir { USAR_LO_GUARDADO, PEDIR_DE_NUEVO }
// Cuándo se guardó el catálogo (en milisegundos), qué hora es y cuánto vale una copia.
fun alAbrir(guardadoEn: Long?, ahora: Long, caducidad: Duration): AlAbrir {
if (guardadoEn == null) return AlAbrir.PEDIR_DE_NUEVO // nunca se guardó nada
val edad = ahora - guardadoEn // cuánto lleva guardada
return if (edad < caducidad.inWholeMilliseconds) AlAbrir.USAR_LO_GUARDADO
else AlAbrir.PEDIR_DE_NUEVO
}
fun main() {
val hora = 1.hours.inWholeMilliseconds
val caducidad = 12.hours
println("sin copia: ${alAbrir(null, 30 * hora, caducidad)}")
println("copia de hace 2 h: ${alAbrir(28 * hora, 30 * hora, caducidad)}")
println("copia de hace 12 h: ${alAbrir(18 * hora, 30 * hora, caducidad)}")
println("copia de hace 30 h: ${alAbrir(0, 30 * hora, caducidad)}")
}Salida real:
sin copia: PEDIR_DE_NUEVO
copia de hace 2 h: USAR_LO_GUARDADO
copia de hace 12 h: PEDIR_DE_NUEVO
copia de hace 30 h: PEDIR_DE_NUEVOFíjate en el caso de las doce horas exactas: ya no vale. El < estricto dice «menos de doce horas», no «doce o menos». Es la misma decisión que tomará el mediador.
Paso 1 · initialize() en el mediador
Se modifica el mediador de la lección anterior. Ábrelo con Navigate → File… (Cmd+Shift+O) y escribe PokemonRemoteMediator. Son cuatro retoques; el resto del fichero no cambia.
1 · Los dos import nuevos, entre aEntidad y CancellationException:
import kotlin.time.Duration
import kotlin.time.Duration.Companion.hours2 · Un parámetro más en el constructor, debajo de ahora. Tiene valor por defecto (el de la constante que se define en el retoque 4), así que quien construya el mediador sin pasarlo usa las doce horas, y un test podrá cambiarlo:
internal class PokemonRemoteMediator(
private val red: PokemonRepository,
private val db: PokedexDatabase,
/** El reloj, inyectado como en los favoritos: los tests fijan la hora. */
private val ahora: () -> Long,
/** Cuanto vale una copia guardada antes de pedirla de nuevo al abrir. */
private val caducidad: Duration = CADUCIDAD,
) : RemoteMediator<Int, PokemonEntity>() {3 · La función initialize, justo encima de load. Es la regla del ejemplo, con la base de verdad: lee la fila de claves, calcula la edad de la copia y elige:
/**
* Se llama una vez, al abrir la lista: ¿se pide la primera pagina a la red
* o basta con lo guardado?
*
* Con una copia reciente, se salta (`SKIP_INITIAL_REFRESH`): la lista sale
* al instante, con o sin red. Con una copia vieja o sin copia, se refresca
* (`LAUNCH_INITIAL_REFRESH`); si entonces no hay red, lo guardado se sigue
* viendo y la pantalla avisa.
*/
override suspend fun initialize(): InitializeAction {
val claves = clavesDao.get(LISTA_POKEMON) ?: return InitializeAction.LAUNCH_INITIAL_REFRESH
val edad = ahora() - claves.actualizadoEn
return if (edad < caducidad.inWholeMilliseconds) {
InitializeAction.SKIP_INITIAL_REFRESH
} else {
InitializeAction.LAUNCH_INITIAL_REFRESH
}
}La primera línea cubre el caso «nunca se guardó nada»: si no hay fila de claves, el ?: return … sale ya de la función con «refresca». Es el guardadoEn == null del ejemplo del catálogo.
4 · La constante, al final de la clase, después de load. Es un companion object, como el de PAGE_SIZE que viste en Paginación con Paging 3: el rincón de la clase para lo que no pertenece a un ejemplar concreto, y se lee desde fuera como PokemonRemoteMediator.CADUCIDAD:
companion object {
/** Medio dia: la lista de Pokemon casi nunca cambia. */
val CADUCIDAD: Duration = 12.hours
}Fíjate en una diferencia: PAGE_SIZE era const val y esta es solo val. Un const val solo admite valores que el compilador conoce de antemano (números, textos…); una Duration se construye ejecutando 12.hours, así que no puede ser const.
Doce horas es una decisión, no una ley: la lista de Pokémon casi nunca cambia, así que cualquier valor razonable sirve. Con otra lista (noticias, precios) sería mucho menos. Y dos consecuencias para cuando lo veas funcionar:
- Con una copia de menos de doce horas, la app abre la lista sin una sola petición, con o sin conexión.
- Con la copia caducada se refresca al abrir. Si entonces no hay red, el refresco falla, lo guardado no se toca y se sigue viendo.
Paso 2 · El repositorio con las dos fuentes
El concepto. Ya tienes dos clases que hablan con cosas distintas: PokemonRepository con la red y los DAO con la base. Falta la que las reúne y le ofrece a la pantalla una única cosa: un flujo de páginas. Esa clase es PokemonListRepository. Dentro monta el Pager con las dos piezas que necesita:
pagingSourceFactory: de dónde lee la pantalla. Siempre de la base (elPagingSourcede Room).remoteMediator: quién llena la base cuando se acaba lo guardado. El mediador de la lección anterior.
Es otra clase y no un método de PokemonRepository por una razón práctica: cambiar el constructor de ese repositorio habría roto los tests del detalle, de la búsqueda y del propio repositorio. PokemonRepository se queda como «la fuente remota».
Créalo. Clic derecho sobre la carpeta data (dentro de shared/src/commonMain/kotlin/com/sebastiannarvaez/pokedex/) → New → Kotlin Class/File → PokemonListRepository, Class, y sustituye lo generado por:
package com.sebastiannarvaez.pokedex.data
import androidx.paging.ExperimentalPagingApi
import androidx.paging.Pager
import androidx.paging.PagingConfig
import androidx.paging.PagingData
import androidx.paging.map
import com.sebastiannarvaez.pokedex.data.local.PokedexDatabase
import com.sebastiannarvaez.pokedex.data.local.aDominio
import com.sebastiannarvaez.pokedex.domain.Pokemon
import kotlin.time.Clock
import kotlin.time.ExperimentalTime
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
/**
* La lista de Pokemon, con sus dos fuentes: la base y la red.
*
* Las dos no estan al mismo nivel. La pantalla lee **solo** de la base, que es
* la fuente de verdad; la red (`PokemonRepository`) solo sirve para llenarla,
* y eso lo hace el mediador. Por eso, sin conexion, la lista sigue ahi: lo
* que llega a la pantalla nunca vino directamente de la red.
*/
@OptIn(ExperimentalTime::class)
internal class PokemonListRepository(
private val red: PokemonRepository,
private val db: PokedexDatabase,
private val ahora: () -> Long = { Clock.System.now().toEpochMilliseconds() },
) {
@OptIn(ExperimentalPagingApi::class) // RemoteMediator sigue marcado como experimental
fun pokemon(): Flow<PagingData<Pokemon>> = Pager(
config = PagingConfig(
// Veinte por pagina: cada una cuesta una peticion de listado mas
// una por Pokemon, asi que subirlo multiplica el trafico.
pageSize = PAGE_SIZE,
// Cuantos quedan por delante antes de pedir la siguiente pagina.
prefetchDistance = 5,
enablePlaceholders = false,
),
// Quien llena la base cuando se acaba lo guardado.
remoteMediator = PokemonRemoteMediator(red, db, ahora),
// De donde lee la pantalla: siempre de la base. Una funcion y no un
// objeto, porque Room crea un PagingSource nuevo cada vez que la tabla
// cambia y el anterior queda invalidado.
pagingSourceFactory = { db.pokemonDao().pagingSource() },
).flow.map { pagina -> pagina.map { it.aDominio() } }
companion object {
const val PAGE_SIZE = 20
}
}Lo que conviene entender, de arriba abajo:
ahoracon valor por defecto. En la app real la hora sale del reloj del sistema (Clock.System.now()); los tests pasan una hora suya. Es el mismo reloj por parámetro deFavoritesRepository. El@OptIn(ExperimentalTime::class)de encima de la clase es inofensivo: con la versión de Kotlin de este proyectoClockya no lo exige (lo viste en Favoritos en el núcleo), así que compila igual con él o sin él.pagingSourceFactoryes una función, no un objeto. ElPagingSourcede Room avisa cuando la tabla cambia y, al hacerlo, se invalida: ya no vale y Paging pide uno nuevo. Por eso recibe una fábrica y no una única instancia.- Dos
map, uno dentro del otro. El de fuera,.flow.map { … }, es elmapde los flujos de Flow frío: se aplica a cadaPagingDataque sale. El de dentro,pagina.map { it.aDominio() }, es el de Paging (import androidx.paging.map): transforma cada elemento de esa página, de fila de la base (PokemonEntity) aPokemondel dominio. La pantalla recibe el mismo tipo que antes y no sabe que ahora hay una base de por medio. PAGE_SIZEse muda aquí. Antes vivía enPokemonListViewModel; el paso siguiente lo quita de allí. Esconst valporque es un número fijo.
Al grafo. Abre shared/src/commonMain/kotlin/com/sebastiannarvaez/pokedex/di/Modules.kt. Añade el import (Option+Enter sobre el nombre en rojo) y, debajo de single { FavoritesRepository(get(), get()) }, la línea que registra el repositorio. Koin le entrega la red y la base que ya están registradas; el parámetro del reloj, al tener valor por defecto, no se pasa.
// La lista con sus dos fuentes: la red (PokemonRepository) y la base.
single { PokemonListRepository(get(), get()) }¿Por qué single? PokemonListRepository no guarda nada propio: cada llamada a pokemon() monta su Pager, así que con factory (el registro que crea uno nuevo cada vez, de Koin: un solo grafo en commonMain) funcionaría igual. Es single como los demás repositorios del grafo, para no crear uno nuevo en cada petición. Lo que sí tiene que ser único es la base (single { createDatabase(get()) }): el mediador escribe y el PagingSource lee en el mismo objeto PokedexDatabase, y Room solo avisa del cambio a quien lee de ese mismo objeto. Con dos bases abiertas sobre el mismo fichero, lo que escribe una no repinta la lista de la otra.
Paso 3 · El ViewModel, en tres líneas, y la fuente de red que sobra
Qué cambia. El ViewModel deja de construir el Pager y de conocer la red: recibe el repositorio nuevo y le pide el flujo. Abre PokemonListViewModel.kt (Cmd+Shift+O) y sustituye todo su contenido por esto:
package com.sebastiannarvaez.pokedex.feature.list
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import androidx.paging.PagingData
import androidx.paging.cachedIn
import com.sebastiannarvaez.pokedex.data.PokemonListRepository
import com.sebastiannarvaez.pokedex.domain.Pokemon
import kotlinx.coroutines.flow.Flow
class PokemonListViewModel internal constructor(
repository: PokemonListRepository,
) : ViewModel() {
/**
* La lista, leida de la base y llenada desde la red por el mediador. El
* ViewModel no sabe nada de eso: recibe paginas, como antes.
*
* `cachedIn(viewModelScope)` es lo que hace que al girar el telefono la
* lista no vuelva a pedirse entera: las paginas ya cargadas sobreviven
* mientras viva el ViewModel.
*
* Sin el, ademas, un `Flow<PagingData>` no se puede recolectar dos veces.
*/
val pokemon: Flow<PagingData<Pokemon>> = repository.pokemon().cachedIn(viewModelScope)
}Ya no hay Pager, ni PagingConfig, ni companion object: todo eso vive en el repositorio. El registro de Koin (viewModelOf(::PokemonListViewModel)) no cambia: Koin mira el constructor y encuentra el repositorio nuevo, que acabas de registrar.
Dos palabras del código: internal constructor deja visible solo dentro de shared el constructor (la clase sigue siendo pública, porque las apps la usan), igual que en El ViewModel del detalle; y cachedIn guarda las páginas cargadas para repartirlas a quien las recoja después (por ejemplo, la pantalla tras girar el móvil), porque Paging no deja recoger dos veces el mismo Flow<PagingData> sin él.
Qué se borra, y por qué. PokemonPagingSource pedía las páginas a la red y las daba a la pantalla. Ya no tiene quien la use: la pantalla lee de la base. Dejarla sería código muerto, con un test que sigue pasando y que no prueba nada de lo que hace la app. Lo que hacía no se pierde: lo hereda el mediador (pedir por desplazamiento, terminar con una página corta, no cargar hacia atrás, relanzar la cancelación), y por eso aquellas decisiones se repitieron en el mediador con sus propios tests.
Bórralos en este orden, primero el test y después la clase (si lo haces al revés, Android Studio puede avisar de que el test la sigue usando):
- En el panel de proyecto, clic derecho sobre
PokemonPagingSourceTest.kt→ Delete… → confirma. Está enshared/src/commonTest/kotlin/com/sebastiannarvaez/pokedex/feature/list/. - Lo mismo con
PokemonPagingSource.kt, enshared/src/commonMain/kotlin/com/sebastiannarvaez/pokedex/feature/list/.
Ejecuta los tests de la JVM y de Android host, y compila la app de Android:
./gradlew :shared:jvmTest
./gradlew :shared:testAndroidHostTest
./gradlew :androidApp:assembleDebugPunto de control
./gradlew :shared:jvmTesttermina enBUILD SUCCESSFULcon 59 tests en la JVM y./gradlew :shared:testAndroidHostTestcon 37 en Android host, sin contar los de la carpetaejercicios: los 62 / 40 de antes menos los tres dePokemonPagingSourceTestque has borrado, que contaban en las dos. Si hiciste ejercicios en módulos anteriores, tu cifra será mayor../gradlew :androidApp:assembleDebugtermina enBUILD SUCCESSFUL.- Ya no existen
PokemonPagingSource.ktniPokemonPagingSourceTest.kt, yPokemonListViewModelno tienePagerniPAGE_SIZE. PokemonListRepositoryexiste y está registrado enModules.kt; el mediador tieneinitialize()yCADUCIDAD.- Los tests de iOS no se ejecutan todavía: el del presentador se reescribe en la lección siguiente. El framework sí enlaza.
Comprueba lo que entendiste
7 preguntas · necesitas 5 para completar la lección
1.En el
Pagerdel repositorio, ¿qué función tiene cada pieza?2.¿Por qué el
Pagerrecibe una función (pagingSourceFactory) y no un únicoPagingSource?3.La caducidad es de 12 horas. La copia se guardó hace exactamente 12 horas. ¿Qué devuelve
initialize()?4.Cierras la app y la abres a los diez minutos. ¿Cuántas peticiones de listado a PokeAPI hace la lista?
5.Se borran
PokemonPagingSourcey su test. ¿Por qué?6.Quitas de
Modules.ktla línea que registraPokemonListRepositoryyModulesTestsigue en verde. ¿Por qué?7.Si Koin creara dos
PokedexDatabase, una para el mediador y otra para la lista, ¿qué pasaría?