Configurer la base de données Room pour KMP

La bibliothèque de persistance Room fournit une couche d'abstraction sur SQLite afin de permettre un accès plus fiable à la base de données, tout en exploitant toute la puissance de SQLite. Cette page explique comment utiliser Room dans les projets Kotlin Multiplatform (KMP). Pour en savoir plus sur l'utilisation de Room, consultez Enregistrer des données dans une base de données locale à l'aide de Room ou nos exemples officiels.

Configurer des dépendances

Pour configurer Room dans votre projet KMP, ajoutez les dépendances des artefacts dans le fichier build.gradle.kts de votre module KMP.

Définissez les dépendances dans le fichier 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" }

Ajoutez le plug-in Room Gradle pour configurer les schémas Room et le plug-in KSP.

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

Ajoutez la dépendance d'exécution Room et la bibliothèque SQLite groupée :

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)
}

Ajoutez les dépendances KSP au bloc dependencies racine. Vous devez ajouter toutes les cibles utilisées par votre application. Pour en savoir plus, consultez 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
}

Définissez le répertoire de schéma Room. Pour en savoir plus, consultez Définir l'emplacement du schéma à l'aide du plug-in Room Gradle.

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

Définir des classes de base de données

Vous devez créer une classe de base de données annotée avec @Database, ainsi que des DAO et des entités dans l'ensemble de sources commun de votre module KMP partagé. Le fait de placer ces classes dans des sources communes leur permettra d'être partagées sur toutes les plates-formes cibles.

// 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
}

Lorsque vous déclarez un objet expect avec l'interface RoomDatabaseConstructor, le compilateur Room génère les implémentations actual. Android Studio peut émettre l'avertissement suivant, que vous pouvez supprimer avec @Suppress("KotlinNoActualForExpect") :

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

Ensuite, définissez une nouvelle interface DAO ou déplacez-en une existante vers 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>>
}

Définissez ou déplacez vos entités vers commonMain :

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

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

Créer des compilateurs de base de données spécifiques à la plate-forme

Vous devez définir un compilateur de base de données pour instancier Room sur chaque plate-forme. Il s'agit de la seule partie de l'API que vous devez définir dans des ensembles de sources spécifiques à la plate-forme, en raison des différences entre les API du système de fichiers.

Android

Sur Android, vous obtenez généralement l'emplacement de la base de données à l'aide de l' Context.getDatabasePath API. Pour créer l'instance de base de données, spécifiez un Context et le chemin d'accès à la base de données.

// 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

Pour créer l'instance de base de données sur iOS, fournissez un chemin d'accès à la base de données à l'aide du NSFileManager, généralement situé dans le 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)
}

Bureau JVM

Pour créer l'instance de base de données, fournissez un chemin d'accès à la base de données à l'aide des API Java ou 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,
    )
}

Instancier la base de données

Une fois que vous avez obtenu le RoomDatabase.Builder à partir de l'un des constructeurs spécifiques à la plate-forme, vous pouvez configurer le reste de la base de données Room dans le code commun, ainsi que l'instanciation réelle de la base de données.

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

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

Sélectionner un pilote SQLite

L'extrait de code précédent appelle la fonction de compilateur setDriver pour définir le pilote SQLite que la base de données Room doit utiliser. Ces pilotes diffèrent en fonction de la plate-forme cible. Les extraits de code précédents utilisent BundledSQLiteDriver. Il s'agit du pilote recommandé qui inclut SQLite compilé à partir de la source, ce qui fournit la version la plus cohérente et la plus récente de SQLite sur toutes les plates-formes.

Si vous souhaitez utiliser SQLite fourni par le système d'exploitation, utilisez l'API setDriver dans les ensembles de sources spécifiques à la plate-forme qui spécifient un pilote spécifique à la plate-forme. Consultez Implémentations de pilotes pour obtenir des descriptions des implémentations de pilotes disponibles. Vous pouvez utiliser l'une des options suivantes :

Pour utiliser NativeSQLiteDriver, vous devez fournir une option d'éditeur de liens -lsqlite3 afin que l'application iOS soit liée dynamiquement à SQLite du système.

// 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")
        }
    }
}

Définir un contexte de coroutine

