Cómo migrar Room a Kotlin Multiplaform

En este documento, se describe cómo migrar una implementación existente de Room a una que usa Kotlin multiplataforma (KMP).

La migración de los usos de Room en una base de código de Android existente a un módulo KMP compartido común puede variar ampliamente según las APIs de Room que se usen o si la base de código ya usa corrutinas. En esta sección, se ofrecen algunas orientaciones y sugerencias para migrar los usos de Room a un módulo común.

Es importante que primero te familiarices con las diferencias y las funciones faltantes entre la versión de Android de Room y la versión de KMP, además de la configuración involucrada. En esencia, una migración exitosa implica refactorizar los usos de las APIs de SupportSQLite* y reemplazarlos por las APIs del controlador de SQLite, además de mover las declaraciones de Room (clase con anotaciones @Database, DAO, entidades, etc.) a código común.

Antes de continuar, revisa la siguiente información:

En las siguientes secciones, se describen los diversos pasos necesarios para una migración exitosa.

Cómo migrar de la compatibilidad con SQLite al controlador de SQLite

Las APIs de androidx.sqlite.db son solo para Android, y cualquier uso debe refactorizarse con las APIs del controlador de SQLite. Para ofrecer retrocompatibilidad, y siempre que RoomDatabase esté configurado con un SupportSQLiteOpenHelper.Factory (es decir, no se haya configurado un SQLiteDriver), Room se comporta en el "modo de compatibilidad", donde las APIs de compatibilidad de SQLite y SQLite Driver funcionan como se espera. De esta manera, puedes realizar migraciones incrementales para que no tengas que convertir todos tus usos de compatibilidad de SQLite en el controlador de SQLite con un solo cambio.

Los siguientes ejemplos son usos comunes de la compatibilidad con SQLite y sus equivalentes del controlador de SQLite:

Compatibilidad con SQLite (desde)

Ejecuta una consulta sin resultados

val database: SupportSQLiteDatabase = ...
database.execSQL("ALTER TABLE ...")

Ejecuta una consulta con un resultado, pero sin argumentos

val database: SupportSQLiteDatabase = ...
database.query("SELECT * FROM Pet").use { cursor ->
  while (cusor.moveToNext()) {
    // read columns
    cursor.getInt(0)
    cursor.getString(1)
  }
}

Ejecuta una consulta con el resultado y los argumentos

database.query("SELECT * FROM Pet WHERE id = ?", id).use { cursor ->
  if (cursor.moveToNext()) {
    // row found, read columns
  } else {
    // row not found
  }
}

Controlador de SQLite (hasta)

Ejecuta una consulta sin resultados

val connection: SQLiteConnection = ...
connection.execSQL("ALTER TABLE ...")

Ejecuta una consulta con un resultado, pero sin argumentos

val connection: SQLiteConnection = ...
connection.prepare("SELECT * FROM Pet").use { statement ->
  while (statement.step()) {
    // read columns
    statement.getInt(0)
    statement.getText(1)
  }
}

Ejecuta una consulta con el resultado y los argumentos

connection.prepare("SELECT * FROM Pet WHERE id = ?").use { statement ->
  statement.bindInt(1, id)
  if (statement.step()) {
    // row found, read columns
  } else {
    // row not found
  }
}

Las APIs de transacciones de bases de datos están disponibles directamente en SupportSQLiteDatabase con beginTransaction(), setTransactionSuccessful() y endTransaction(). También están disponibles a través de Room con runInTransaction(). Migra estos usos a las APIs de controlador de SQLite.

Compatibilidad con SQLite (desde)

Realiza una transacción (con RoomDatabase)

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

Realiza una transacción (con SupportSQLiteDatabase)

val database: SupportSQLiteDatabase = ...
database.beginTransaction()
try {
  // perform database operations in transaction
  database.setTransactionSuccessful()
} finally {
  database.endTransaction()
}

Controlador de SQLite (hasta)

Realiza una transacción (con RoomDatabase)

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

Realiza una transacción (con SQLiteConnection)

