Saltar al contenido
K
Favoritos con RoomPaso a paso28 min Avanzado

La base en cada plataforma

Al terminar: Hacer que la base arranque igual en Android y en iOS, y ver qué pasa si falta una de las piezas de cada plataforma.

La tabla, el DAO y la base ya compilan en los cuatro destinos, pero ninguna plataforma sabe todavía con qué motor habla ni dónde guarda el fichero. Al terminar esta lección la base abre igual en Android, en iOS y en la JVM, y cuatro tests comprueban el DAO sin emulador.

Son tres decisiones, y cada una tiene su porqué: el driver de SQLite (uno que exista en todas partes, con su peaje), la ruta del fichero (lo único que de verdad cambia por plataforma) y dónde viven los tests.

shared
src/commonMain/kotlin/com/sebastiannarvaez/pokedex/data/local/Database.ktse crea (paso 1)
src/androidMain/kotlin/com/sebastiannarvaez/pokedex/data/local/DatabaseBuilder.android.ktse crea (paso 2)
src/iosMain/kotlin/com/sebastiannarvaez/pokedex/data/local/DatabaseBuilder.ios.ktse crea (paso 2)
src/jvmMain/kotlin/com/sebastiannarvaez/pokedex/data/local/DatabaseBuilder.jvm.ktse crea (paso 2)
src/commonMain/kotlin/com/sebastiannarvaez/pokedex/di/Modules.ktse modifica (paso 2)
src/androidMain/kotlin/com/sebastiannarvaez/pokedex/di/Modules.android.ktse modifica (paso 2)
src/iosMain/kotlin/com/sebastiannarvaez/pokedex/di/Modules.ios.ktse modifica (paso 2)
src/jvmMain/kotlin/com/sebastiannarvaez/pokedex/di/Modules.jvm.ktse modifica (paso 2)
src/jvmTest/kotlin/com/sebastiannarvaez/pokedex/data/local/FavoriteDaoTest.ktse crea (paso 3)
Lo que se toca hoy en el módulo shared (rutas desde su carpeta).

Paso 1 · El driver: uno que exista en todas partes

El archivo. Database.kt es la fábrica de la base: recibe la receta de la base (un RoomDatabase.Builder), le pone el driver y construye. Un builder («constructor paso a paso») es un objeto al que se le van dando ajustes —aquí, .setDriver(…)— y al final se le pide el resultado con .build(), como rellenar un pedido y entregarlo. Va en commonMain. Créalo con clic derecho sobre la carpeta shared → New → File y la ruta src/commonMain/kotlin/com/sebastiannarvaez/pokedex/data/local/Database.kt, como en la lección anterior.

El concepto. El driver es la pieza que habla con SQLite (lo viste en Room: entidades y DAO). Hay dos familias: los que usan el SQLite que ya trae el sistema y el que lleva su propio SQLite dentro de la app.

  • Los del sistema son uno por plataforma. AndroidSQLiteDriver usa el SQLite que trae Android y solo existe en Android: escrito en commonMain no compila (Unresolved reference 'AndroidSQLiteDriver'), solo cabe en androidMain. En iOS hace lo mismo NativeSQLiteDriver, y pide un ajuste extra al construir la app (lo cuenta la caja de abajo).
  • BundledSQLiteDriver, de la librería sqlite-bundled que instalaste, incrusta SQLite en la app. Existe en Android, iOS y la JVM, así que cabe en común. Es el que usa el curso.

El linker (enlazador) es el último paso de construir una app de iOS: une tu código con las librerías del sistema, y -lsqlite3 es la orden que le dice «une también el SQLite del sistema». Con el driver incrustado esa orden no hace falta.

Cuesta unos cientos de kilobytes de binario y, a cambio, el SQL que pruebas en la JVM es el mismo que corre en el teléfono de alguien. Escribe el fichero:

shared/src/commonMain/kotlin/com/sebastiannarvaez/pokedex/data/local/Database.kt
package com.sebastiannarvaez.pokedex.data.local
 
import androidx.room3.RoomDatabase
import androidx.sqlite.driver.bundled.BundledSQLiteDriver
 
internal const val DATABASE_FILE_NAME = "pokedex.db"
 
