Configura la base de datos de Room para KMP

La biblioteca de persistencias Room brinda una capa de abstracción sobre SQLite que permite acceder a la base de datos sin problemas y, al mismo tiempo, aprovechar toda la potencia de SQLite. En esta página, se explica cómo usar Room en proyectos de Kotlin Multiplatform (KMP). Para obtener más información sobre el uso de Room, consulta Cómo guardar contenido en una base de datos local con Room o nuestros ejemplos oficiales.

Configura dependencias

Para configurar Room en tu proyecto de KMP, agrega las dependencias de los artefactos en el archivo build.gradle.kts de tu módulo de KMP.

Define las dependencias en el archivo libs.versions.toml:

[versions]
room3 = "3.0.1"
sqlite = "2.7.0"
ksp = "<kotlinCompatibleKspVersion>"

[libraries]
androidx-sqlite-bundled = { module = "androidx.sqlite:sqlite-bundled", version.ref = "sqlite" }
androidx-room3-runtime = { module = "androidx.room3:room3-runtime", version.ref = "room3" }
androidx-room3-compiler = { module = "androidx.room3:room3-compiler", version.ref = "room3" }

# Optional SQLite Wrapper
androidx-room3-sqlite-wrapper = { module = "androidx.room3:room3-sqlite-wrapper", version.ref = "room3" }

[plugins]
ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" }
androidx-room3 = { id = "androidx.room3", version.ref = "room3" }

Agrega el complemento de Gradle de Room para configurar los esquemas de Room y el KSP complemento.

plugins {
  alias(libs.plugins.ksp)
  alias(libs.plugins.androidx.room3)
}

Agrega la dependencia del tiempo de ejecución de Room y la biblioteca de SQLite agrupada:

commonMain.dependencies {
  implementation(libs.androidx.room3.runtime)
  implementation(libs.androidx.sqlite.bundled)
}

// Optional when using Room SQLite Wrapper
androidMain.dependencies {
  implementation(libs.androidx.room3.sqlite.wrapper)
}

Agrega las dependencias de KSP al bloque dependencies raíz. Debes agregar todos los destinos que usa tu app. Para obtener más información, consulta KSP with Kotlin Multiplatform.

dependencies {
    add("kspAndroid", libs.androidx.room3.compiler)
    add("kspIosSimulatorArm64", libs.androidx.room3.compiler)
    add("kspIosX64", libs.androidx.room3.compiler)
    add("kspIosArm64", libs.androidx.room3.compiler)
    // Add any other platform target you use in your project, for example kspDesktop
}

Define el directorio del esquema de Room. Para obtener información adicional, consulta Cómo establecer la ubicación del esquema con el complemento de Gradle de Room.

room {
    schemaDirectory("$projectDir/schemas")
}

Define clases de base de datos

Debes crear una clase de base de datos anotada con @Database junto con DAOs y entidades dentro del conjunto de orígenes comunes de tu módulo de KMP compartido. Si colocas estas clases en fuentes comunes, se podrán compartir en todas las plataformas de destino.

// shared/src/commonMain/kotlin/Database.kt

@Database(entities = [TodoEntity::class], version = 1)
@ConstructedBy(AppDatabaseConstructor::class)
abstract class AppDatabase : RoomDatabase() {
  abstract fun getDao(): TodoDao
}

// The Room compiler generates the `actual` implementations.
@Suppress("KotlinNoActualForExpect")
expect object AppDatabaseConstructor : RoomDatabaseConstructor<AppDatabase> {
    override fun initialize(): AppDatabase
}

Cuando declaras un objeto expect con la interfaz RoomDatabaseConstructor, el compilador de Room genera las implementaciones actual. Android Studio podría emitir la siguiente advertencia, que puedes suprimir con @Suppress("KotlinNoActualForExpect"):

