Saltar al contenido
K
Favoritos con RoomPaso a paso38 min Avanzado

El RemoteMediator: la red llena la base

Al terminar: Escribir el mediador que pide páginas a PokeAPI y las guarda en la base, y probarlo.

La base ya tiene dos tablas nuevas, una para los Pokémon y otra para recordar por dónde va la descarga, pero nadie las llena. Al terminar esta lección tendrás una clase, PokemonRemoteMediator, que pide una página a PokeAPI y la escribe en la base, y cinco tests que demuestran lo que hace: llenar, ampliar, parar al final, no romper lo guardado cuando falla la red y empezar de cero al refrescar.

La pantalla todavía no la usa. En El repositorio con dos fuentes la conectarás a la lista; aquí solo construyes la pieza y la pruebas por separado, sin pantalla y sin emulador. Por eso, al ejecutar la app, no verás ninguna diferencia.

Para saber que no rompes nada: ./gradlew :shared:jvmTest :shared:testAndroidHostTest :shared:iosSimulatorArm64Test da 57 / 40 / 42 tests (JVM / Android host / iOS) al empezar, sin contar los de la carpeta ejercicios.

El concepto · El reponedor de la estantería

Hasta ahora la lista sacaba las páginas directamente de la red. Con una base de datos en medio, el trabajo se reparte en dos y cada parte tiene un nombre:

  • Leer, para la pantalla. Lo hace un PagingSource, como el de Paginación con Paging 3, pero esta vez uno que lee de la base (el que Room escribió por ti en La base como fuente de verdad).
  • Llenar la base desde la red. Lo hace un RemoteMediator. «Remote» es remoto (la red) y «mediator», mediador: el que está en medio entre la red y la base.

Las tres órdenes y los dos resultados

Paging llama a una sola función del mediador, load, y le dice qué tipo de carga quiere con un valor de LoadType. Hay tres:

Orden (LoadType)Cuándo llegaQué hace nuestro mediador
REFRESHAl abrir la lista por primera vez, o al tirar para refrescarBorra lo guardado y empieza otra vez desde el principio
APPENDQuedan pocos elementos por delante del final de lo guardadoPide la página siguiente y la añade detrás
PREPENDSe necesitan elementos antes del primeroNada: la lista solo avanza

La respuesta de load también tiene dos formas, MediatorResult: Success (todo bien, y con un dato extra: endOfPaginationReached, que dice si ya no queda nada más por traer) o Error (algo falló, y lleva la excepción para que la pantalla pueda contarlo).

Falta un tercer concepto, y es el que más se pasa por alto. Para pedir «la página siguiente», el mediador tiene que saber dónde se quedó. Eso es una clave de página: un dato que dice desde dónde sigue la descarga. Es el marcapáginas de un libro: no guarda el texto, solo en qué punto lo dejaste. Aquí es un número, nextOffset (el desplazamiento de la próxima página: cuántos Pokémon hay antes de ella), y vive en la tabla remote_keys. Si el marcapáginas vale null, ya no hay más páginas.

Es la tabla remote_keys de La base como fuente de verdad: el número vive en la base y sobrevive a cerrar la app.

Una palabra más: transacción

El mediador, al refrescar, hace tres cosas seguidas en la base: borra los Pokémon viejos, borra las claves viejas y escribe lo nuevo. Si algo falla entre una y otra, la lista se queda a medias o vacía. Una transacción es agrupar esos pasos para que ocurran todos o ninguno: o se ve el resultado completo, o la base queda exactamente como estaba.

Ejemplo comentado, con frutas

Casi todo lo que aparece en él ya lo conoces:

Lo único nuevo es un when que devuelve un valor, con return dentro. val desde = when (carga) { … } calcula un número según el caso, y una rama puede hacer return: sale de la función entera con una respuesta y ya no sigue.

Antes de escribir el mediador de verdad, el mismo reparto con datos que no tienen nada que ver con el proyecto: un almacén con 45 frutas y una estantería. Es para leer, no lo escribas en el proyecto. La «transacción» de este ejemplo es solo la idea del borrador (la estantería nueva se prepara aparte y se cambia de golpe); en la base de datos de verdad la hace la propia base.

