Saltar al contenido
K
Favoritos con RoomPaso a paso44 min Avanzado

La base como fuente de verdad

Al terminar: Entender por qué la pantalla lee solo de la base y la red solo la rellena, preparar las tablas de Pokémon y de claves y probar que la base, ya en la versión 3, conserva los favoritos.

Ahora mismo la lista de la Pokédex vive de la red: cada vez que la abres pide los Pokémon a PokeAPI, y sin cobertura ves una pantalla de error. Los favoritos, en cambio, se ven en el metro, porque están en la base. Al terminar el módulo la lista funcionará igual, con lo último que descargó, y para eso la base tiene que aprender a guardarla.

Esta lección prepara el terreno y la pantalla no cambia. Tendrás dos tablas nuevas (los Pokémon de la lista y por dónde va la descarga), un DAO que entrega la lista por páginas, la base en su versión 3 con la migración que protege a quien ya tenía la app, y cinco tests nuevos. Quién llena las tablas lo cuenta El RemoteMediator: la red llena la base; quién las lee en la pantalla, El repositorio con dos fuentes.

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

El concepto · La base manda y la red la rellena

Hay dos sitios de donde pueden salir los datos: la red (PokeAPI, lejos, lenta y a veces ausente) y la base (el teléfono, cerca, rápida y siempre disponible). Una app puede mezclarlos de muchas maneras. La que usa Google en sus ejemplos, y la que construimos, es esta: la pantalla lee solo de la base, y la red solo sirve para rellenar la base.

Tiene tres ventajas: sin red, la lista sigue ahí (muestra lo último que se descargó); la pantalla tiene un solo camino (pinta lo que haya en la tabla, sin una versión «de la red» y otra «de la base» que se pisen); y la lista se actualiza sola (cuando la red guarda Pokémon nuevos, la tabla avisa y la pantalla se repinta).

Lo que falta: saber por dónde va la descarga

Para una cosa guardada, «mira la base y, si falta, pide a la red» basta. Para 1351 Pokémon que se descargan de veinte en veinte faltan tres cosas: saber cuándo pedir la página siguiente (lo resuelve Paging, en El RemoteMediator: la red llena la base), saber por dónde va la descarga y avisar a la pantalla cuando la base cambia (lo que construyes hoy).

La clave de página. Si has guardado los Pokémon del 1 al 40, toca pedir la que empieza en el 41 (el desplazamiento 40). Ese número tiene que sobrevivir a cerrar la app, así que va a una tabla de claves de una sola fila con dos datos: el desplazamiento de la página siguiente (ausente si la lista ya terminó) y cuándo se descargó la copia, para decidir si está demasiado vieja.

Paso 1 · Instala room3-paging

Objetivo: que un DAO pueda devolver una lista por páginas. Hace falta una pieza más de Room, la que sabe construir un PagingSource (la fuente de páginas de Paginación con Paging 3).

Pokedex-KMP
gradle
libs.versions.tomlse modifica: [libraries]
shared
build.gradle.ktsse modifica: commonMain
Dos ficheros de Gradle: el catálogo y el del módulo shared.

La librería. Abre gradle/libs.versions.toml y, en [libraries], justo debajo de la línea androidx-room-testing, añade una línea. Reutiliza la versión room: las librerías de Room 3 van siempre a la misma.

libs.versions.toml: [libraries], línea nueva
androidx-room-paging = { module = "androidx.room3:room3-paging", version.ref = "room" }

La dependencia. Abre shared/build.gradle.kts y, dentro de commonMain.dependencies { … }, justo debajo de implementation(libs.androidx.sqlite.bundled):

build.gradle.kts del módulo shared: commonMain, línea nueva
            // Lo que deja a un DAO devolver un PagingSource: la lista lee las
            // paginas directamente de la base.
            implementation(libs.androidx.room.paging)

Pulsa Sync Now; sin rojo. Y comprueba que Gradle ha elegido la variante de iOS (una librería multiplataforma publica una versión por plataforma, y el módulo tiene que usar la de iOS para compilar para el iPhone). En la Terminal de Android Studio (View → Tool Windows → Terminal), desde la carpeta de tu proyecto; la barra | pasa la salida del primer programa al segundo, grep, que deja solo las líneas con ese texto:

./gradlew :shared:dependencies --configuration iosSimulatorArm64CompileKlibraries | grep "room3-paging"
+--- androidx.room3:room3-paging:3.0.3
|    \--- androidx.room3:room3-paging-iossimulatorarm64:3.0.3

(Ignora las líneas con (c) al final. Lo importante es la variante iossimulatorarm64.)

Paso 2 · Las dos tablas

Los archivos. Dos entidades nuevas, junto a FavoriteEntity.kt, en shared/src/commonMain/kotlin/com/sebastiannarvaez/pokedex/data/local/. Cada una es una tabla.

shared
src/commonMain/kotlin/.../data/local/PokemonEntity.ktse crea: la lista guardada
src/commonMain/kotlin/.../data/local/RemoteKeysEntity.ktse crea: por dónde va la descarga
Dos entidades nuevas en el paquete de la base. Rutas desde la carpeta del módulo shared.

Créalos como en El módulo compartido y la prueba de humo: clic derecho sobre la carpeta shared → New → File, con la ruta desde esa carpeta. El primero:

src/commonMain/kotlin/com/sebastiannarvaez/pokedex/data/local/PokemonEntity.kt

El concepto. Cada fila es un Pokémon tal como lo pinta la tarjeta: número, nombre y tipos (la imagen no se guarda, sale del número). Dos decisiones:

  • Los tipos son una lista y SQLite no tiene columnas de lista. Se guardan como un texto con los nombres separados por comas ("GRASS,POISON"), igual que el favorito guarda su tipo principal, y al leer se traducen de vuelta con PokemonType.deApi, que ya conoces de Los tipos que cruzan a Swift.
  • position es el puesto en la lista de PokeAPI (0, 1, 2…). La pantalla ordena por esa columna y no por el número del Pokémon: el orden lo decide el servidor, y la base solo lo recuerda.

Las dos funciones del final traducen entre la fila y el Pokemon del dominio, para que ninguna otra capa sepa que los tipos van separados por comas. Son funciones de extensión (Extensiones): el nombre delante del punto, PokemonEntity.aDominio(), la deja llamarse como si fuera de esa clase (entidad.aDominio()), y dentro id, name y types son los de esa entidad. Usan tres herramientas de las listas; solo una es nueva:

  • split(",") (nueva) parte un texto por las comas: "GRASS,POISON".split(",") da la lista ["GRASS", "POISON"].
  • mapNotNull { … }, de Clases que se validan: transforma cada elemento y descarta los que salen null. Si PokemonType.deApi no reconoce un nombre, ese nombre simplemente no entra.
  • joinToString(",") { it.name }, de Guardar y leer JSON con Json: el camino contrario, que pega los elementos con comas. Aquí it.name es el nombre escrito tal cual del valor del enum (PokemonType.GRASS.name es "GRASS").
shared/src/commonMain/kotlin/com/sebastiannarvaez/pokedex/data/local/PokemonEntity.kt
package com.sebastiannarvaez.pokedex.data.local
 
import androidx.room3.Entity
import androidx.room3.PrimaryKey
import com.sebastiannarvaez.pokedex.domain.Pokemon
import com.sebastiannarvaez.pokedex.domain.PokemonType
 
/**
 * Un Pokemon de la lista, tal y como se guarda en la base.
 *
 * Es la copia local de lo que trajo la red: la pantalla de la lista ya no lee
 * de PokeAPI, lee de esta tabla. Por eso guarda todo lo que la tarjeta pinta
 * (numero, nombre y tipos); la imagen no hace falta, porque sale del numero.
 */
@Entity(tableName = "pokemon")
data class PokemonEntity(
    @PrimaryKey val id: Int,
    val name: String,
    /**
     * Los tipos como texto, separados por comas: `"GRASS,POISON"`.
     *
     * SQLite no tiene columnas de tipo lista. Se guarda el nombre de cada
     * `enum`, como ya se hacia con el tipo del favorito, y al leer se
     * traduce de vuelta con `PokemonType.deApi`.
     */
    val types: String,
    /**
     * El puesto en la lista de PokeAPI: 0, 1, 2…
     *
     * La pantalla ordena por esta columna y no por el numero: el orden lo
     * decide el servidor, y la base solo lo recuerda.
     */
    val position: Int,
)
 