/**
 * El driver se elige una sola vez, aqui.
 *
 * `BundledSQLiteDriver` incrusta SQLite en la app en vez de usar el del
 * sistema: la misma version del motor en Android, en iOS y en la JVM, sin
 * sorpresas de «en mi movil si funciona».
 */
fun createDatabase(builder: RoomDatabase.Builder<PokedexDatabase>): PokedexDatabase =
    builder
        .setDriver(BundledSQLiteDriver())
        .build()

const val declara una constante que se conoce al compilar (el nombre del fichero no cambia nunca) e internal la deja visible solo dentro del módulo shared, que es de quien es el fichero (Propiedades y visibilidad).

Hay un peaje, y Room lo confirma: ya lo anunció El módulo compartido y la prueba de humo. El proyecto no declara iosX64, el destino del simulador en un Mac con procesador Intel. Room 3 tampoco lo publica (ni androidx.sqlite), igual que Paging, que ya usas desde Paginación con Paging 3. Si lo añadieras al bloque de destinos, Gradle no compilaría ni la primera línea. El mensaje empieza así y se repite para cada librería que no lo publica y para cada carpeta común; aquí va el primero:

e: ❌ KMP Dependencies Resolution Failure
Source set 'commonMain' couldn't resolve dependencies for all target platforms
Couldn't resolve dependency 'androidx.room3:room3-runtime:3.0.3' in 'commonMain' for all target platforms.
The dependency should target platforms: [android, iosArm64, iosSimulatorArm64, iosX64, jvm]
Unresolved platforms: [iosX64]

La consecuencia: el simulador de un Mac con procesador Intel deja de ser una opción. Si en un equipo hay Macs Intel, esa conversación se tiene antes de elegir Room 3, no después.

Paso 2 · Lo único por plataforma es la ruta

Los archivos. Tres ficheros pequeños, uno por carpeta de código, con el nombre DatabaseBuilder.<plataforma>.kt en data/local. Créalos como el anterior pero en su carpeta: src/androidMain/…, src/iosMain/… y src/jvmMain/… (la ruta completa, cambiando commonMain por el source set). Tres cosas que parecen raras:

  • Las tres funciones se llaman igual, getDatabaseBuilder, y no chocan: cada carpeta de código (source set) se compila solo para su plataforma, así que la de Android y la de iOS nunca están en la misma compilación.
  • No son un expect/actual (expect/actual frente a interfaces): ese mecanismo exige la misma firma en todas, y aquí no la tienen, porque la de Android necesita un Context y las otras dos no.
  • Quien da a cada plataforma la suya es Koin: cada una registra la suya en su platformModule y el código común la pide con get() sin saber cuál recibe. Ese platformModule sí es un expect/actual, el de Koin que ya conoces: en Modules.kt hay un expect val platformModule: Module y cada plataforma escribe su actual val platformModule (lo ves abajo).

Android primero, con el contexto de la aplicación.

context.getDatabasePath(DATABASE_FILE_NAME) le pide al sistema la ruta del fichero dentro de la carpeta de bases de datos que Android reserva a cada app, y .absolutePath la convierte en la ruta completa, como texto:

shared/src/androidMain/kotlin/com/sebastiannarvaez/pokedex/data/local/DatabaseBuilder.android.kt
package com.sebastiannarvaez.pokedex.data.local
 
import android.content.Context
import androidx.room3.Room
import androidx.room3.RoomDatabase
 
/**
 * Lo unico que cambia por plataforma: donde esta el fichero.
 *
 * Es una funcion normal, no un `expect/actual`. La decision de que
 * implementacion usar la toma Koin, que es donde debe tomarse.
 */
fun getDatabaseBuilder(context: Context): RoomDatabase.Builder<PokedexDatabase> =
    Room.databaseBuilder<PokedexDatabase>(
        context = context.applicationContext,
        name = context.getDatabasePath(DATABASE_FILE_NAME).absolutePath,
    )