ejemplo mínimo: reponer una estantería (con comentarios)
// El almacén central: 45 frutas, y una red que a veces falla.
val almacen = (1..45).map { "fruta-$it" }
var hayRed = true
 
fun pedirAlAlmacen(cuantas: Int, desde: Int): List<String> {
    if (!hayRed) throw RuntimeException("sin red")        // el camión no llega
    return almacen.drop(desde).take(cuantas)               // salta `desde` y toma `cuantas`
}
 
enum class Carga { EMPEZAR, SEGUIR }                        // como los LoadType, con otros nombres
 
class Reponedor(
    val estanteria: MutableList<String>,                    // lo que se ve en la tienda («la base»)
) {
    var siguiente: Int? = null                              // el marcapáginas; null = se acabó
 
    // Devuelve true si fue bien y false si falló. Nunca lanza.
    fun reponer(carga: Carga, cuantas: Int): Boolean {
        val desde = when (carga) {
            Carga.EMPEZAR -> 0                              // empezar es volver al principio
            Carga.SEGUIR -> siguiente ?: return true        // sin marcapáginas no hay nada que seguir
        }
        return try {
            val traidas = pedirAlAlmacen(cuantas, desde)
            // Todo o nada: la estantería nueva se prepara aparte y se cambia de golpe.
            val nueva = if (carga == Carga.EMPEZAR) mutableListOf() else estanteria.toMutableList()
            nueva += traidas                                // `+=` con una lista: añade todos sus elementos al final
            estanteria.clear()
            estanteria += nueva                             // lo mismo: la estantería recibe todos los de `nueva`
            // Una caja corta quiere decir que el almacén se vació.
            siguiente = if (traidas.size < cuantas) null else desde + cuantas
            println("$carga: ${estanteria.size} en la estantería, sigue en $siguiente")
            true
        } catch (e: RuntimeException) {
            // El fallo no toca la estantería: se cuenta y ya.
            println("$carga: falló (${e.message}); la estantería se queda con ${estanteria.size}")
            false
        }
    }
}
 
fun main() {
    val r = Reponedor(mutableListOf())
    r.reponer(Carga.EMPEZAR, 20)
    r.reponer(Carga.SEGUIR, 20)
    hayRed = false
    r.reponer(Carga.SEGUIR, 20)
    r.reponer(Carga.EMPEZAR, 20)
    hayRed = true
    r.reponer(Carga.SEGUIR, 20)
    r.reponer(Carga.SEGUIR, 20)
}

Salida real:

EMPEZAR: 20 en la estantería, sigue en 20
SEGUIR: 40 en la estantería, sigue en 40
SEGUIR: falló (sin red); la estantería se queda con 40
EMPEZAR: falló (sin red); la estantería se queda con 40
SEGUIR: 45 en la estantería, sigue en null

Las dos primeras reposiciones llenan la estantería y mueven el marcapáginas. Las dos siguientes fallan por falta de red y no tocan nada: incluso EMPEZAR, que habría borrado todo, deja las 40 frutas donde estaban. La quinta trae las cinco que quedaban (una caja corta: 5 de 20), y el marcapáginas pasa a null. La sexta llamada no imprime nada: con el marcapáginas en null no hay nada que seguir y devuelve true sin pedir nada. Las cuatro ideas del mediador están ahí: la orden decide dónde se empieza, una caja corta termina la lista, un fallo no estropea lo guardado y la clave dice por dónde se sigue.

Paso 1 · Escribe el mediador

shared
src/commonMain/kotlin/.../data/PokemonRemoteMediator.ktse crea
Un fichero nuevo, en el código común del módulo shared.

El archivo. PokemonRemoteMediator.kt es el reponedor de los Pokémon. Va en commonMain, junto a PokemonRepository, que es la fuente remota de siempre (la que habla con PokeAPI): en el código la verás con el nombre red. Créalo: clic derecho sobre la carpeta data (dentro de shared/src/commonMain/kotlin/com/sebastiannarvaez/pokedex/) → New → Kotlin Class/File → escribe PokemonRemoteMediator, elige Class y sustituye lo que genere por el código de abajo.