/** De fila a dominio: lo que la pantalla sabe pintar. */
fun PokemonEntity.aDominio(): Pokemon = Pokemon(
    id = id,
    name = name,
    types = types.split(",").mapNotNull { PokemonType.deApi(it) },
)
 
/** De dominio a fila, con el puesto que ocupa en la lista. */
fun Pokemon.aEntidad(position: Int): PokemonEntity = PokemonEntity(
    id = id,
    name = name,
    types = types.joinToString(",") { it.name },
    position = position,
)

El segundo fichero, src/commonMain/kotlin/com/sebastiannarvaez/pokedex/data/local/RemoteKeysEntity.kt, es la tabla de claves, de una sola fila:

shared/src/commonMain/kotlin/com/sebastiannarvaez/pokedex/data/local/RemoteKeysEntity.kt
package com.sebastiannarvaez.pokedex.data.local
 
import androidx.room3.Entity
import androidx.room3.PrimaryKey
 
/**
 * Por donde va la descarga de la lista, y cuando se hizo.
 *
 * Es una sola fila por lista. La lista de PokeAPI solo avanza, asi que basta
 * con recordar **un** numero: el desplazamiento de la pagina siguiente. Es la
 * «clave de pagina» de la documentacion de Google (una tabla con una clave por
 * consulta); la otra variante, una clave por elemento, sirve cuando la API se
 * pide a partir del ultimo elemento visto.
 */
@Entity(tableName = "remote_keys")
data class RemoteKeysEntity(
    /** De que lista son las claves. Hoy solo hay una: `LISTA_POKEMON`. */
    @PrimaryKey val lista: String,
    /** El desplazamiento de la pagina siguiente. `null`: ya no hay mas. */
    val nextOffset: Int?,
    /** Cuando se descargo la primera pagina, en milisegundos. Mide la edad de la copia. */
    val actualizadoEn: Long,
)
 
/** El nombre de la unica fila de `RemoteKeysEntity`. */
const val LISTA_POKEMON = "pokemon"

nextOffset es Int? porque puede ser null (la lista terminó). actualizadoEn la escribe el mediador y la leerá El repositorio con dos fuentes; se crea ya, para no migrar otra vez.

Paso 3 · Los dos DAO

Los archivos. PokemonDao.kt y RemoteKeysDao.kt, en la misma carpeta. Empieza por el primero (src/commonMain/kotlin/com/sebastiannarvaez/pokedex/data/local/PokemonDao.kt).

El concepto. La consulta importante devuelve un PagingSource. A diferencia del de Paginación con Paging 3, este no lo escribes tú: Room lo escribe entero. Cuenta las filas, trae solo las que la pantalla pide y avisa cuando la tabla cambia, que resuelve el «avisar a la pantalla».

shared/src/commonMain/kotlin/com/sebastiannarvaez/pokedex/data/local/PokemonDao.kt
package com.sebastiannarvaez.pokedex.data.local
 
import androidx.paging.PagingSource
import androidx.room3.Dao
import androidx.room3.DaoReturnTypeConverters
import androidx.room3.Insert
import androidx.room3.OnConflictStrategy
import androidx.room3.Query
import androidx.room3.paging.PagingSourceDaoReturnTypeConverter
 
/**
 * La lista de Pokemon guardada.
 *
 * `@DaoReturnTypeConverters` es lo que deja a una consulta devolver un
 * `PagingSource`: el convertidor vive en `room3-paging`. Sin la anotacion,
 * Room no sabe construir ese tipo y KSP falla al compilar.
 */
@Dao
@DaoReturnTypeConverters(PagingSourceDaoReturnTypeConverter::class)
interface PokemonDao {
 
    /**
     * Las paginas, leidas de la base.
     *
     * Room escribe el `PagingSource` entero: cuenta, recorta con LIMIT y
     * OFFSET y, lo mas importante, **avisa solo** cuando la tabla cambia, asi
     * que la lista se repinta en cuanto llega una pagina nueva.
     */
    @Query("SELECT * FROM pokemon ORDER BY position")
    fun pagingSource(): PagingSource<Int, PokemonEntity>
 