Un objet RoomDatabase sur Android peut également être configuré avec des exécutants d'application partagés à l'aide de RoomDatabase.Builder.setQueryExecutor pour effectuer des opérations de base de données.

Étant donné que les exécutants ne sont pas compatibles avec KMP, l'API setQueryExecutor de Room n'est pas disponible dans commonMain. Au lieu de cela, l'objet RoomDatabase doit être configuré avec un CoroutineContext, que vous pouvez définir à l'aide de RoomDatabase.Builder.setCoroutineContext. Si vous ne définissez pas de contexte, l'objet RoomDatabase est défini par défaut sur Dispatchers.IO.

Minimisation et obscurcissement

Si le projet est minimisé ou obscurci, vous devez inclure la règle ProGuard suivante afin que Room puisse trouver l'implémentation générée de la définition de la base de données :

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

Migrer vers Kotlin Multiplatform

Room a été initialement développé en tant que bibliothèque Android, puis migré vers KMP en mettant l'accent sur la compatibilité des API. La version KMP de Room diffère quelque peu entre les plates-formes et de la version spécifique à Android. Ces différences sont listées et décrites comme suit.

Migrer de Support SQLite vers le pilote SQLite

Toutes les utilisations de SupportSQLiteDatabase et d'autres API dans androidx.sqlite.db doivent être refactorisées avec les API du pilote SQLite, car les API de androidx.sqlite.db sont réservées à Android (notez le package différent de celui de KMP).

Pour assurer la rétrocompatibilité, et tant que RoomDatabase est configuré avec un SupportSQLiteOpenHelper.Factory (par exemple, aucun SQLiteDriver n'est défini), Room se comporte en "mode de compatibilité" où les API Support SQLite et SQLite Driver fonctionnent comme prévu. Cela permet des migrations incrémentielles afin que vous n'ayez pas besoin de convertir toutes vos utilisations de Support SQLite en pilote SQLite en une seule modification.

Utiliser le wrapper SQLite de Room

L'artefact androidx.room3:room3-sqlite-wrapper fournit des API pour établir un pont entre SQLiteDriver et SupportSQLiteDatabase lors de la migration.

Pour obtenir un SupportSQLiteDatabase à partir d'un RoomDatabase configuré avec un SQLiteDriver, utilisez la nouvelle fonction d'extension RoomDatabase.getSupportWrapper. Ce wrapper de compatibilité permet de conserver les utilisations existantes de SupportSQLiteDatabase, que vous obtenez souvent à partir de RoomDatabase.openHelper.writableDatabase, tout en adoptant SQLiteDriver, en particulier pour les bases de code avec des utilisations étendues de l'API SupportSQLite qui souhaitent utiliser BundledSQLiteDriver.

Convertir les sous-classes de migration

Les sous-classes de migration doivent être migrées vers les homologues du pilote SQLite :

Multiplateforme Kotlin

Sous-classes de migration

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

Sous-classes de spécification de migration automatique

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

Android uniquement

Sous-classes de migration

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

Sous-classes de spécification de migration automatique

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

Convertir le rappel de base de données

Les rappels de base de données doivent être migrés vers les homologues du pilote SQLite :

Multiplateforme Kotlin

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

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

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

Android uniquement

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

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

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

Convertir les fonctions DAO @RawQuery

Les fonctions annotées avec @RawQuery qui sont compilées pour des plates-formes non Android devront déclarer un paramètre de type RoomRawQuery au lieu de SupportSQLiteQuery.

Multiplateforme Kotlin

Définir la requête brute

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

Un RoomRawQuery peut ensuite être utilisé pour créer une requête au moment de l'exécution :

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)
}

Android uniquement

Définir la requête brute

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

Un SimpleSQLiteQuery peut ensuite être utilisé pour créer une requête au moment de l'exécution :

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

Convertir les fonctions DAO bloquantes

Room bénéficie de la bibliothèque asynchrone riche en fonctionnalités kotlinx.coroutines que Kotlin propose pour plusieurs plates-formes. Pour une fonctionnalité optimale, les fonctions suspend sont appliquées aux DAO compilés dans un projet KMP, à l'exception des DAO implémentés dans androidMain pour maintenir la rétrocompatibilité avec la base de code existante. Lorsque vous utilisez Room pour KMP, toutes les fonctions DAO compilées pour des plates-formes non Android doivent être des fonctions suspend.