Antes de leer el código, una pieza nueva de Kotlin: mapIndexed. Es como map, pero la lambda recibe además la posición de cada elemento (0, 1, 2…). Sirve para dar a cada Pokémon su puesto en la lista: offset + i. El when con return y el ?: return son los del ejemplo de las frutas: en la rama de PREPEND hay un return, así que el when solo da un número cuando la orden es una de las dos que sí piden a la red.

shared/src/commonMain/kotlin/com/sebastiannarvaez/pokedex/data/PokemonRemoteMediator.kt
package com.sebastiannarvaez.pokedex.data
 
import androidx.paging.ExperimentalPagingApi
import androidx.paging.LoadType
import androidx.paging.PagingState
import androidx.paging.RemoteMediator
import androidx.room3.withWriteTransaction
import com.sebastiannarvaez.pokedex.data.local.LISTA_POKEMON
import com.sebastiannarvaez.pokedex.data.local.PokedexDatabase
import com.sebastiannarvaez.pokedex.data.local.PokemonEntity
import com.sebastiannarvaez.pokedex.data.local.RemoteKeysEntity
import com.sebastiannarvaez.pokedex.data.local.aEntidad
import kotlinx.coroutines.CancellationException
 
/**
 * Llena la base desde la red. **No** le da nada a la pantalla.
 *
 * La pantalla lee solo de la base (el `PagingSource` de Room). Cuando esa
 * lectura llega al final de lo guardado, Paging llama a este mediador para
 * que traiga la pagina siguiente y la escriba en la tabla; Room avisa del
 * cambio y la lista se repinta sola. Asi la base es la unica fuente de verdad
 * de la pantalla, y sin red se sigue viendo lo que ya se guardo.
 *
 * Es el patron que recomienda Google para «red y base de datos»:
 * https://developer.android.com/topic/libraries/architecture/paging/v3-network-db
 */
@OptIn(ExperimentalPagingApi::class)
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,
) : RemoteMediator<Int, PokemonEntity>() {
 
    private val pokemonDao = db.pokemonDao()
    private val clavesDao = db.remoteKeysDao()
 
    override suspend fun load(loadType: LoadType, state: PagingState<Int, PokemonEntity>): MediatorResult {
        val claves = clavesDao.get(LISTA_POKEMON)
 
        // Donde empieza la pagina que hay que traer.
        val offset = when (loadType) {
            // Refrescar es empezar de cero.
            LoadType.REFRESH -> 0
            // La lista solo avanza: por arriba no hay nada que cargar.
            LoadType.PREPEND -> return MediatorResult.Success(endOfPaginationReached = true)
            LoadType.APPEND -> {
                // Sin claves, el primer refresco aun no ha escrito nada: no es
                // el final, Paging volvera a llamar cuando lo haya.
                if (claves == null) return MediatorResult.Success(endOfPaginationReached = false)
                // Con claves y sin siguiente, la lista se acabo.
                claves.nextOffset ?: return MediatorResult.Success(endOfPaginationReached = true)
            }
        }
 
        val limit = state.config.pageSize
        return try {
            val pagina = red.page(limit = limit, offset = offset)
            // Si la pagina viene corta, no hay mas: igual que en el PagingSource.
            val fin = pagina.size < limit
 
            // Todo o nada: si algo falla a mitad, la base queda como estaba.
            // Sin la transaccion, un refresco podria borrar la lista y no
            // llegar a escribir la nueva, y la pantalla se quedaria vacia.
            db.withWriteTransaction {
                if (loadType == LoadType.REFRESH) {
                    pokemonDao.clearAll()
                    clavesDao.clearAll()
                }
                pokemonDao.insertAll(pagina.mapIndexed { i, p -> p.aEntidad(position = offset + i) })
                clavesDao.save(
                    RemoteKeysEntity(
                        lista = LISTA_POKEMON,
                        nextOffset = if (fin) null else offset + limit,
                        // La edad de la copia es la de su primera pagina: al
                        // ampliar se conserva la fecha del ultimo refresco.
                        actualizadoEn = if (loadType == LoadType.REFRESH) ahora()
                        else claves?.actualizadoEn ?: ahora(),
                    ),
                )
            }
            MediatorResult.Success(endOfPaginationReached = fin)
        } catch (e: CancellationException) {
            // Cancelar no es fallar: se relanza, como en el PagingSource.
            throw e
        } catch (e: Throwable) {
            // Lo guardado no se toca. Paging lo convierte en un LoadState.Error
            // del mediador, y la pantalla decide como contarlo.
            MediatorResult.Error(e)
        }
    }
 
}