val connection: SQLiteConnection = ...
connection.execSQL("BEGIN IMMEDIATE TRANSACTION")
try {
  // perform database operations in transaction
  connection.execSQL("END TRANSACTION")
} catch(t: Throwable) {
  connection.execSQL("ROLLBACK TRANSACTION")
}

También se deben migrar varias anulaciones de devolución de llamada a sus equivalentes del controlador:

Compatibilidad con SQLite (desde)

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) {
    // ...
  }
}

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

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

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

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

Controlador de SQLite (hasta)

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) {
    // ...
  }
}

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

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

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

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

En resumen, reemplaza los usos de SQLiteDatabase por SQLiteConnection cuando no haya un RoomDatabase disponible, como en las anulaciones de devolución de llamada (onMigrate, onCreate, etcétera). Si hay un RoomDatabase disponible, accede a la conexión de la base de datos subyacente con RoomDatabase.useReaderConnection y RoomDatabase.useWriterConnection en lugar de RoomDatabase.openHelper.writtableDatabase.

Cómo convertir funciones DAO de bloqueo en funciones de suspensión

La versión de KMP de Room se basa en corrutinas para realizar operaciones de E/S en el CoroutineContext configurado. Eso significa que debes migrar cualquier función DAO de bloqueo para suspender las funciones.

Bloqueo de la función DAO (desde)

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

Suspensión de la función DAO (to)

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

La migración de funciones de bloqueo de DAO existentes para suspender funciones puede resultar complicada si la base de código existente aún no incorpora corrutinas. Consulta Corrutinas en Android para comenzar a usar corrutinas en tu base de código.

Convierte los tipos de datos reactivos que se muestran en 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.

Tipo de KMP incompatible (desde)

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

Tipo de KMP compatible (hasta)

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

Consulta Flujos en Android para comenzar a usar flujos en tu base de código.

Cómo configurar un contexto de corrutina (opcional)

De manera opcional, un RoomDatabase se puede configurar con ejecutores de aplicaciones compartidos mediante RoomDatabase.Builder.setQueryExecutor() para realizar operaciones de base de datos. Como los ejecutores no son compatibles con KMP, la API de setQueryExecutor() de Room no está disponible para fuentes comunes. En su lugar, RoomDatabase debe configurarse con un CoroutineContext. Se puede configurar un contexto con RoomDatabase.Builder.setCoroutineContext(). Si no se establece ninguno, RoomDatabase usará Dispatchers.IO de forma predeterminada.

Configura un controlador de SQLite

Una vez que se hayan migrado los usos de compatibilidad de SQLite a las APIs del controlador de SQLite, se deberá configurar un controlador usando RoomDatabase.Builder.setDriver. El controlador recomendado es BundledSQLiteDriver. Consulta Implementaciones de controladores para obtener descripciones de las implementaciones de controladores disponibles.

Los SupportSQLiteOpenHelper.Factory personalizados configurados con RoomDatabase.Builder.openHelperFactory() no son compatibles con KMP. Las funciones que proporciona el asistente abierto personalizado deberán volver a implementarse con las interfaces del controlador SQLite.

Declaraciones de Move Room

Una vez que se completan la mayoría de los pasos de migración, se pueden mover las definiciones de Room a un conjunto de orígenes común. Ten en cuenta que las estrategias expect y actual se pueden usar para mover de manera incremental las definiciones relacionadas con Room. Por ejemplo, si no todas las funciones DAO de bloqueo se pueden migrar a funciones de suspensión, es posible declarar una interfaz con anotaciones expect @Dao que esté vacía en el código común, pero que contenga funciones de bloqueo en Android.

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

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

@Dao
interface TodoDao {
    @Query("SELECT count(*) FROM TodoEntity")
    suspend fun count(): Int
}

@Dao
expect interface BlockingTodoDao
// shared/src/androidMain/kotlin/BlockingTodoDao.kt

@Dao
actual interface BlockingTodoDao {
    @Query("SELECT count(*) FROM TodoEntity")
    fun count(): Int
}