Migrer Room vers Kotlin Multiplateforme

Ce document explique comment migrer une implémentation de Room existante vers une implémentation utilisant la multiplateforme Kotlin (KMP).

La migration des utilisations de Room dans un codebase Android existant vers un module KMP partagé commun peut varier considérablement en fonction des API Room utilisées ou si le codebase utilise déjà des coroutines. Cette section propose des conseils et des astuces lorsque vous tentez de migrer des utilisations de Room vers un module courant.

Il est important de vous familiariser d'abord avec les différences et les fonctionnalités manquantes entre la version Android de Room et la version KMP, ainsi que la configuration concernée. En substance, une migration réussie implique de refactoriser les utilisations des API SupportSQLite* et de les remplacer par les API SQLite Driver, ainsi que de déplacer les déclarations Room (classe annotée @Database, DAO, entités, etc.) dans un code commun.

Consultez à nouveau les informations suivantes avant de continuer:

Les sections suivantes décrivent les différentes étapes requises pour une migration réussie.

Passer du pilote SQLite d'assistance au pilote SQLite

Les API de androidx.sqlite.db ne fonctionnent qu'avec Android, et toutes les utilisations doivent être refactorisées avec les API SQLite Driver. Pour assurer la rétrocompatibilité, et tant que RoomDatabase est configuré avec un SupportSQLiteOpenHelper.Factory (c'est-à-dire qu'aucun SQLiteDriver n'est défini), Room se comporte en "mode de compatibilité", où les API de pilote SQLite et SQLite fonctionnent comme prévu. Cela active les migrations incrémentielles, ce qui vous évite d'avoir à convertir toutes vos utilisations de SQLite Support en pilote SQLite en une seule modification.

Les exemples suivants sont des utilisations courantes de Support SQLite et de leurs équivalents à partir du pilote SQLite:

Compatibilité avec SQLite (à partir de)

Exécuter une requête sans résultat

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

Exécuter une requête avec un résultat mais sans argument

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

Exécuter une requête avec un résultat et des arguments

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

Pilote SQLite (vers)

Exécuter une requête sans résultat

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

Exécuter une requête avec un résultat mais sans argument

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

Exécuter une requête avec un résultat et des arguments

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

Les API de transaction de base de données sont disponibles directement dans SupportSQLiteDatabase avec beginTransaction(), setTransactionSuccessful() et endTransaction(). Elles sont également disponibles dans Room via runInTransaction(). Migrez ces utilisations vers les API SQLite Driver.

Compatibilité avec SQLite (à partir de)

Effectuer une transaction (à l'aide de RoomDatabase)

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

Effectuer une transaction (à l'aide de SupportSQLiteDatabase)

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

Pilote SQLite (vers)

Effectuer une transaction (à l'aide de RoomDatabase)

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

Effectuer une transaction (à l'aide de 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")
}

Plusieurs remplacements de rappel doivent également être migrés vers leurs équivalents pilotes:

Compatibilité avec SQLite (à partir de)

Sous-classes de migration

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

Sous-classes de spécification de la migration automatique

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

Sous-classes de rappel de base de données

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

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

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

Pilote SQLite (vers)

Sous-classes de migration

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

Sous-classes de spécification de la migration automatique

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

Sous-classes de rappel de base de données

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

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

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

Pour résumer, remplacez les utilisations de SQLiteDatabase par SQLiteConnection lorsqu'un RoomDatabase n'est pas disponible, par exemple dans les remplacements de rappel (onMigrate, onCreate, etc.). Si un RoomDatabase est disponible, accédez à la connexion de base de données sous-jacente à l'aide de RoomDatabase.useReaderConnection et RoomDatabase.useWriterConnection au lieu de RoomDatabase.openHelper.writtableDatabase.

Convertir les fonctions DAO bloquantes en fonctions de suspension

La version KMP de Room s'appuie sur des coroutines pour effectuer des opérations d'E/S sur le CoroutineContext configuré. Cela signifie que vous devez migrer toutes les fonctions DAO bloquantes pour suspendre des fonctions.

Blocage de la fonction DAO (à partir de)

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

Suspension de la fonction DAO (à)

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

La migration des fonctions de blocage DAO existantes vers des fonctions de suspension peut être compliquée si le codebase existant n'intègre pas déjà de coroutines. Consultez la page Coroutines sur Android pour commencer à utiliser des coroutines dans votre codebase.

Convertir les types renvoyés réactifs en flux

Les fonctions DAO ne doivent pas toutes être des fonctions de suspension. Les fonctions DAO qui renvoient des types réactifs tels que LiveData ou Flowable de RxJava ne doivent pas être converties en fonctions de suspension. Toutefois, certains types, tels que LiveData, ne sont pas compatibles avec KMP. Les fonctions DAO avec des types renvoyés réactifs doivent être migrées vers des flux de coroutines.

Type de KMP incompatible (à partir de)

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

Type de KMP compatible (vers)

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

Reportez-vous à la page Flux dans Android pour commencer à utiliser des flux dans votre codebase.

Définir un contexte de coroutine (facultatif)

Un RoomDatabase peut éventuellement être configuré avec des exécuteurs d'applications partagés utilisant RoomDatabase.Builder.setQueryExecutor() pour effectuer des opérations de base de données. Étant donné que les exécuteurs ne sont pas compatibles avec KMP, l'API setQueryExecutor() de Room n'est pas disponible pour les sources courantes. À la place, RoomDatabase doit être configuré avec un CoroutineContext. Un contexte peut être défini à l'aide de RoomDatabase.Builder.setCoroutineContext(). Si aucun n'est défini, RoomDatabase utilise Dispatchers.IO par défaut.

Définir un pilote SQLite

Une fois que les utilisations de l'assistance SQLite ont été migrées vers les API du pilote SQLite, un pilote doit être configuré à l'aide de RoomDatabase.Builder.setDriver. Le pilote recommandé est BundledSQLiteDriver. Consultez la section Implémentations de pilotes pour obtenir une description des implémentations de pilotes disponibles.

Les SupportSQLiteOpenHelper.Factory personnalisés configurés à l'aide de RoomDatabase.Builder.openHelperFactory() ne sont pas compatibles avec KMP. Les fonctionnalités fournies par l'assistant ouvert personnalisé devront être réimplémentées avec les interfaces du pilote SQLite.

Déplacer des déclarations Room

Une fois la plupart des étapes de migration terminées, vous pouvez déplacer les définitions Room vers un ensemble de sources commun. Notez que les stratégies expect / actual peuvent être utilisées pour déplacer progressivement les définitions associées à Room. Par exemple, si les fonctions DAO bloquantes ne peuvent pas toutes être migrées vers des fonctions de suspension, vous pouvez déclarer une interface annotée expect @Dao vide dans le code commun, mais contenant des fonctions de blocage dans 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
}