Lo que hace cada parte, y por qué está así:

  • RemoteMediator<Int, PokemonEntity> fija dos tipos, como PagingSource<Int, Pokemon>: la clave de las páginas (Int) y lo que hay guardado (PokemonEntity, la fila de la tabla, no el Pokemon del dominio: el mediador trabaja con la base).
  • internal deja la clase visible solo dentro del módulo shared (como FavoritesRepository, en Favoritos en el núcleo): ni la app de Android ni la de iOS necesitan construir el mediador. Los dos DAO guardados en propiedades evitan repetir db.pokemonDao() en cada línea.
  • db.withWriteTransaction { … } es la transacción. Es una función suspend de Room 3 (androidx.room3) que ejecuta su bloque como una unidad: todas las escrituras de dentro, o ninguna. Las llamadas a los DAO que hagas dentro se unen a ella.
  • REFRESH borra y reinserta en la misma transacción. Imagina 20 filas guardadas y una app que se cierra entre el clearAll() y el insertAll(…): con withWriteTransaction el borrado se deshace y las 20 filas siguen ahí; sin ella, la tabla queda vacía y la lista desaparece justo cuando algo falla.
  • fin = pagina.size < limit repite el criterio del PagingSource: una página corta es el final. Con él, nextOffset pasa a null y la siguiente petición de APPEND ni siquiera sale a la red.
  • actualizadoEn guarda la hora del último refresco, no de la última ampliación: es la edad de la copia, y la usará El repositorio con dos fuentes para decidir si hay que volver a pedirla. Al refrescar es ahora(). Al ampliar se conserva la fecha que ya había, y claves?.actualizadoEn lleva ?. porque claves puede ser null (si no había nada guardado); en ese caso, y solo en ese, se usa ahora().
  • El catch de CancellationException relanza, igual que en Paginación con Paging 3: que cancelen la carga no es un fallo de red. Cualquier otro error se convierte en MediatorResult.Error(e) sin tocar la base, y Paging lo traduce en un estado de error que la pantalla podrá enseñar. Se captura Throwable, el tipo que abarca todos los errores (Exception es solo una parte de ellos; lo viste en Result: errores como valores), para que ninguno se escape y tumbe la lista.

Paso 2 · Los cinco tests del mediador

shared
src/jvmTest/kotlin/.../data/PokemonRemoteMediatorTest.ktse crea
Un fichero nuevo, en el código de pruebas de la JVM.

El concepto. El mediador se prueba llamándolo a mano: se le pide un load con la orden que quieras y después se mira qué quedó en la base. Paging le entrega un PagingState (lo cargado hasta ahora y la configuración); en un test se fabrica uno vacío, porque al mediador solo le interesa de él el tamaño de página (config.pageSize).

El archivo. PokemonRemoteMediatorTest.kt, en jvmTest (como FavoriteDaoTest: allí Room monta la base en memoria sin rutas ni emulador). Clic derecho sobre la carpeta shared → New → File, con la ruta desde la carpeta del módulo:

src/jvmTest/kotlin/com/sebastiannarvaez/pokedex/data/PokemonRemoteMediatorTest.kt

Empieza por el esqueleto: la base en memoria, un reloj que tú controlas, un ayudante que monta el mediador y otro que lee qué números de Pokémon hay guardados.