    /** REPLACE: si un Pokemon ya estaba, se sustituye por la version nueva. */
    @Insert(onConflict = OnConflictStrategy.REPLACE)
    suspend fun insertAll(pokemon: List<PokemonEntity>)
 
    @Query("DELETE FROM pokemon")
    suspend fun clearAll()
 
    @Query("SELECT COUNT(*) FROM pokemon")
    suspend fun count(): Int
}

Lo nuevo:

  • @DaoReturnTypeConverters(PagingSourceDaoReturnTypeConverter::class) le dice a Room «para construir un PagingSource usa el convertidor de room3-paging». En Room 3 hay que pedirlo; tener la librería no basta.
  • ORDER BY position ordena por el puesto en la lista. Sin él SQLite no promete ningún orden; el test del Paso 5 lo vigila.
  • OnConflictStrategy.REPLACE, el del DAO de favoritos: si el Pokémon ya estaba, lo sustituye en vez de fallar.

El segundo DAO es corto (…/data/local/RemoteKeysDao.kt):

shared/src/commonMain/kotlin/com/sebastiannarvaez/pokedex/data/local/RemoteKeysDao.kt
package com.sebastiannarvaez.pokedex.data.local
 
import androidx.room3.Dao
import androidx.room3.Insert
import androidx.room3.OnConflictStrategy
import androidx.room3.Query
 
@Dao
interface RemoteKeysDao {
 
    @Query("SELECT * FROM remote_keys WHERE lista = :lista")
    suspend fun get(lista: String): RemoteKeysEntity?
 
    /** REPLACE: la fila es siempre la misma y se sobrescribe. */
    @Insert(onConflict = OnConflictStrategy.REPLACE)
    suspend fun save(keys: RemoteKeysEntity)
 
    @Query("DELETE FROM remote_keys")
    suspend fun clearAll()
}

get devuelve null si todavía no se descargó nada, y save sobrescribe la única fila.

Paso 4 · La base sube a la versión 3

El archivo. PokedexDatabase.kt, el de Persistencia con Room 3 (Navigate → File…, Cmd+Shift+O). Tres cosas a la vez: declarar las dos entidades, subir la versión de 2 a 3 y declarar la migración. Sustituye la anotación @Database(…) y añade los dos DAO dentro de la clase:

shared/src/commonMain/kotlin/com/sebastiannarvaez/pokedex/data/local/PokedexDatabase.kt
@Database(
    entities = [FavoriteEntity::class, PokemonEntity::class, RemoteKeysEntity::class],
    version = 3,
    exportSchema = true,
    // La migracion automatica: Room compara los dos esquemas exportados y
    // escribe el ALTER TABLE. Solo puede hacerlo porque `schemas/1.json` se
    // guarda con el proyecto y no se borra; si faltara, habria que escribirla a mano.
    autoMigrations = [
        AutoMigration(from = 1, to = 2),
        // La 3 solo anade dos tablas nuevas: Room escribe los CREATE TABLE y
        // no toca la de favoritos.
        AutoMigration(from = 2, to = 3),
    ],
)
@ConstructedBy(PokedexDatabaseConstructor::class)
abstract class PokedexDatabase : RoomDatabase() {
    abstract fun favoriteDao(): FavoriteDao
    abstract fun pokemonDao(): PokemonDao
    abstract fun remoteKeysDao(): RemoteKeysDao
}

(El expect object que sigue no cambia.)

Por qué las tres cosas juntas. Si dejas version = 2, todo compila y Room sobrescribe 2.json con el esquema nuevo: la foto de la versión anterior pasa a mentir (la trampa de Probar la migración). Si subes a 3 y no declaras la migración, también compila, pero la app de quien ya la tenía se cierra al abrir con A migration from 2 to 3 was required but not found; en tu emulador no lo verías si desinstalas. Con las tres, Room escribe los dos CREATE TABLE, no toca los favoritos y la app se actualiza sin perder nada. En el Paso 6 verás ese mensaje en un test.

