Konfigurowanie bazy danych Room na potrzeby KMP

Biblioteka trwałości danych Room zapewnia warstwę abstrakcji nad SQLite, aby umożliwić bardziej niezawodny dostęp do bazy danych przy jednoczesnym wykorzystaniu pełnej mocy SQLite. Na tej stronie skupiamy się na używaniu Room w projektach Kotlin Multiplatform (KMP). Więcej informacji o korzystaniu z biblioteki Room znajdziesz w artykule Zapisywanie danych w lokalnej bazie danych za pomocą biblioteki Room lub w naszych oficjalnych przykładach.

Konfigurowanie zależności

Aby skonfigurować Room w projekcie KMP, dodaj zależności artefaktów w pliku build.gradle.kts modułu KMP.

Zdefiniuj zależności w pliku libs.versions.toml:

[versions]
room3 = "3.1.0-alpha03"
sqlite = "2.7.1"
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" }

Dodaj wtyczkę Room Gradle, aby skonfigurować schematy Room i wtyczkę KSP.

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

Dodaj zależność środowiska wykonawczego Room i dołączoną bibliotekę SQLite:

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

Dodaj zależności KSP do głównego bloku dependencies. Musisz dodać wszystkie cele, których używa Twoja aplikacja. Więcej informacji znajdziesz w artykule KSP z 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
}

Zdefiniuj katalog schematu Room. Więcej informacji znajdziesz w artykule Ustawianie lokalizacji schematu za pomocą wtyczki Room Gradle.

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

Definiowanie klas baz danych

Musisz utworzyć klasę bazy danych z adnotacją @Database oraz obiekty DAO i encje w zbiorze źródeł wspólnych udostępnionego modułu KMP. Umieszczenie tych klas we wspólnych źródłach umożliwi ich udostępnianie na wszystkich platformach docelowych.

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

Gdy zadeklarujesz obiekt expect za pomocą interfejsu RoomDatabaseConstructor, kompilator Room wygeneruje implementacje actual. Android Studio może wyświetlić to ostrzeżenie, które możesz ukryć za pomocą @Suppress("KotlinNoActualForExpect"):

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

Następnie zdefiniuj nowy interfejs DAO lub przenieś istniejący do 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>>
}

Zdefiniuj lub przenieś elementy do commonMain:

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

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

Tworzenie kreatorów baz danych dla konkretnych platform

Aby utworzyć instancję Room na każdej platformie, musisz zdefiniować kreator bazy danych. To jedyna część interfejsu API, którą musisz zdefiniować w zbiorach źródeł dla konkretnych platform ze względu na różnice w interfejsach API systemów plików.

Android

Na Androidzie lokalizację bazy danych zwykle uzyskuje się za pomocą interfejsu API Context.getDatabasePath. Aby utworzyć instancję bazy danych, określ Context i ścieżkę bazy danych.

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

Aby utworzyć instancję bazy danych na iOS, podaj ścieżkę bazy danych za pomocą NSFileManager, która zwykle znajduje się w 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,
    )
}

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

JVM na komputery

Aby utworzyć instancję bazy danych, podaj ścieżkę bazy danych za pomocą interfejsów API Javy lub Kotlina.

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

Tworzenie instancji bazy danych

Po uzyskaniu RoomDatabase.Builder z jednego z konstruktorów dla konkretnej platformy możesz skonfigurować resztę bazy danych Room we wspólnym kodzie wraz z rzeczywistym utworzeniem instancji bazy danych.

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

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

Wybierz sterownik SQLite

Poprzedni fragment kodu wywołuje funkcję kreatora setDriver, aby określić, jakiego sterownika SQLite powinna używać baza danych Room. Sterowniki te różnią się w zależności od platformy docelowej. Poprzednie fragmenty kodu używają BundledSQLiteDriver. Jest to zalecany sterownik, który zawiera SQLite skompilowany ze źródła, co zapewnia najbardziej spójną i aktualną wersję SQLite na wszystkich platformach.

Jeśli chcesz używać bazy SQLite dostarczanej przez system operacyjny, użyj interfejsu API setDriver w zbiorach źródeł dla konkretnej platformy, które określają sterownik dla konkretnej platformy. Opisy dostępnych implementacji sterowników znajdziesz w sekcji Implementacje sterowników. Możesz użyć jednej z tych opcji:

Aby używać NativeSQLiteDriver, musisz podać opcję tagu łączącego -lsqlite3, aby aplikacja na iOS dynamicznie łączyła się z systemem SQLite.

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

Ustawianie kontekstu współprogramu