Multiplateforme Kotlin

Requêtes de suspension

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

Transactions de suspension

@Transaction
suspend fun transaction() {  }

Android uniquement

Requêtes bloquantes

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

Transactions bloquantes

@Transaction
fun blockingTransaction() {  }

Convertir les types réactifs en flux

Toutes les fonctions DAO n'ont pas besoin d'ê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 de retour réactifs doivent être migrées vers des flux de coroutine.

Multiplateforme Kotlin

Types réactifs Flows

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

Android uniquement

Types réactifs tels que LiveData ou Flowable de RxJava

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

Convertir les API de transaction

Les API de transaction de base de données pour Room KMP peuvent faire la distinction entre les transactions d'écriture (useWriterConnection) et de lecture (useReaderConnection).

Multiplateforme Kotlin

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

Android uniquement

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

Transactions d'écriture

Utilisez des transactions d'écriture pour vous assurer que plusieurs requêtes écrivent des données de manière atomique, afin que les lecteurs puissent accéder aux données de manière cohérente. Pour ce faire, utilisez useWriterConnection avec l'un des trois types de transaction :

  • immediateTransaction : en mode WAL (Write-Ahead Logging) (par défaut), ce type de transaction acquiert un verrou au démarrage, mais les lecteurs peuvent continuer à lire. Il s'agit du choix préféré dans la plupart des cas.

  • deferredTransaction: la transaction n'acquiert pas de verrou tant que la première instruction d'écriture n'est pas exécutée. Utilisez ce type de transaction comme optimisation lorsque vous n'êtes pas sûr qu'une opération d'écriture sera nécessaire dans la transaction. Par exemple, si vous démarrez une transaction pour supprimer des titres d'une playlist en indiquant uniquement le nom de la playlist et que la playlist n'existe pas, aucune opération d'écriture (suppression) n'est nécessaire.

  • exclusiveTransaction: ce mode se comporte de la même manière que immediateTransaction en mode WAL. Dans d'autres modes de journalisation, il empêche d'autres connexions à la base de données de lire la base de données pendant la transaction.

Transactions de lecture

Utilisez des transactions de lecture pour lire plusieurs fois de manière cohérente à partir de la base de données. Par exemple, lorsque vous avez deux requêtes distinctes ou plus et que vous n'utilisez pas de clause JOIN. Seules les transactions différées sont autorisées dans les connexions de lecteur. Toute tentative de démarrage d'une transaction immédiate ou exclusive dans une connexion de lecteur générera une exception, car il s'agit d'opérations d'écriture.

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

Non disponible dans Kotlin Multiplatform

Certaines API disponibles pour Android ne sont pas disponibles dans Kotlin Multiplatform.

Rappel de requête

Les API suivantes pour configurer les rappels de requête ne sont pas disponibles en commun et ne sont donc pas disponibles sur les plates-formes autres qu'Android.

  • RoomDatabase.Builder.setQueryCallback
  • RoomDatabase.QueryCallback

Nous prévoyons d'ajouter la prise en charge des rappels de requête dans une future version de Room.

L'API permettant de configurer un RoomDatabase avec un rappel de requête RoomDatabase.Builder.setQueryCallback ainsi que l'interface de rappel RoomDatabase.QueryCallback ne sont pas disponibles en commun et ne sont donc pas disponibles sur les plates-formes autres qu'Android.

Fermeture automatique de la base de données

L'API permettant d'activer la fermeture automatique après un délai d'inactivité, RoomDatabase.Builder.setAutoCloseTimeout, n'est disponible que sur Android et n'est pas disponible sur d'autres plates-formes.

Base de données pré-empaquetée

Les API suivantes permettant de créer un RoomDatabase à l'aide d'une base de données existante (c'est-à-dire une base de données pré-empaquetée) ne sont pas disponibles en commun et ne sont donc pas disponibles sur les plates-formes autres qu'Android. Voici la liste de ces API :

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

Nous prévoyons d'ajouter la prise en charge des bases de données pré-empaquetées dans une future version de Room.

Invalidation multi-instance

L'API permettant d'activer l'invalidation multi-instance, RoomDatabase.Builder.enableMultiInstanceInvalidation, n'est disponible que sur Android et n'est pas disponible en commun ni sur d'autres plates-formes.