Persistencia con Room 3
Al terminar: Montar la base de datos compartida: las librerías, la entidad, el DAO y la base.
Hasta ahora la app no recuerda nada. Cierras y vuelves a empezar: la lista se pide otra vez a PokeAPI y lo que hubieras marcado se pierde, porque no hay dónde marcarlo. Esta lección y la siguiente montan la base de datos que lo guardará, una SQLite que arranca igual en Android, en iOS y en la JVM.
Aquí se monta la mitad que no depende de la plataforma: las librerías, la tabla, el DAO y la base. Al terminar compila para los cuatro destinos (Android, la JVM y los dos de iOS) y el procesador de Room habrá escrito su código en cada uno. Todavía no se abre ninguna base: eso lo hace La base en cada plataforma.
Room ya lo conoces: entidades, DAO, KSP y el plugin que exporta los esquemas los aprendiste a fondo en Room: entidades y DAO, con Room 3 y KSP 2.3. Aquí no se repite. Lo que cambia al llevarlo a Kotlin Multiplatform son cuatro cosas, y las tres primeras son de hoy:
- El código de Room (entidad, DAO, base) vive en
commonMainy se escribe una sola vez. - El procesador de KSP se pide destino por destino (
kspAndroid,kspJvm…), no con unksp(…)suelto. - La base necesita un constructor que cada plataforma genera: un
expect objectsinactualescrito por ti. - El driver de SQLite tiene que ser uno que exista en todas las plataformas, y lo único que cambia por plataforma es dónde está el fichero. Es lo de la lección siguiente.
Paso 1 · Instala Room, KSP y SQLite en shared
Objetivo: que shared pueda procesar anotaciones de Room en cada plataforma. Es la receta de Los ficheros de Gradle de tu proyecto y la de KSP de Room: entidades y DAO, con la diferencia de KMP que ya viste en Gradle sin dolor: cada dependencia va a la carpeta de código (source set) que la necesita.
1 · Qué instalas y por qué ahora. Cuatro piezas, las mismas que en Marcador: el plugin de KSP, el plugin de Room (el que exporta los esquemas), la librería de Room (room3-runtime) y su procesador (room3-compiler). Se añade una quinta, nueva en KMP: sqlite-bundled, el motor de SQLite que viaja dentro de la app (el porqué, en la lección siguiente). Hacen falta ya porque esta lección crea la base.
2 · Las versiones, en [versions]. Abre el catálogo, gradle/libs.versions.toml, y añade al final de [versions] tres líneas:
room = "3.0.3"
sqlite = "2.7.1"
ksp = "2.3.12"3 · Las librerías, en [libraries]. Al final de esa sección (la cabecera ya existe; no la repitas). El módulo de Room es androidx.room3:room3-runtime, con el 3 en el grupo y en el nombre:
androidx-room-runtime = { module = "androidx.room3:room3-runtime", version.ref = "room" }
androidx-room-compiler = { module = "androidx.room3:room3-compiler", version.ref = "room" }
androidx-sqlite-bundled = { module = "androidx.sqlite:sqlite-bundled", version.ref = "sqlite" }4 · Los plugins, en [plugins]. Al final de esa sección:
room = { id = "androidx.room3", version.ref = "room" }
ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" }5 · La raíz, con apply false. Abre build.gradle.kts, el de la raíz del proyecto (no el de shared), y añade dos líneas al bloque plugins, igual que hiciste con el plugin de Un Flow hasta Swift:
alias(libs.plugins.ksp) apply false
alias(libs.plugins.room) apply falseSolo shared usa estos dos plugins, así que el build funcionaría también sin esas dos líneas. Se dejan como en Marcador: la raíz declara una sola vez qué plugins y versiones existen.
6 · El módulo, en tres bloques. Abre shared/build.gradle.kts. Primero, aplica los dos plugins (sin apply false):
alias(libs.plugins.ksp) // KSP: ejecuta procesadores al compilar
alias(libs.plugins.room) // el plugin de Room: crea el bloque room3 { }Después, las dos dependencias de código, dentro de commonMain.dependencies { … }:
// api: `PokedexDatabase` y `createDatabase` (esta la escribirás en la lección
// siguiente) son públicas y nombran tipos de Room
api(libs.androidx.room.runtime)
// El motor de SQLite incrustado en la app: existe para Android, iOS
// y la JVM, que es lo que pide el código común.
implementation(libs.androidx.sqlite.bundled)Y al final del fichero, fuera de kotlin { … }, el bloque room3 (la extensión se llama room3, no room) y las dependencias de KSP. Tu fichero termina hoy con la llave que cierra kotlin { … } y no tiene ningún dependencies { } fuera de ella: los dos bloques nuevos van debajo de esa llave, no dentro, y el dependencies { } es nuevo, no se mezcla con otro:
room3 {
// Carpeta donde Room guarda la foto de cada versión de la tabla
// (1.json, 2.json…). Sin ella no hay migración automática (lo ves en
// «Migraciones de base de datos»).
schemaDirectory("$projectDir/schemas")
}
dependencies {
// Lo propio de KMP. En Marcador era una línea, `ksp(libs.androidx.room.compiler)`.
// Aquí hay un módulo que compila para cuatro destinos, y KSP no mira los
// source sets comunes: hay que darle el procesador a CADA destino. Si falta
// uno, ese destino no compila: el `expect object` se queda sin `actual`.
add("kspAndroid", libs.androidx.room.compiler)
add("kspIosArm64", libs.androidx.room.compiler)
add("kspIosSimulatorArm64", libs.androidx.room.compiler)
add("kspJvm", libs.androidx.room.compiler)
}add("kspAndroid", …) es lo mismo que escribir kspAndroid(…). Una configuración de Gradle es una lista de dependencias con nombre; kspJvm, por ejemplo, es la del procesador para la JVM, y su nombre sale de ksp más el destino con la primera letra en mayúscula. Se escribe con add porque el plugin crea esas listas al sincronizar y el editor no las conoce todavía.
Por qué api: es la decisión de La primera dependencia. Hoy implementation también compilaría, pero las funciones públicas de shared nombran tipos de Room y quien las use mañana los necesitará a la vista.
7 · Sync Now, y comprueba. Pulsa Sync Now. Debe terminar sin rojo (si sale en rojo, mira la caja «Si Sync sale en rojo» de Los ficheros de Gradle de tu proyecto). Para comprobar que KSP tiene su procesador en cada destino, pídele a Gradle una de las listas, en la Terminal de Android Studio (View → Tool Windows → Terminal), desde ~/Pokedex-KMP:
./gradlew :shared:dependencies --configuration kspJvmDebe salir androidx.room3:room3-compiler:3.0.3, con un (n) detrás. Es normal y no es un error: Gradle marca así las listas que solo declaran una dependencia y no se pueden resolver por sí solas. (Con kspIosSimulatorArm64 o kspAndroid sale lo mismo.) Dos errores típicos de esta instalación, que puedes provocar ahora (un tercero espera al paso 3):
ksp(…)a secas, como en Marcador. Es el reflejo natural, y Gradle lo para:The 'ksp' configuration is deprecated in Kotlin Multiplatform projects. Please use target-specific configurations like 'kspJvm' instead.Usaadd("kspJvm", …)y sus hermanos.room { … }en vez deroom3 { … }.Unresolved reference 'room'y, en la línea de dentro,Unresolved reference 'schemaDirectory': la extensión cambió de nombre con el paquete.
Paso 2 · La tabla y el DAO, en código común
Los archivos. FavoriteEntity.kt es la fila de la tabla y FavoriteDao.kt es la lista de consultas. Los dos van en commonMain, en el paquete data/local, y no llevan nada específico de una plataforma.
Créalos. En la vista Project, clic derecho sobre la carpeta shared → New → File y escribe la ruta desde esa carpeta (el método de El módulo compartido y la prueba de humo):
src/commonMain/kotlin/com/sebastiannarvaez/pokedex/data/local/FavoriteEntity.ktEl concepto. La entidad se escribe como las de Marcador (una clase data class con @Entity y @PrimaryKey, de Room: entidades y DAO), con una decisión de diseño: guarda el nombre además del identificador. Pedirlo otra vez significa red, y la lista de favoritos es justo la pantalla que más sentido tiene abrir sin cobertura. Y una columna que no es evidente, addedAt: la hora en que se marcó, como un solo número. Ese número son los milisegundos que han pasado desde el 1 de enero de 1970 a las 00:00 (UTC); es como casi todos los sistemas guardan una hora. Un número mayor es un momento posterior, y por eso sirve para ordenar de más reciente a más antiguo. Míralo con un ejemplo (pégalo en el Kotlin Playground si quieres verlo):
import java.time.Instant
fun main() {
// Una hora guardada como un solo número: los milisegundos que han pasado
// desde el 1 de enero de 1970 a las 00:00 (UTC).
// La `L` final marca el número como `Long`, un entero que admite cifras
// grandes; los `_` solo separan los miles para leerlo mejor.
val antes = 1_000L // 1 000 ms = un segundo después de esa fecha
val despues = 1_760_000_000_000L
// Instant.ofEpochMilli convierte el número en una fecha que se puede leer.
println(Instant.ofEpochMilli(antes))
println(Instant.ofEpochMilli(despues))
// Un número mayor es un momento posterior. Por eso sirve para ordenar.
println(despues > antes)
}1970-01-01T00:00:01Z
2025-10-09T08:53:20Z
trueAhora sí, escribe la entidad:
package com.sebastiannarvaez.pokedex.data.local
import androidx.room3.Entity
import androidx.room3.PrimaryKey
/**
* Un favorito, tal y como se guarda en la base.
*
* Es una clase aparte del modelo de dominio a proposito: una fila tiene tipos
* de columna, clave primaria y anotaciones, y el dominio no debe saber de eso.
* La misma razon por la que los DTO de red tampoco son el dominio.
*
* Guarda el nombre ademas del identificador para poder pintar la lista de
* favoritos **sin red**. Si solo guardara el numero, abrir favoritos en el
* metro daria una pantalla vacia.
*/
@Entity(tableName = "favorite")
data class FavoriteEntity(
@PrimaryKey val pokemonId: Int,
val name: String,
/** Cuando se marco. Sirve para ordenar por lo mas reciente. */
val addedAt: Long,
)Fíjate en los import: son androidx.room3.…, no androidx.room, y el editor no los resolverá hasta que el Sync del paso 1 haya terminado. Ahora el DAO (FavoriteDao.kt, en la misma carpeta, creado igual). Los métodos que leen una vez son suspend; los que observan devuelven Flow. Dos consultas parecidas van aparte a propósito: observeAll() trae las filas completas, para la pantalla de favoritos, y observeIds() solo los números, que es lo único que necesita la rejilla para decidir si el corazón va lleno o vacío (Favoritos en el núcleo lo desarrolla):
package com.sebastiannarvaez.pokedex.data.local
import androidx.room3.Dao
import androidx.room3.Insert
import androidx.room3.OnConflictStrategy
import androidx.room3.Query
import kotlinx.coroutines.flow.Flow
/**
* En Room 3 **todo es corrutina**.
*
* Los metodos que leen una vez son `suspend`; los que observan devuelven
* `Flow`. No hay nada sincrono, y no es un capricho: leer o escribir en una
* base puede tardar, y una funcion normal congelaria la pantalla mientras espera.
*/
@Dao
interface FavoriteDao {
@Query("SELECT * FROM favorite ORDER BY addedAt DESC")
fun observeAll(): Flow<List<FavoriteEntity>>
/** Solo los identificadores: es lo que la lista necesita para pintar el corazon. */
@Query("SELECT pokemonId FROM favorite")
fun observeIds(): Flow<List<Int>>
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun add(favorite: FavoriteEntity)
@Query("DELETE FROM favorite WHERE pokemonId = :pokemonId")
suspend fun remove(pokemonId: Int)
@Query("SELECT EXISTS(SELECT 1 FROM favorite WHERE pokemonId = :pokemonId)")
suspend fun isFavorite(pokemonId: Int): Boolean
@Query("DELETE FROM favorite")
suspend fun clear()
}@Insert(onConflict = OnConflictStrategy.REPLACE) dice qué hacer si ya existe una fila con esa clave primaria: la nueva sustituye a la vieja en vez de dar error, de modo que marcar dos veces el mismo Pokémon no duplica nada. remove borra la fila cuyo identificador coincide con el argumento (:pokemonId es el parámetro de la función). isFavorite pregunta si existe alguna fila con ese identificador: SELECT 1 solo comprueba que haya una, y EXISTS lo convierte en un Boolean. clear vacía la tabla: lo usará el botón «Quitar todos»: lo creas en Pestañas en Android y su equivalente en iOS, en Favoritos en iOS. Los tests de la lección siguiente no lo necesitan, porque cada uno crea su propia base en memoria, que nace vacía.
Paso 3 · La base de datos, y un hueco que rellena el compilador
El archivo. PokedexDatabase.kt junta las entidades en una base. Créalo con la misma ruta de antes (…/data/local/). Va en commonMain, y aquí aparece lo propio de KMP.
El concepto. Room genera, a partir de tu clase abstract, otra clase que la implementa (PokedexDatabase_Impl). Alguien tiene que crearla. La JVM podría buscarla por su nombre mientras corre el programa (eso es la reflexión), pero Kotlin/Native (iOS) no la tiene. Por eso Room usa el mismo camino sin reflexión en todas las plataformas: un expect object que implementa RoomDatabaseConstructor y que sabe crear esa clase. Tú solo lo declaras y lo enlazas con la anotación @ConstructedBy; el procesador de KSP escribe su actual en cada destino, durante la compilación. Cuando más tarde llames a Room.databaseBuilder(...), Room consulta ese objeto por su cuenta: tú no se lo pasas.
package com.sebastiannarvaez.pokedex.data.local
import androidx.room3.ConstructedBy
import androidx.room3.Database
import androidx.room3.RoomDatabase
import androidx.room3.RoomDatabaseConstructor
@Database(entities = [FavoriteEntity::class], version = 1, exportSchema = true)
@ConstructedBy(PokedexDatabaseConstructor::class)
abstract class PokedexDatabase : RoomDatabase() {
abstract fun favoriteDao(): FavoriteDao
}
/**
* Lo genera KSP en cada plataforma. El `expect` sin `actual` visible es
* correcto: por eso hace falta el `@Suppress`, o el IDE marca error en rojo
* sobre codigo que compila perfectamente.
*/
@Suppress("KotlinNoActualForExpect")
expect object PokedexDatabaseConstructor : RoomDatabaseConstructor<PokedexDatabase> {
override fun initialize(): PokedexDatabase
}Compílalo en los cuatro destinos (el procesador corre en cada uno, y cada uno escribe su propio actual):
./gradlew :shared:compileKotlinJvm :shared:compileAndroidMain :shared:compileKotlinIosSimulatorArm64 :shared:compileKotlinIosArm64Debe terminar en BUILD SUCCESSFUL y, entre las tareas, aparecer kspKotlinJvm, kspAndroidMain, kspKotlinIosSimulatorArm64 y kspKotlinIosArm64: una ejecución de KSP por destino. Al compilar para iOS salen además seis avisos nuevos sobre FavoriteDao.kt por cada uno de los dos destinos de iOS (los mismos seis, repetidos): dos de Flow type is exposed to ObjC y cuatro de suspend function is exposed to ObjC. Son los de El ViewModel compartido: el DAO es público y Swift lo ve en el framework, pero nunca lo llamará, así que no hay que arreglarlos.
Mira lo que generó el procesador. Vive en shared/build/generated/ksp/: en el panel de proyecto sale en naranja, porque Android Studio marca esa carpeta como generada. O, con este find en la Terminal:
find shared/build/generated/ksp -name "*.kt"Salen doce ficheros, tres por cada destino (android, iosArm64, iosSimulatorArm64, jvm): PokedexDatabase_Impl.kt (la base), FavoriteDao_Impl.kt (tu DAO con el SQL ya traducido) y PokedexDatabaseConstructor.kt, que es el hueco rellenado:
public actual object PokedexDatabaseConstructor : RoomDatabaseConstructor<PokedexDatabase> {
actual override fun initialize(): PokedexDatabase = com.sebastiannarvaez.pokedex.`data`.local.PokedexDatabase_Impl()
}Y la foto de la tabla, que Room exporta gracias al bloque room3: ls shared/schemas/* muestra 1.json. Es la versión 1 de la tabla favorite; la usarás de verdad en Migraciones de base de datos.
Provócalo: un destino sin su procesador. Comenta la línea add("kspIosSimulatorArm64", libs.androidx.room.compiler) del build.gradle.kts de shared y lanza solo ese destino:
./gradlew :shared:compileKotlinIosSimulatorArm64Falla, y falla pronto: el expect object PokedexDatabaseConstructor se queda sin su actual, porque el único que lo escribía era el procesador que acabas de quitar.
e: …/PokedexDatabase.kt:20:1 The 'expect' declaration 'PokedexDatabaseConstructor' has no 'actual' declaration in module '<commonMain> for Native'.Con kspAndroid pasa igual al compilar Android. Lo que importa es dónde aparece el fallo: si el destino que te dejas fuera no entra en la compilación que lanzas (solo :shared:jvmTest, por ejemplo), todo sale verde y el agujero espera al día en que alguien compile para ese destino, normalmente al preparar una versión. Por eso el punto de control compila los cuatro a la vez. Descomenta la línea antes de seguir.
Y el tercer error de la instalación, ahora que el expect object ya existe: add("kspCommonMainMetadata", …), una línea que se ve en guías de Room 2. Con Room 3 rompe la tarea que compila el código común (./gradlew :shared:compileCommonMainKotlinMetadata) con PokedexDatabaseConstructor: 'expect' and corresponding 'actual' are declared in the same module., porque el procesador generaría el actual dentro del mismo módulo común que declara el expect. No la pongas.
Punto de control
./gradlew :shared:compileKotlinJvm :shared:compileAndroidMain :shared:compileKotlinIosSimulatorArm64 :shared:compileKotlinIosArm64termina enBUILD SUCCESSFUL: el procesador de KSP corre en los cuatro destinos.find shared/build/generated/ksp -name "*_Impl.kt"listaPokedexDatabase_Impl.ktyFavoriteDao_Impl.ktenandroid,iosArm64,iosSimulatorArm64yjvm.- Existe
shared/schemas/com.sebastiannarvaez.pokedex.data.local.PokedexDatabase/1.json(compruébalo conls shared/schemas/*). - En
libs.versions.tomlel módulo esandroidx.room3:room3-runtime, noandroidx.room:room-runtime, y no hay ninguna entrada deroom3-testing. ./gradlew :shared:jvmTest :shared:testAndroidHostTest :shared:iosSimulatorArm64Testtermina enBUILD SUCCESSFULcon 41 tests en la JVM, 40 en Android host y 42 en iOS, sin contar los de la carpetaejercicios: los mismos que al terminar la búsqueda, porque hoy no se escribe ningún test.- La app de Android y la de iOS arrancan igual que antes. Todavía no hay nada que guarde nada.
Falta lo que hace que la base exista de verdad: el driver que habla con SQLite, la ruta del fichero en cada plataforma y el primer test. Es La base en cada plataforma.
Comprueba lo que entendiste
6 preguntas · necesitas 5 para completar la lección
1.¿Por qué los módulos de Room 3 se llaman
androidx.room3:…, con un nombre nuevo, en vez deandroidx.room:…?2.Te dejas fuera
add("kspIosArm64", libs.androidx.room.compiler). ¿Cuándo te enteras?3.En
shared/build.gradle.kts, ¿cómo se llama la extensión que configuraschemaDirectory?4.¿Por qué la clase que crea la base la escribe el procesador de KSP en cada destino (el
actual) en vez de buscarla Room por su nombre al arrancar?5.¿Por qué
FavoriteEntityguarda el nombre además del número del Pokémon?6.¿Qué guarda la columna
addedAty por qué sirve para ordenar de más reciente a más antiguo?