Obiekt RoomDatabase na Androidzie można opcjonalnie skonfigurować za pomocą współdzielonych wykonawców aplikacji, używając RoomDatabase.Builder.setQueryExecutor do wykonywania operacji na bazie danych.

Ponieważ wykonawcy nie są zgodni z KMP, interfejs API setQueryExecutor Room nie jest dostępny w commonMain. Zamiast tego obiekt RoomDatabase musi być skonfigurowany za pomocą CoroutineContext, który możesz ustawić za pomocą RoomDatabase.Builder.setCoroutineContext. Jeśli nie ustawisz kontekstu, obiekt RoomDatabase będzie domyślnie ustawiony na Dispatchers.IO.

Minifikacja i zaciemnianie

Jeśli projekt jest zminimalizowany lub zaciemniony, musisz uwzględnić tę regułę ProGuard, aby Room mógł znaleźć wygenerowaną implementację definicji bazy danych:

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

Migracja do Kotlin Multiplatform

Room został pierwotnie opracowany jako biblioteka Androida, a później przeniesiony do KMP z naciskiem na zgodność interfejsu API. Wersja KMP Room różni się nieco między platformami i od wersji na Androida. Różnice te są wymienione i opisane poniżej.

Migracja z pomocniczego SQLite na sterownik SQLite

Wszystkie przypadki użycia SupportSQLiteDatabase i innych interfejsów API w androidx.sqlite.db muszą zostać poddane refaktoryzacji za pomocą interfejsów API sterownika SQLite, ponieważ interfejsy API w androidx.sqlite.db są przeznaczone tylko na Androida (zwróć uwagę na inny pakiet niż pakiet KMP).

Aby zachować zgodność wsteczną, o ile RoomDatabase jest skonfigurowany z SupportSQLiteOpenHelper.Factory (np. nie ustawiono SQLiteDriver), Room działa w „trybie zgodności”, w którym zarówno interfejsy API Support SQLite, jak i SQLite Driver działają zgodnie z oczekiwaniami. Umożliwia to migracje przyrostowe, dzięki czemu nie musisz konwertować wszystkich zastosowań Support SQLite na sterownik SQLite w ramach jednej zmiany.

Używanie kodu opakowującego SQLite w Room

Artefakt androidx.room3:room3-sqlite-wrapper udostępnia interfejsy API, które podczas migracji łączą SQLiteDriver i SupportSQLiteDatabase.

Aby uzyskać SupportSQLiteDatabase z RoomDatabase skonfigurowanego za pomocą SQLiteDriver, użyj nowej funkcji rozszerzenia RoomDatabase.getSupportWrapper. Ta otoczka zgodności pomaga zachować dotychczasowe zastosowania SupportSQLiteDatabase, które często uzyskuje się z RoomDatabase.openHelper.writableDatabase, podczas wdrażania SQLiteDriver, zwłaszcza w przypadku baz kodu z rozległymi zastosowaniami interfejsu API SupportSQLite, które chcą używać BundledSQLiteDriver.

Przekształcanie podklas migracji

Podklasy migracji muszą zostać przeniesione do odpowiedników sterowników SQLite:

Kotlin Multiplatform

Podklasy migracji

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

Podklasy specyfikacji automatycznej migracji

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

Tylko Android

Podklasy migracji

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

Podklasy specyfikacji automatycznej migracji

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

Wywołanie zwrotne konwersji bazy danych

Wywołania zwrotne bazy danych muszą zostać przeniesione do odpowiedników sterowników SQLite:

Kotlin Multiplatform

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

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

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

Tylko Android

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

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

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

Konwertowanie funkcji DAO @RawQuery

Funkcje z adnotacją @RawQuery, które są kompilowane na platformy inne niż platforma Androida, będą musiały deklarować parametr typu RoomRawQuery zamiast SupportSQLiteQuery.

Kotlin Multiplatform

Definiowanie nieprzetworzonego zapytania

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

Następnie można użyć RoomRawQuery, aby utworzyć zapytanie w czasie działania:

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

    return getDao().getTodos(query)
}

Tylko Android

Definiowanie nieprzetworzonego zapytania

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

Następnie można użyć SimpleSQLiteQuery, aby utworzyć zapytanie w czasie działania:

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

Konwertowanie blokujących funkcji DAO