Expected object 'AppDatabaseConstructor' has no actual declaration in module`

A continuación, define una nueva interfaz DAO o mueve una existente a commonMain:

// shared/src/commonMain/kotlin/TodoDao.kt

@Dao
interface TodoDao {
  @Insert
  suspend fun insert(item: TodoEntity)

  @Query("SELECT count(*) FROM TodoEntity")
  suspend fun count(): Int

  @Query("SELECT * FROM TodoEntity")
  fun getAllAsFlow(): Flow<List<TodoEntity>>
}

Define o mueve tus entidades a commonMain:

// shared/src/commonMain/kotlin/TodoEntity.kt

@Entity
data class TodoEntity(
  @PrimaryKey(autoGenerate = true) val id: Long = 0,
  val title: String,
  val content: String
)

Crea compiladores de bases de datos específicos de la plataforma

Debes definir un compilador de bases de datos para crear instancias de Room en cada plataforma. Esta es la única parte de la API que debes definir en conjuntos de fuentes específicos de la plataforma, debido a las diferencias en las APIs del sistema de archivos.

Android

En Android, por lo general, obtienes la ubicación de la base de datos con la Context.getDatabasePath API. Para crear la instancia de base de datos, especifica un Context y la ruta de acceso de la base de datos.

// shared/src/androidMain/kotlin/Database.android.kt

fun getDatabaseBuilder(context: Context): RoomDatabase.Builder<AppDatabase> {
  val appContext = context.applicationContext
  val dbFile = appContext.getDatabasePath("my_room.db")
  return Room.databaseBuilder<AppDatabase>(
    context = appContext,
    name = dbFile.absolutePath
  )
}

iOS

Para crear la instancia de base de datos en iOS, proporciona una ruta de acceso de la base de datos con el NSFileManager, que suele ubicarse en el NSDocumentDirectory.

// shared/src/iosMain/kotlin/Database.ios.kt

fun getDatabaseBuilder(): RoomDatabase.Builder<AppDatabase> {
    val dbFilePath = documentDirectory() + "/my_room.db"
    return Room.databaseBuilder<AppDatabase>(
        name = dbFilePath,
    )
}

private fun documentDirectory(): String {
  val documentDirectory = NSFileManager.defaultManager.URLForDirectory(
    directory = NSDocumentDirectory,
    inDomain = NSUserDomainMask,
    appropriateForURL = null,
    create = false,
    error = null,
  )
  return requireNotNull(documentDirectory?.path)
}

Escritorio de JVM

Para crear la instancia de base de datos, proporciona una ruta de acceso de la base de datos con las APIs de Java o Kotlin.

// shared/src/jvmMain/kotlin/Database.desktop.kt

fun getDatabaseBuilder(): RoomDatabase.Builder<AppDatabase> {
    val dbFile = File(System.getProperty("java.io.tmpdir"), "my_room.db")
    return Room.databaseBuilder<AppDatabase>(
        name = dbFile.absolutePath,
    )
}

Crea una instancia de la base de datos

Una vez que obtengas el RoomDatabase.Builder de uno de los constructores específicos de la plataforma, podrás configurar el resto de la base de datos de Room en código común junto con la creación de instancias de la base de datos real.

// shared/src/commonMain/kotlin/Database.kt

fun getRoomDatabase(
    builder: RoomDatabase.Builder<AppDatabase>
): AppDatabase {
  return builder
      .setDriver(BundledSQLiteDriver())
      .setQueryCoroutineContext(Dispatchers.IO)
      .build()
}

Selecciona un controlador de SQLite

El fragmento de código anterior llama a la función del compilador setDriver para definir qué controlador de SQLite debe usar la base de datos de Room. Estos controladores difieren según la plataforma de destino. Los fragmentos de código anteriores usan BundledSQLiteDriver. Es el controlador recomendado que incluye SQLite compilado desde la fuente, que proporciona la versión más coherente y actualizada de SQLite en todas las plataformas.

Si deseas usar el SQLite proporcionado por el SO, usa la API de setDriver en los conjuntos de fuentes específicos de la plataforma que especifican un controlador específico de la plataforma. Consulta Implementaciones de controladores para obtener descripciones de las implementaciones de controladores disponibles. Puedes usar cualquiera de las siguientes opciones:

Para usar NativeSQLiteDriver, debes proporcionar una opción de vinculador -lsqlite3 para que la app para iOS se vincule de forma dinámica con el SQLite del sistema.

// shared/build.gradle.kts

kotlin {
    listOf(
        iosX64(),
        iosArm64(),
        iosSimulatorArm64()
    ).forEach { iosTarget ->
        iosTarget.binaries.framework {
            baseName = "TodoApp"
            isStatic = true
            // Required when using NativeSQLiteDriver
            linkerOpts.add("-lsqlite3")
        }
    }
}

Establece un contexto de corrutina

Un objeto RoomDatabase en Android se puede configurar de manera opcional con ejecutores de aplicaciones compartidas mediante RoomDatabase.Builder.setQueryExecutor para realizar operaciones de bases de datos.

Debido a que los ejecutores no son compatibles con KMP, la API de setQueryExecutor de Room no está disponible en commonMain. En su lugar, el RoomDatabase objeto debe configurarse con un CoroutineContext, que puedes configurar con RoomDatabase.Builder.setCoroutineContext. Si no estableces un contexto, el objeto RoomDatabase se establece de forma predeterminada en Dispatchers.IO.

Reducción y ofuscación

Si el proyecto se reduce o se ofusca, debes incluir la siguiente regla de ProGuard para que Room pueda encontrar la implementación generada de la definición de la base de datos:

-keep class * extends androidx.room3.RoomDatabase { <init>(); }

Migra a Kotlin Multiplatform

Room se desarrolló originalmente como una biblioteca de Android y, luego, se migró a KMP con un enfoque en la compatibilidad con la API. La versión de KMP de Room difiere un poco entre las plataformas y de la versión específica de Android. Estas diferencias se enumeran y describen de la siguiente manera.

Migra de Support SQLite a SQLite Driver

Cualquier uso de SupportSQLiteDatabase y otras APIs en androidx.sqlite.db debe refactorizarse con las APIs de SQLite Driver, ya que las APIs en androidx.sqlite.db son solo para Android (observa el paquete diferente del paquete KMP).

Para la retrocompatibilidad, y siempre que RoomDatabase esté configurado con un SupportSQLiteOpenHelper.Factory (por ejemplo, no se establece SQLiteDriver), Room se comporta en "modo de compatibilidad", en el que las APIs de Support SQLite y SQLite Driver funcionan como se espera. Esto permite migraciones incrementales para que no necesites convertir todos los usos de Support SQLite a SQLite Driver en un solo cambio.

Usa el wrapper de SQLite de Room

El artefacto androidx.room3:room3-sqlite-wrapper proporciona APIs para establecer un puente entre SQLiteDriver y SupportSQLiteDatabase durante la migración.

Para obtener un SupportSQLiteDatabase de un RoomDatabase configurado con un SQLiteDriver, usa la nueva función de extensión RoomDatabase.getSupportWrapper. Este wrapper de compatibilidad ayuda a mantener los usos existentes de SupportSQLiteDatabase, que a menudo obtienes de RoomDatabase.openHelper.writableDatabase, mientras adoptas SQLiteDriver, en especial para bases de código con usos extensos de la API de SupportSQLite que desean usar BundledSQLiteDriver.

Convierte subclases de migración

Las subclases de migraciones deben migrarse a las contrapartes del controlador de SQLite:

Kotlin multiplataforma

Subclases de migración

object Migration_1_2 : Migration(1, 2) {
  override fun migrate(connection: SQLiteConnection) {
    // …
  }
}

Subclases de especificación de migración automática

class AutoMigrationSpec_1_2 : AutoMigrationSpec {
  override fun onPostMigrate(connection: SQLiteConnection) {
    // …
  }
}

Solo para Android

Subclases de migración

object Migration_1_2 : Migration(1, 2) {
  override fun migrate(db: SupportSQLiteDatabase) {
    // …
  }
}

Subclases de especificación de migración automática

class AutoMigrationSpec_1_2 : AutoMigrationSpec {
  override fun onPostMigrate(db: SupportSQLiteDatabase) {
    // …
  }
}

Convierte la devolución de llamada de la base de datos

Las devoluciones de llamada de la base de datos deben migrarse a las contrapartes del controlador de SQLite:

Kotlin multiplataforma

object MyRoomCallback : RoomDatabase.Callback() {
  override fun onCreate(connection: SQLiteConnection) {
    // …
  }

  override fun onDestructiveMigration(connection: SQLiteConnection) {
    // …
  }

  override fun onOpen(connection: SQLiteConnection) {
    // …
  }
}

Solo para Android

object MyRoomCallback : RoomDatabase.Callback() {
  override fun onCreate(db: SupportSQLiteDatabase) {
    // …
  }

  override fun onDestructiveMigration(db: SupportSQLiteDatabase) {
    // …
  }

  override fun onOpen(db: SupportSQLiteDatabase) {
    // …
  }
}

Convierte funciones DAO @RawQuery

Las funciones anotadas con @RawQuery que se compilan para plataformas que no son de Android deberán declarar un parámetro de tipo RoomRawQuery en lugar de SupportSQLiteQuery.

Kotlin multiplataforma

Define la consulta sin procesar

@Dao
interface TodoDao {
  @RawQuery
  suspend fun getTodos(query: RoomRawQuery): List<TodoEntity>
}

Luego, se puede usar un RoomRawQuery para crear una consulta en el tiempo de ejecución:

suspend fun AppDatabase.getTodosWithLowercaseTitle(title: String): List<TodoEntity> {
    val query = RoomRawQuery(
        sql = "SELECT * FROM TodoEntity WHERE title = ?",
        onBindStatement = {
            it.bindText(1, title.lowercase())
        }
    )

    return todoDao().getTodos(query)
}

Solo para Android

Define la consulta sin procesar

@Dao
interface TodoDao {
  @RawQuery
  suspend fun getTodos(query: SupportSQLiteQuery): List<TodoEntity>
}

Luego, se puede usar un SimpleSQLiteQuery para crear una consulta en el tiempo de ejecución:

suspend fun AndroidOnlyDao.getTodosWithLowercaseTitle(title: String): List<TodoEntity> {
  val query = SimpleSQLiteQuery(
      query = "SELECT * FROM TodoEntity WHERE title = ?",
      bindArgs = arrayOf(title.lowercase())
  )
  return getTodos(query)
}

Convierte funciones DAO de bloqueo

Room se beneficia de la biblioteca asíncrona kotlinx.coroutines enriquecida en funciones que ofrece Kotlin para varias plataformas. Para una funcionalidad óptima, se aplican funciones suspend para los DAOs compilados en un proyecto de KMP, con la excepción de los DAOs implementados en androidMain para mantener la retrocompatibilidad con la base de código existente. Cuando se usa Room para KMP, todas las funciones DAO compiladas para plataformas que no son de Android deben ser funciones suspend.

Kotlin multiplataforma

Consultas de suspensión

@Query("SELECT * FROM Todo")
suspend fun getAllTodos(): List<Todo>

Transacciones de suspensión

@Transaction
suspend fun transaction() {  }

Solo para Android

Consultas de bloqueo

@Query("SELECT * FROM Todo")
fun getAllTodos(): List<Todo>

Transacciones de bloqueo

@Transaction
fun blockingTransaction() {  }

Convierte tipos reactivos a Flow

No todas las funciones DAO deben ser funciones de suspensión. Las funciones DAO que muestran tipos reactivos, como LiveData o Flowable de RxJava, no deben convertirse en funciones de suspensión. Sin embargo, algunos tipos, como LiveData, no son compatibles con KMP. Las funciones DAO con tipos de datos que se muestran reactivos deben migrarse a flujos de corrutinas.

Kotlin multiplataforma

Tipos reactivos Flows

@Query("SELECT * FROM Todo")
fun getTodosFlow(): Flow<List<Todo>>

Solo para Android

Tipos reactivos como LiveData o Flowable de RxJava

@Query("SELECT * FROM Todo")
fun getTodosLiveData(): LiveData<List<Todo>>

Convierte las APIs de Transaction

Las APIs de Transaction de la base de datos para Room KMP pueden diferenciar entre las transacciones de escritura (useWriterConnection) y lectura (useReaderConnection).

Kotlin multiplataforma

val database: RoomDatabase = 
database.useWriterConnection { transactor ->
  transactor.immediateTransaction {
    // perform database operations in transaction
  }
}

Solo para Android

val database: RoomDatabase = 
database.withTransaction {
  // perform database operations in transaction
}

Transacciones de escritura

Usa transacciones de escritura para asegurarte de que varias consultas escriban datos de forma atómica, de modo que los lectores puedan acceder a los datos de manera coherente. Puedes hacerlo con useWriterConnection con cualquiera de los tres tipos de transacciones:

  • immediateTransaction: En el modo de registro de escritura por adelantado (WAL) (predeterminado), este tipo de transacción adquiere un bloqueo cuando comienza, pero los lectores pueden seguir leyendo. Esta es la opción preferida para la mayoría de los casos.

  • deferredTransaction: La transacción no adquirirá un bloqueo hasta la primera sentencia de escritura. Usa este tipo de transacción como una optimización cuando no estés seguro de si se necesitará una operación de escritura dentro de la transacción. Por ejemplo, si inicias una transacción para borrar canciones de una lista de reproducción con solo un nombre de la lista de reproducción y la lista no existe, no se necesita ninguna operación de escritura (borrado).

  • exclusiveTransaction: Este modo se comporta de la misma manera que immediateTransaction en el modo WAL. En otros modos de registro, impide que otras conexiones de bases de datos lean la base de datos mientras la transacción está en curso.

Transacciones de lectura

Usa transacciones de lectura para leer de manera coherente desde la base de datos varias veces. Por ejemplo, cuando tienes dos o más consultas separadas y no usas una cláusula JOIN. Solo se permiten transacciones diferidas en las conexiones de lectores. Si intentas iniciar una transacción inmediata o exclusiva en una conexión de lector, se generará una excepción, ya que se consideran operaciones de "escritura".

val database: RoomDatabase = 
database.useReaderConnection { transactor ->
  transactor.deferredTransaction {
      // perform database operations in transaction
  }
}

No disponible en Kotlin Multiplatform

Algunas de las APIs que estaban disponibles para Android no están disponibles en Kotlin Multiplatform.

Devolución de llamada de consulta

Las siguientes APIs para configurar devoluciones de llamada de consultas no están disponibles en común y, por lo tanto, no están disponibles en plataformas que no sean Android.

  • RoomDatabase.Builder.setQueryCallback
  • RoomDatabase.QueryCallback

Tenemos la intención de agregar compatibilidad con la devolución de llamada de consultas en una versión futura de Room.

La API para configurar un RoomDatabase con una devolución de llamada de consulta RoomDatabase.Builder.setQueryCallback junto con la interfaz de devolución de llamada RoomDatabase.QueryCallback no está disponible en común y, por lo tanto, no está disponible en otras plataformas que no sean Android.

Base de datos de cierre automático

La API para habilitar el cierre automático después de un tiempo de espera, RoomDatabase.Builder.setAutoCloseTimeout, solo está disponible en Android y no en otras plataformas.

Base de datos preempaquetada

Las siguientes APIs para crear un RoomDatabase con una base de datos existente (es decir, una base de datos preempaquetada) no están disponibles en común y, por lo tanto, no están disponibles en otras plataformas que no sean Android. Estas APIs son las siguientes:

  • RoomDatabase.Builder.createFromAsset
  • RoomDatabase.Builder.createFromFile
  • RoomDatabase.Builder.createFromInputStream
  • RoomDatabase.PrepackagedDatabaseCallback

Tenemos la intención de agregar compatibilidad con bases de datos preempaquetadas en una versión futura de Room.

Invalidación de instancias múltiples

La API para habilitar la invalidación de instancias múltiples, RoomDatabase.Builder.enableMultiInstanceInvalidation, solo está disponible en Android y no en común ni en otras plataformas.