Compílalo en la Terminal, desde la carpeta de tu proyecto:

./gradlew :shared:kspKotlinJvm

Debe terminar en BUILD SUCCESSFUL, y junto a 1.json y 2.json aparece 3.json, en shared/schemas/com.sebastiannarvaez.pokedex.data.local.PokedexDatabase. Es la foto del esquema nuevo (ábrelo y busca las tablas pokemon y remote_keys); Room la usará el día que la base pase a la versión 4, por eso se guarda con el código.

Paso 5 · Un test para la tabla y otro para las claves

El archivo. PokemonDaoTest.kt, en jvmTest, junto al test de favoritos (FavoriteDaoTest, que también vive en la JVM porque allí Room monta la base en memoria sin emulador): src/jvmTest/kotlin/com/sebastiannarvaez/pokedex/data/local/PokemonDaoTest.kt (clic derecho sobre shared → New → File).

Dos piezas nuevas: TestPager, de paging-testing (la instalaste en Paginación con Paging 3), que hace de pantalla y pide la primera página a una fuente, y PagingSource.LoadResult.Page, la respuesta con los datos. La fuente puede contestar con una página o con un error, así que resultado as Page (el as de Probar la paginación) afirma «es una página» para leer pagina.data; si no lo fuera, el test se rompe, que es lo que queremos.

shared/src/jvmTest/kotlin/com/sebastiannarvaez/pokedex/data/local/PokemonDaoTest.kt
package com.sebastiannarvaez.pokedex.data.local
 
import androidx.paging.PagingConfig
import androidx.paging.PagingSource
import androidx.paging.testing.TestPager
import com.sebastiannarvaez.pokedex.domain.Pokemon
import com.sebastiannarvaez.pokedex.domain.PokemonType
import kotlinx.coroutines.test.runTest
import kotlin.test.AfterTest
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNull
 
/**
 * La tabla de la lista y la de las claves, en una base en memoria.
 *
 * En `jvmTest`, como `FavoriteDaoTest`, por la misma razon: en la JVM Room
 * monta la base en memoria sin rutas de fichero ni emulador.
 */
class PokemonDaoTest {
 
    private val db = createDatabase(getDatabaseBuilder())
    private val dao = db.pokemonDao()
    private val claves = db.remoteKeysDao()
 
    @AfterTest
    fun cerrar() = db.close()
 
    private fun fila(id: Int, posicion: Int) =
        PokemonEntity(id = id, name = "pokemon-$id", types = "GRASS,POISON", position = posicion)
 
    @Test
    fun lasPaginasSalenEnElOrdenDeLaLista() = runTest {
        // El orden de la lista no es el de los numeros: el 3 va primero.
        // Sin `ORDER BY position`, SQLite las devolveria por numero (1, 2, 3).
        dao.insertAll(listOf(fila(1, 2), fila(2, 1), fila(3, 0)))
 
        val pager = TestPager(PagingConfig(pageSize = 2, enablePlaceholders = false), dao.pagingSource())
        val pagina = pager.refresh() as PagingSource.LoadResult.Page
 
        assertEquals(listOf(3, 2, 1), pagina.data.map { it.id })
    }
 
    @Test
    fun losTiposVanYVuelvenComoTexto() {
        val pokemon = Pokemon(1, "bulbasaur", listOf(PokemonType.GRASS, PokemonType.POISON))
 
        val entidad = pokemon.aEntidad(position = 0)
 
        assertEquals("GRASS,POISON", entidad.types)
        assertEquals(pokemon, entidad.aDominio())
    }
 
    @Test
    fun clearAllVaciaLaTabla() = runTest {
        dao.insertAll(listOf(fila(1, 0), fila(2, 1)))
 
        dao.clearAll()
 
        assertEquals(0, dao.count())
    }
 
    @Test
    fun lasClavesSonUnaSolaFilaQueSeSobrescribe() = runTest {
        assertNull(claves.get(LISTA_POKEMON), "sin descargar nada no hay claves")
 
        claves.save(RemoteKeysEntity(LISTA_POKEMON, nextOffset = 20, actualizadoEn = 1_000))
        claves.save(RemoteKeysEntity(LISTA_POKEMON, nextOffset = 40, actualizadoEn = 1_000))
 
        assertEquals(40, claves.get(LISTA_POKEMON)?.nextOffset)
    }
}