Room korzysta z bogatej w funkcje asynchronicznej biblioteki kotlinx.coroutines, którą Kotlin oferuje na wielu platformach. Aby zapewnić optymalne działanie, funkcje suspend są wymuszane w przypadku obiektów DAO skompilowanych w projekcie KMP, z wyjątkiem obiektów DAO zaimplementowanych w androidMain, aby zachować zgodność wsteczną z istniejącą bazą kodu. W przypadku korzystania z Room dla KMP wszystkie funkcje DAO skompilowane na platformy inne niż platforma Androida muszą być funkcjami suspend.

Kotlin Multiplatform

Zawieszam zapytania

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

zawieszanie transakcji,

@Transaction
suspend fun transaction() {
    // ...
}

Tylko Android

Blokowanie zapytań

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

Blokowanie transakcji

@Transaction
fun blockingTransaction() { … }

Konwertowanie typów reaktywnych na Flow

Nie wszystkie funkcje DAO muszą być funkcjami zawieszania. Funkcje DAO, które zwracają typy reaktywne, takie jak LiveData lub Flowable RxJava, nie powinny być konwertowane na funkcje zawieszające. Jednak niektóre typy, takie jak LiveData, nie są zgodne z KMP. Funkcje DAO z reaktywnymi zwracanymi typami muszą zostać przeniesione do przepływów współprogramów.

Kotlin Multiplatform

Typy reaktywne: Flows

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

Tylko Android

Typy reaktywne, takie jak LiveData lub Flowable RxJava

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

Konwertowanie interfejsów API transakcji

Interfejsy API transakcji bazy danych dla Room KMP mogą rozróżniać transakcje zapisu (useWriterConnection) i odczytu (useReaderConnection).

Kotlin Multiplatform

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

Tylko Android

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

Transakcje zapisu

Używaj transakcji zapisu, aby mieć pewność, że wiele zapytań zapisuje dane niepodzielnie, dzięki czemu czytelnicy mogą stale uzyskiwać dostęp do danych. Możesz to zrobić za pomocą useWriterConnection, korzystając z jednego z 3 typów transakcji:

  • immediateTransaction: w trybie zapisywania logów z wyprzedzeniem (WAL) (domyślnym) ten typ transakcji uzyskuje blokadę po uruchomieniu, ale czytelnicy mogą nadal czytać. Jest to preferowany wybór w większości przypadków.

  • deferredTransaction: transakcja nie uzyska blokady do czasu pierwszej instrukcji zapisu. Używaj tego typu transakcji jako optymalizacji, gdy nie masz pewności, czy w ramach transakcji będzie potrzebna operacja zapisu. Jeśli na przykład rozpoczniesz transakcję, aby usunąć utwory z playlisty, podając tylko jej nazwę, a playlista nie istnieje, nie jest wymagana żadna operacja zapisu (usunięcia).

  • exclusiveTransaction: ten tryb działa tak samo jak immediateTransaction w trybie WAL. W innych trybach kronikowania uniemożliwia innym połączeniom z bazą danych odczytywanie bazy danych podczas transakcji.

Transakcje odczytu

Używaj transakcji odczytu, aby wielokrotnie odczytywać dane z bazy danych w spójny sposób. Na przykład gdy masz co najmniej 2 osobne zapytania i nie używasz klauzuli JOIN. W połączeniach z czytelnikami dozwolone są tylko transakcje odroczone. Próba rozpoczęcia natychmiastowej lub wyłącznej transakcji w połączeniu z czytnikiem spowoduje wyjątek, ponieważ są one uważane za operacje „zapisu”.

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

Niedostępne w Kotlin Multiplatform

Niektóre interfejsy API, które były dostępne na Androidzie, nie są dostępne w Kotlin Multiplatform.

Wywołanie zwrotne zapytania

Te interfejsy API do konfigurowania wywołań zwrotnych zapytań nie są dostępne w wersji wspólnej, więc nie są dostępne na platformach innych niż Android.

  • RoomDatabase.Builder.setQueryCallback
  • RoomDatabase.QueryCallback

Automatyczne zamykanie bazy danych

Interfejs API umożliwiający automatyczne zamykanie po upływie limitu czasu, RoomDatabase.Builder.setAutoCloseTimeout, jest dostępny tylko na Androidzie.

Gotowa baza danych

Te interfejsy API do tworzenia RoomDatabase przy użyciu istniejącej bazy danych (bazy danych w pakiecie) nie są dostępne w kodzie wspólnym, więc nie można ich używać na platformach innych niż Android:

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

Unieważnianie wielu instancji

Interfejs API umożliwiający unieważnianie w trybie podzielonym, RoomDatabase.Builder.enableMultiInstanceInvalidation, jest dostępny tylko na Androidzie i nie jest dostępny na platformach wspólnych ani innych.