PokemonRemoteMediatorTest.kt: el esqueleto
package com.sebastiannarvaez.pokedex.data
 
import androidx.paging.ExperimentalPagingApi
import androidx.paging.LoadType
import androidx.paging.PagingConfig
import androidx.paging.PagingSource
import androidx.paging.PagingState
import androidx.paging.RemoteMediator.MediatorResult
import com.sebastiannarvaez.pokedex.data.local.LISTA_POKEMON
import com.sebastiannarvaez.pokedex.data.local.PokemonEntity
import com.sebastiannarvaez.pokedex.data.local.RemoteKeysEntity
import com.sebastiannarvaez.pokedex.data.local.createDatabase
import com.sebastiannarvaez.pokedex.data.local.getDatabaseBuilder
import com.sebastiannarvaez.pokedex.dobles.FakePokeApi
import com.sebastiannarvaez.pokedex.dobles.TestDispatchers
import kotlin.test.AfterTest
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertIs
import kotlin.test.assertNull
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.runTest
 
/**
 * El mediador, llamado a mano: sin pantalla y sin Pager.
 *
 * Es como lo prueba la guia de Google: se le pide un `load` con un
 * `PagingState` vacio y se mira que dejo en la base. La red es el
 * `FakePokeApi` de los dobles; la base, una de verdad en memoria.
 */
@OptIn(ExperimentalPagingApi::class)
class PokemonRemoteMediatorTest {
 
    private val db = createDatabase(getDatabaseBuilder())
    private var reloj = 1_000_000L
 
    @AfterTest
    fun cerrar() = db.close()
 
    /**
     * Un mediador con su propia red. Sin conexion = otro mediador sobre la misma base.
     * Ojo: `api` vale por defecto `FakePokeApi(total, falla)`, es decir, usa los
     * dos parametros de antes. Si pasas tu propio `api`, `total` y `falla` se ignoran.
     */
    private fun TestScope.mediador(total: Int = 60, falla: Boolean = false, api: FakePokeApi = FakePokeApi(total, falla)) =
        PokemonRemoteMediator(
            red = PokemonRepository(api, TestDispatchers(StandardTestDispatcher(testScheduler))),
            db = db,
            ahora = { reloj },
        )
 
    /** Lo que Paging le pasa al mediador. Aqui solo importa el tamano de pagina. */
    private val estado = PagingState<Int, PokemonEntity>(
        pages = emptyList(),
        anchorPosition = null,
        config = PagingConfig(pageSize = 20), // las paginas de veinte de la lista
        leadingPlaceholderCount = 0,
    )
 
    private suspend fun idsGuardados(): List<Int> {
        val pagina = db.pokemonDao().pagingSource().load(
            PagingSource.LoadParams.Refresh(key = null, loadSize = 1000, placeholdersEnabled = false),
        ) as PagingSource.LoadResult.Page
        return pagina.data.map { it.id }
    }
}

Antes de seguir, el esqueleto tiene piezas que conviene entender una a una:

  • TestScope.mediador(…) es una extensión de TestScope (se escribe delante del nombre de la función): así puede usar el testScheduler del test, el reloj virtual, para que el PokeAPI de mentira y el test vayan a la vez.
  • api: FakePokeApi = FakePokeApi(total, falla) es un parámetro con valor por defecto, y ese valor usa los parámetros anteriores (total y falla). Si no pasas api, se construye con ellos; si pasas el tuyo, total y falla no se usan. Verás las dos formas en los tests.
  • PagingState(…) es lo que Paging entrega al mediador; aquí se fabrica a mano, argumento por argumento: pages son las páginas ya cargadas (ninguna), anchorPosition es el elemento que el usuario está mirando (nadie: null), config es la configuración, de la que solo importa pageSize, y leadingPlaceholderCount son huecos vacíos al principio (0).
  • idsGuardados() lee lo que hay en la tabla: pide al PagingSource de Room una carga inicial (LoadParams.Refresh) sin clave (key = null, desde el principio) y de mil elementos (loadSize: más de los que habrá), sin huecos de relleno (placeholdersEnabled = false). Devuelve un resultado que puede ser una página o un error, y as PagingSource.LoadResult.Page afirma «es una página» para poder leer sus datos (pagina.data): si no lo fuera, el test se rompería. Salen en el orden de la columna position.