iOS, con una decisión de carpeta que importa más de lo que parece. El fichero usa cinterop, el mecanismo con el que Kotlin/Native llama a las APIs de Apple (aquí, para preguntar dónde está la carpeta Documents); por eso lleva @OptIn(ExperimentalForeignApi::class), el permiso que pide ese mecanismo. Y aparecen en él dos formas de Kotlin de decir «esto no debería ser null»: ?: error("…") y requireNotNull(…). Las dos lanzan una excepción (el aviso con el que un programa se detiene cuando algo sale mal, de Excepciones: try, catch y require) si encuentran null: requireNotNull corta con un error si el valor es null, y ?: error("…") corta con tu mensaje (?: es el «si es null, usa esto» de Nulabilidad). Aquí no debería pasar nunca; si pasara, la app se detendría con un mensaje que dice qué falló en vez de seguir con una ruta rota. Mira las dos en acción, con su salida:

ejemplo mínimo: dos formas de decir «esto no debería ser null» (con comentarios)
fun carpetaQueExiste(): String? = "/datos"
fun carpetaQueNoExiste(): String? = null   // imagina que el sistema no la encontró
 
fun main() {
    // Con un valor que existe, las dos formas lo devuelven tal cual.
    println(requireNotNull(carpetaQueExiste()))
    println(carpetaQueExiste() ?: error("no se pudo resolver la carpeta"))
 
    // Con null, las dos lanzan una excepción. runCatching { … } ejecuta el bloque sin
    // dejar que el programa se detenga, y .exceptionOrNull() entrega la excepción
    // (IllegalArgumentException: un argumento no vale; IllegalStateException: el
    // programa llegó a un estado imposible) para poder imprimirla.
    println(runCatching { requireNotNull(carpetaQueNoExiste()) }.exceptionOrNull())
    println(runCatching { carpetaQueNoExiste() ?: error("no se pudo resolver la carpeta") }.exceptionOrNull())
}
/datos
/datos
java.lang.IllegalArgumentException: Required value was null.
java.lang.IllegalStateException: no se pudo resolver la carpeta

Y el fichero de iOS:

shared/src/iosMain/kotlin/com/sebastiannarvaez/pokedex/data/local/DatabaseBuilder.ios.kt
package com.sebastiannarvaez.pokedex.data.local
 
import androidx.room3.Room
import androidx.room3.RoomDatabase
import kotlinx.cinterop.ExperimentalForeignApi
import platform.Foundation.NSDocumentDirectory
import platform.Foundation.NSFileManager
import platform.Foundation.NSURL
import platform.Foundation.NSUserDomainMask
 
/**
 * En Documents y no en Caches: el sistema puede vaciar Caches cuando le hace
 * falta espacio, y los favoritos del usuario no son cache.
 */
@OptIn(ExperimentalForeignApi::class)
fun getDatabaseBuilder(): RoomDatabase.Builder<PokedexDatabase> {
    val documentos: NSURL = NSFileManager.defaultManager.URLForDirectory(
        directory = NSDocumentDirectory,
        inDomain = NSUserDomainMask,
        appropriateForURL = null,
        create = false,
        error = null,   // este `error` es un parámetro de Apple, no la función `error()` de abajo
    ) ?: error("no se pudo resolver el directorio de documentos")
 
    return Room.databaseBuilder<PokedexDatabase>(
        name = requireNotNull(documentos.path) + "/" + DATABASE_FILE_NAME,
    )
}

La JVM tiene el suyo, en memoria, porque solo existe para los tests y un fichero compartido entre tests es estado que se arrastra de uno a otro:

shared/src/jvmMain/kotlin/com/sebastiannarvaez/pokedex/data/local/DatabaseBuilder.jvm.kt
package com.sebastiannarvaez.pokedex.data.local
 
import androidx.room3.Room
import androidx.room3.RoomDatabase
 
/**
 * En memoria: la JVM solo existe para los tests, y un fichero compartido entre
 * tests es estado que se arrastra de uno a otro.
 */
fun getDatabaseBuilder(): RoomDatabase.Builder<PokedexDatabase> =
    Room.inMemoryDatabaseBuilder<PokedexDatabase>(factory = PokedexDatabaseConstructor::initialize)