En el primero, el 3 va en la posición 0, así que la página sale 3, 2, 1. El cuarto es la tabla de claves: dos save seguidos y sigue habiendo una fila, con el último valor.

Ejecuta primero los nuevos y después todos:

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

Los dos deben terminar en BUILD SUCCESSFUL: suman 56 en la JVM (los 52 de antes y los cuatro nuevos).

Pruébalo. Borra ORDER BY position de la consulta de PokemonDao y repite el primer test: falla, porque sin la cláusula salen 1, 2, 3 y no 3, 2, 1. En la Terminal de Gradle solo verás el nombre de la excepción y el fichero; el mensaje entero está en el informe de tests que enlaza la última línea (shared/build/reports/tests/jvmTest/index.html: entra en la clase PokemonDaoTest y después en el test que falla), o en el panel de tests de Android Studio:

expected:<[3, 2, 1]> but was:<[1, 2, 3]>

Vuelve a poner la cláusula.

Paso 6 · Pruébala como quien ya tenía la app

La base ya está en la versión 3, pero nadie ha comprobado que la migración funcione: compila, los demás tests pasan y el fallo solo aparecería en el teléfono de quien ya tenía la app. Este paso añade un test que se comporta como esa persona.

Cada salto de versión necesita su propio test. Este no toca la tabla de favoritos (solo crea dos tablas): comprueba que lo que ya estaba sigue entero y lo nuevo existe y está vacío. Crea una base en la versión 2 con un favorito escrito con SQL a mano, aplica la migración a la 3 y mira el resultado.

shared
src/jvmTest/kotlin/.../data/local/MigrationTest.ktse modifica: un test más
src/commonMain/kotlin/.../data/local/PokedexDatabase.ktse toca y se deshace
Un test más, y un fichero que tocas y deshaces.

El archivo. MigrationTest.kt, en shared/src/jvmTest/kotlin/com/sebastiannarvaez/pokedex/data/local/ (Navigate → File…, Cmd+Shift+O). Los import ya están. Añade este test dentro de la clase, debajo del anterior:

shared/src/jvmTest/kotlin/com/sebastiannarvaez/pokedex/data/local/MigrationTest.kt
    @Test
    fun losFavoritosSobrevivenALaVersionTres() = runTest {
        // Version 2: favoritos con su tipo, y todavia sin la lista guardada.
        helper.createDatabase(version = 2).use { conexion ->
            conexion.execSQL(
                "INSERT INTO favorite (pokemonId, name, addedAt, primaryType) VALUES (25, 'pikachu', 1000, 'ELECTRIC')",
            )
        }
 
        helper.runMigrationsAndValidate(version = 3).use { conexion ->
            // El favorito sigue ahi, entero: la migracion no toca su tabla.
            conexion.prepare("SELECT pokemonId, primaryType FROM favorite").use { fila ->
                check(fila.step()) { "la fila desaparecio en la migracion" }
                assertEquals(25, fila.getInt(0))
                assertEquals("ELECTRIC", fila.getText(1))
            }
            // Y las dos tablas nuevas existen, vacias: la copia de la lista se
            // llena desde la red la primera vez que se abre la pantalla.
            conexion.prepare("SELECT COUNT(*) FROM pokemon").use { fila ->
                check(fila.step())
                assertEquals(0, fila.getInt(0))
            }
            conexion.prepare("SELECT COUNT(*) FROM remote_keys").use { fila ->
                check(fila.step())
                assertEquals(0, fila.getInt(0))
            }
        }
    }

Lo nuevo respecto del test anterior:

  • createDatabase(version = 2) pide una base con el esquema exacto de la versión 2, que sale de 2.json (por eso ese fichero no se borra nunca). El INSERT incluye primaryType porque Migraciones de base de datos ya añadió esa columna.
  • runMigrationsAndValidate(version = 3) aplica la migración y comprueba que el esquema resultante coincide exactamente con el de la versión 3.
  • SELECT COUNT(*) cuenta las filas: si la tabla no existiera, la consulta fallaría; si tuviera filas, no daría 0.