Ahora los cinco tests, dentro de la clase. El primero comprueba que refrescar llena la base y deja escrito el marcapáginas y la hora. Usa assertIs, que ya conoces de Ficheros y depurador: comprueba el tipo y, si pasa, Kotlin trata el resultado como ese tipo (el «smart cast»), así que se puede leer endOfPaginationReached sin más:

PokemonRemoteMediatorTest.kt: refrescar y ampliar, dentro de la clase
    @Test
    fun refrescarLlenaLaBase() = runTest {
        val resultado = mediador().load(LoadType.REFRESH, estado)
 
        // assertIs comprueba que es un Success y, ademas, le dice a Kotlin que
        // desde aqui `resultado` ya es un Success: por eso se puede leer su
        // `endOfPaginationReached` (el compilador lo llama «smart cast»).
        assertIs<MediatorResult.Success>(resultado)
        assertEquals(false, resultado.endOfPaginationReached)
        assertEquals((1..20).toList(), idsGuardados())
        val claves = db.remoteKeysDao().get(LISTA_POKEMON)
        assertEquals(20, claves?.nextOffset)
        assertEquals(reloj, claves?.actualizadoEn)
    }
 
    @Test
    fun ampliarAnadeLaPaginaSiguiente() = runTest {
        val m = mediador()
        m.load(LoadType.REFRESH, estado)
 
        m.load(LoadType.APPEND, estado)
 
        // Del 1 al 40, en orden y sin huecos: la segunda pagina va detras.
        assertEquals((1..40).toList(), idsGuardados())
        assertEquals(40, db.remoteKeysDao().get(LISTA_POKEMON)?.nextOffset)
    }

El tercero y el cuarto cubren el final de la lista y los fallos de red. Los dos más importantes de la lección:

PokemonRemoteMediatorTest.kt: el final de la lista y la red caída, dentro de la clase
    @Test
    fun laListaSeAcabaConLaPaginaCorta() = runTest {
        val api = FakePokeApi(total = 25)
        val m = mediador(api = api)
        m.load(LoadType.REFRESH, estado)
 
        val ultima = m.load(LoadType.APPEND, estado)
 
        assertIs<MediatorResult.Success>(ultima)
        assertEquals(true, ultima.endOfPaginationReached)
        assertNull(db.remoteKeysDao().get(LISTA_POKEMON)?.nextOffset)
 
        // Una vez acabada, ampliar ya no gasta red.
        val llamadas = api.llamadasAlListado
        val otra = m.load(LoadType.APPEND, estado)
        assertEquals(true, (otra as MediatorResult.Success).endOfPaginationReached)
        assertEquals(llamadas, api.llamadasAlListado)
    }
 
    @Test
    fun sinRedLoGuardadoSeConserva() = runTest {
        mediador().load(LoadType.REFRESH, estado)
 
        val sinRed = mediador(falla = true)
        val alAmpliar = sinRed.load(LoadType.APPEND, estado)
        val alRefrescar = sinRed.load(LoadType.REFRESH, estado)
 
        // Los dos fallan, y los dos lo dicen: no se tragan el error.
        assertIs<MediatorResult.Error>(alAmpliar)
        assertIs<MediatorResult.Error>(alRefrescar)
        // Y ninguno toca la base: el refresco fallido NO la vacia.
        assertEquals((1..20).toList(), idsGuardados())
        assertEquals(20, db.remoteKeysDao().get(LISTA_POKEMON)?.nextOffset)
    }

Fíjate en cómo se simula quedarse sin conexión: no se apaga nada, se crea otro mediador sobre la misma base cuyo PokeAPI de mentira falla siempre. Lo guardado por el primero sigue ahí y es lo que el segundo no debe estropear. Y el último, el que prueba que refrescar no arrastra restos de una copia vieja:

PokemonRemoteMediatorTest.kt: refrescar borra lo viejo, dentro de la clase
    @Test
    fun refrescarBorraLoViejoYEmpiezaDeCero() = runTest {
        // Una copia vieja: un Pokemon que ya no esta y claves de una copia con tres paginas.
        db.pokemonDao().insertAll(listOf(PokemonEntity(id = 999, name = "viejo", types = "", position = 0)))
        db.remoteKeysDao().save(RemoteKeysEntity(LISTA_POKEMON, nextOffset = 60, actualizadoEn = 0))
 
        mediador().load(LoadType.REFRESH, estado)
 
        assertEquals((1..20).toList(), idsGuardados())
        assertEquals(20, db.remoteKeysDao().get(LISTA_POKEMON)?.nextOffset)
    }

Ejecuta los tests del mediador y después todos los de la JVM:

./gradlew :shared:jvmTest --tests "*PokemonRemoteMediatorTest*"
./gradlew :shared:jvmTest

Los dos deben acabar en BUILD SUCCESSFUL. El mediador es código común, así que comprueba también que compila para iOS:

./gradlew :shared:compileKotlinIosSimulatorArm64

El compilador de iOS avisa de suspend function is exposed to ObjC en los dos DAO nuevos (PokemonDao.kt y RemoteKeysDao.kt): seis avisos nuevos, tres de cada uno, además de los que ya salían de FavoriteDao. Son los mismos de Persistencia con Room 3 y de El ViewModel compartido: el DAO es público y Swift lo ve en el framework. Aquí no hace falta arreglarlos, porque Swift nunca llamará a un DAO.

Para ver el recuento, abre el informe en el navegador (open es el comando de macOS que abre un fichero con su programa: open shared/build/reports/tests/jvmTest/index.html) y comprueba que PokemonRemoteMediatorTest lista 5 tests y 0 fallos. Las otras dos tareas de tests (Android host e iOS) no cambian: el mediador y su test están en jvmTest, que ni Android ni iOS ejecutan.

Punto de control

  • ./gradlew :shared:jvmTest :shared:testAndroidHostTest :shared:iosSimulatorArm64Test termina en BUILD SUCCESSFUL con 62 tests en la JVM, 40 en Android host y 42 en iOS, sin contar los de la carpeta ejercicios: 5 más en la JVM que al empezar (57 / 40 / 42), los de PokemonRemoteMediatorTest. Si hiciste ejercicios en módulos anteriores, tu cifra será mayor.
  • ./gradlew :shared:compileKotlinIosSimulatorArm64 también termina bien: el mediador es código común y compila para iOS (con seis avisos nuevos de suspend function is exposed to ObjC).
  • PokemonRemoteMediator relanza la CancellationException, convierte el resto de errores en MediatorResult.Error y no toca la base cuando falla.
  • Al refrescar borra y reinserta dentro de withWriteTransaction, y al ampliar guarda nextOffset = null cuando la página viene corta.
  • La app se comporta igual que al terminar la lección anterior: la lista sigue leyendo de la red, porque nadie usa todavía el mediador.

Comprueba lo que entendiste

6 preguntas · necesitas 5 para completar la lección

  1. 1.En una lista con base de datos y red, ¿qué hace el RemoteMediator y qué NO hace?

  2. 2.Paging llama a load con LoadType.PREPEND y nuestra lista solo avanza. ¿Qué devuelve el mediador?

  3. 3.El refresco borra la tabla y escribe la lista nueva dentro de withWriteTransaction. ¿Qué se gana?

  4. 4.La tabla de claves tiene una sola fila con nextOffset, no una fila por Pokémon. ¿Por qué basta?

  5. 5.En el test sinRedLoGuardadoSeConserva, ¿cómo se simula quedarse sin conexión?

  6. 6.En el catch del mediador, ¿por qué la CancellationException se relanza y el resto de errores se convierte en MediatorResult.Error?

Faltan 6 de 6