Fíjate en factory = PokedexDatabaseConstructor::initialize: es el hueco de la lección anterior usándose, pasado de forma explícita. El :: entrega la función initialize sin ejecutarla, para que Room la llame cuando le haga falta (Lambdas con listas: filter, map y reglas lo explica). En Android y en iOS no hace falta pasarlo (Room lo localiza por @ConstructedBy); aquí se deja a la vista para que veas cuál es la pieza que usa. Si lo quitas, el constructor en memoria también funciona.

Koin los registra. Cada plataforma ya tiene su platformModule (de Koin: un solo grafo en commonMain), vacío hasta hoy. Se llena con su receta de la base. androidContext() devuelve el Context que la app le entregó a Koin al arrancar (Arrancar Koin desde Android y desde iOS). En Modules.android.kt, Modules.ios.kt y Modules.jvm.kt cambia el módulo vacío por estos (con los import que cada uno pida):

shared/src/androidMain/kotlin/com/sebastiannarvaez/pokedex/di/Modules.android.kt
actual val platformModule: Module = module {
    single { getDatabaseBuilder(androidContext()) }
}
shared/src/iosMain/kotlin/com/sebastiannarvaez/pokedex/di/Modules.ios.kt
actual val platformModule: Module = module {
    single { getDatabaseBuilder() }
}
shared/src/jvmMain/kotlin/com/sebastiannarvaez/pokedex/di/Modules.jvm.kt
actual val platformModule: Module = module {
    single { getDatabaseBuilder() }
}

Los import nuevos de cada fichero: com.sebastiannarvaez.pokedex.data.local.getDatabaseBuilder en los tres y, solo en Android, org.koin.android.ext.koin.androidContext. Y el código común solo pide lo que ya está montado. En Modules.kt, dentro del module { … } de pokedexModule, junto a las líneas single { …Repository(…) } y con dos import más (PokedexDatabase y createDatabase, de data.local). Aquí get() pide a Koin el RoomDatabase.Builder<PokedexDatabase> que registró el platformModule de la plataforma en la que corre, y createDatabase lo convierte en la base. En la segunda línea, get<PokedexDatabase>() lleva el tipo entre <> porque ahí no hay nada que le diga a Koin qué quieres (en la primera, Kotlin lo deduce del parámetro de createDatabase):

shared/src/commonMain/kotlin/com/sebastiannarvaez/pokedex/di/Modules.kt
    single { createDatabase(get()) }
    single { get<PokedexDatabase>().favoriteDao() }

single quiere decir una sola base para toda la app: Koin la crea la primera vez que alguien la pide y reparte siempre esa misma. No es un capricho de estilo; Google lo pide así:

Paso 3 · El test vive en la JVM, y no es una rebaja

El archivo. FavoriteDaoTest.kt, en src/jvmTest/kotlin/com/sebastiannarvaez/pokedex/data/local/. Usa Turbine (.test { … }), la librería para comprobar lo que emite un Flow: se instaló en Testing del núcleo compartido y cómo se usa lo viste en Tests con backgroundScope y Turbine.

El concepto. Vive en jvmTest y no en commonTest por una razón práctica: en la JVM, Room monta la base en memoria (getDatabaseBuilder() de la JVM), sin emulador ni simulador, y el test corre en un segundo. Probarlo en Android exigiría Robolectric (una librería que simula el sistema Android dentro de la JVM) o un emulador, y en iOS un simulador y una base en un fichero real. No se pierde nada importante, porque el SQL es el mismo en las tres plataformas: el motor va incrustado.

shared/src/jvmTest/kotlin/com/sebastiannarvaez/pokedex/data/local/FavoriteDaoTest.kt
package com.sebastiannarvaez.pokedex.data.local
 
import app.cash.turbine.test
import kotlinx.coroutines.test.runTest
import kotlin.test.AfterTest
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertTrue
 
class FavoriteDaoTest {
 
    // kotlin.test crea una instancia nueva de la clase para cada @Test, así que
    // cada test arranca con su propia base, vacía.
    private val db = createDatabase(getDatabaseBuilder())
    private val dao = db.favoriteDao()
 
    @AfterTest
    fun cerrar() = db.close()
 