Ahora son dos los tests que simulan a quien ya tenía la app: cambia la primera línea del comentario de arriba de la clase, «El unico test que simula…», por «Los tests que simulan…».

Ejecuta los tests de la clase y después todos, desde la carpeta de tu proyecto:

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

Los dos deben terminar en BUILD SUCCESSFUL; suman 57 (los 56 de antes y este).

Rompe la migración y mira el error

Un test que nunca has visto fallar no demuestra nada. En PokedexDatabase.kt, borra (o comenta) la línea AutoMigration(from = 2, to = 3), y repite ./gradlew :shared:jvmTest --tests "*MigrationTest*". Falla losFavoritosSobrevivenALaVersionTres. En la Terminal solo verás el nombre de la excepción; el mensaje entero está en el informe de tests (shared/build/reports/tests/jvmTest/index.html: entra en la clase MigrationTest y después en el test que falla; o el panel de tests de Android Studio):

java.lang.IllegalStateException: A migration from 2 to 3 was required but not found. Please provide the necessary Migration path via RoomDatabase.Builder.addMigration(...) or allow for destructive migrations via one of the RoomDatabase.Builder.fallbackToDestructiveMigration* functions.

Dice «hacía falta una migración de la 2 a la 3 y no la encuentro». Es el que vería al abrir la app quien tuviera la versión anterior, y la compilación no se quejó: sin este test, el fallo habría llegado a los teléfonos.

Deshaz el cambio: vuelve a poner AutoMigration(from = 2, to = 3), y comprueba que los dos tests pasan otra vez.

Instálala encima de la anterior

Ejecuta la app de Android (configuración androidApp) sin desinstalar la anterior: la lista se ve igual y tus favoritos siguen en su sitio, porque la base se migró por debajo. Haz lo mismo en iOS desde Xcode (Run), sin borrar la app del simulador.

Para cerrar, las tres tareas de tests a la vez:

./gradlew :shared:jvmTest :shared:testAndroidHostTest :shared:iosSimulatorArm64Test

Punto de control

  • ./gradlew :shared:jvmTest :shared:testAndroidHostTest :shared:iosSimulatorArm64Test termina en BUILD SUCCESSFUL con 57 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 (52 / 40 / 42), los cuatro de PokemonDaoTest y el de MigrationTest. Si hiciste ejercicios en módulos anteriores, tu cifra será mayor.
  • PokemonDaoTest y los dos tests de MigrationTest están en verde. Sin la línea AutoMigration(from = 2, to = 3) el segundo falla con A migration from 2 to 3 was required but not found (el mensaje, en el informe de tests); con ella, pasa.
  • Existe shared/schemas/…PokedexDatabase/3.json junto a 1.json y 2.json, y muestra las tablas pokemon y remote_keys.
  • PokedexDatabase declara tres entidades, version = 3 y las dos migraciones automáticas.
  • La app de Android y la de iOS, instaladas encima de la anterior, abren igual y conservan los favoritos.
  • La lista sigue pidiéndose a la red: la pantalla empezará a leer de la base en El repositorio con dos fuentes.

Comprueba lo que entendiste

8 preguntas · necesitas 6 para completar la lección

  1. 1.En una app con la base como fuente de verdad, ¿de dónde lee la pantalla de la lista?

  2. 2.¿Para qué sirve la fila de remote_keys?

  3. 3.¿Por qué los tipos de un Pokémon se guardan como un texto, "GRASS,POISON"?

  4. 4.¿Qué hace @DaoReturnTypeConverters(PagingSourceDaoReturnTypeConverter::class) en PokemonDao?

  5. 5.Quitas ORDER BY position de la consulta de PokemonDao. ¿Qué pasa con el orden de la lista?

  6. 6.¿Por qué el test de migración inserta el favorito con SQL a mano y no con el DAO de hoy?

  7. 7.Sin AutoMigration(from = 2, to = 3), ¿cuándo se descubre el problema?

  8. 8.El mensaje de error sugiere permitir migraciones destructivas. ¿Por qué no sirve en la Pokédex?

Faltan 8 de 8