    @Test
    fun guardaYRecuperaUnFavorito() = runTest {
        dao.add(FavoriteEntity(pokemonId = 25, name = "pikachu", addedAt = 1_000))
 
        assertTrue(dao.isFavorite(25))
        assertFalse(dao.isFavorite(26))
    }
 
    @Test
    fun losMasRecientesVanPrimero() = runTest {
        dao.add(FavoriteEntity(1, "bulbasaur", addedAt = 1_000))
        dao.add(FavoriteEntity(25, "pikachu", addedAt = 3_000))
        dao.add(FavoriteEntity(4, "charmander", addedAt = 2_000))
 
        dao.observeAll().test {
            assertEquals(listOf("pikachu", "charmander", "bulbasaur"), awaitItem().map { it.name })
            cancelAndIgnoreRemainingEvents()
        }
    }
 
    @Test
    fun marcarDosVecesNoDuplica() = runTest {
        // REPLACE y no IGNORE: si el usuario vuelve a marcarlo, se actualiza la
        // fecha, que es lo que espera al ver la lista ordenada por reciente.
        dao.add(FavoriteEntity(25, "pikachu", addedAt = 1_000))
        dao.add(FavoriteEntity(25, "pikachu", addedAt = 5_000))
 
        dao.observeAll().test {
            val todos = awaitItem()
            assertEquals(1, todos.size)
            assertEquals(5_000, todos.first().addedAt)
            cancelAndIgnoreRemainingEvents()
        }
    }
 
    @Test
    fun elFlujoAvisaAlQuitarUnFavorito() = runTest {
        dao.add(FavoriteEntity(25, "pikachu", addedAt = 1_000))
 
        dao.observeIds().test {
            assertEquals(listOf(25), awaitItem())
            dao.remove(25)
            // Room reemite solo: la pantalla no tiene que preguntar.
            assertEquals(emptyList(), awaitItem())
            cancelAndIgnoreRemainingEvents()
        }
    }
}

El último test es el que ahorra media pantalla de código más adelante: Room reemite el flujo solo al cambiar la tabla. Lánzalos:

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

Los cuatro pasan. La JVM prueba el SQL. Que la base también abre en iOS (carpeta Documents, driver incrustado) lo verás al marcar un favorito en el simulador, en Favoritos en iOS: hoy el punto de control comprueba que iOS compila y que sus tests siguen en verde, pero no abre la base allí.

Punto de control

  • ./gradlew :shared:jvmTest --tests "*FavoriteDaoTest*" pasa, con los cuatro tests en verde.
  • ./gradlew :shared:jvmTest :shared:testAndroidHostTest :shared:iosSimulatorArm64Test termina en BUILD SUCCESSFUL con 45 tests en la JVM, 40 en Android host y 42 en iOS, sin contar los de la carpeta ejercicios: los cuatro de hoy son de la JVM (de 41 a 45).
  • ./gradlew :shared:compileKotlinJvm :shared:compileAndroidMain :shared:compileKotlinIosSimulatorArm64 :shared:compileKotlinIosArm64 sigue en BUILD SUCCESSFUL: los cuatro destinos compilan con el driver y las tres rutas.
  • Existen DatabaseBuilder.android.kt, DatabaseBuilder.ios.kt y DatabaseBuilder.jvm.kt, cada uno en la carpeta de código de su plataforma, y Database.kt en commonMain.
  • El platformModule de cada plataforma registra su getDatabaseBuilder, y Modules.kt crea la base con createDatabase(get()).
  • La app de Android y la de iOS arrancan igual que antes. Todavía no hay nada que guarde nada.

Comprueba lo que entendiste

5 preguntas · necesitas 4 para completar la lección

  1. 1.¿Qué se gana usando BundledSQLiteDriver en lugar de AndroidSQLiteDriver?

  2. 2.¿Por qué FavoriteDaoTest está en jvmTest y no en commonTest?

  3. 3.¿Por qué la base se registra en Koin con single { createDatabase(get()) } y no con factory?

  4. 4.¿Por qué el fichero de la base en iOS va en la carpeta Documents y no en Caches?

  5. 5.Añades iosX64() a los destinos del módulo shared. ¿Qué pasa al sincronizar y compilar?

Faltan 5 de 5