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. Ta strona zawiera informacje o korzystaniu z biblioteki 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.

Konfiguracja zależności

Aby skonfigurować bibliotekę 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]
room = "2.8.4"
sqlite = "2.6.2"
ksp = "<kotlinCompatibleKspVersion>"

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

# Optional SQLite Wrapper available in version 2.8.0 and higher
androidx-room-sqlite-wrapper = { module = "androidx.room:room-sqlite-wrapper", version.ref = "room" }

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

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

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

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

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

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

Dodaj zależności KSP do bloku dependencies głównego. Pamiętaj, że musisz dodać wszystkie elementy docelowe używane przez aplikację. Więcej informacji znajdziesz w artykule KSP z Kotlin Multiplatform.

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

Określ katalog schematu Room. Dodatkowe informacje znajdziesz w artykule Ustawianie lokalizacji schematu za pomocą wtyczki Room Gradle.

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

Definiowanie klas bazy danych

Musisz utworzyć klasę bazy danych z adnotacją @Database wraz z DAO i encjami w wspólnym zbiorze źródeł 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 z interfejsem RoomDatabaseConstructor, kompilator Room wygeneruje implementacje actual. Android Studio może wyświetlić to ostrzeżenie, które możesz pominąć 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ś swoje encje 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 narzędzia do tworzenia bazy danych specyficznego dla platformy

Aby utworzyć instancję biblioteki Room na każdej platformie, musisz zdefiniować narzędzie do tworzenia bazy danych. Jest to jedyna część interfejsu API, która musi znajdować się w zestawach źródeł specyficznych dla platformy ze względu na różnice w interfejsach API systemu plików.

Android

Na Androidzie lokalizacja bazy danych jest zwykle uzyskiwana za pomocą interfejsu Context.getDatabasePath() API. Aby utworzyć instancję bazy danych, określ a Context wraz ze ś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,
    )
}

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

JVM (komputer)

Aby utworzyć instancję bazy danych, podaj ścieżkę bazy danych za pomocą interfejsów API Java lub 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,
    )
}

Tworzenie instancji bazy danych

Gdy uzyskasz RoomDatabase.Builder z jednego z konstruktorów specyficznych dla platformy, możesz skonfigurować resztę bazy danych Room we wspólnym kodzie wraz z rzeczywistą instancją bazy danych.

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

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

Wybieranie sterownika SQLite

Poprzedni fragment kodu wywołuje funkcję narzędzia do tworzenia setDriver, aby określić, jakiego sterownika SQLite powinna używać baza danych Room. Te sterowniki różnią się w zależności od platformy docelowej. W poprzednich fragmentach kodu używamy 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ć SQLite dostarczonego przez system operacyjny, użyj interfejsu setDriver API w zestawach źródeł specyficznych dla platformy, które określają sterownik specyficzny dla 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ę linkera -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 Coroutine (opcjonalnie)

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

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

Minimalizacja i zaciemnianie

Jeśli projekt jest zminimalizowany lub zaciemniony, musisz dodać tę regułę ProGuard, aby biblioteka Room mogła znaleźć wygenerowaną implementację definicji bazy danych:

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

Migracja do Kotlin Multiplatform

Biblioteka Room została pierwotnie opracowana jako biblioteka na Androida, a następnie przeniesiona do KMP z naciskiem na zgodność interfejsu API. Wersja KMP biblioteki Room różni się nieco w zależności od platformy i od wersji specyficznej dla Androida. Te różnice są wymienione i opisane poniżej.

Migracja z Support SQLite do sterownika SQLite

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

Aby zachować zgodność wsteczną, i dopóki RoomDatabase jest skonfigurowana za pomocą SupportSQLiteOpenHelper.Factory (na przykład nie jest ustawiony SQLiteDriver), biblioteka Room działa w „trybie zgodności”, w którym zarówno interfejsy API Support SQLite, jak i sterownika SQLite działają zgodnie z oczekiwaniami. Umożliwia to migracje przyrostowe, dzięki czemu nie musisz konwertować wszystkich użyć Support SQLite na sterownik SQLite w ramach jednej zmiany.

Korzystanie z otoki SQLite biblioteki Room (opcjonalnie)

Artefakt androidx.room:room-sqlite-wrapper udostępnia interfejsy API do łączenia SQLiteDriver i SupportSQLiteDatabase podczas migracji.

Aby uzyskać SupportSQLiteDatabase z RoomDatabase skonfigurowanej za pomocą SQLiteDriver, użyj nowej funkcji rozszerzenia RoomDatabase.getSupportWrapper(). Ta otoka zgodności pomaga zachować dotychczasowe użycia SupportSQLiteDatabase (często uzyskiwane z RoomDatabase.openHelper.writableDatabase) podczas wdrażania SQLiteDriver, zwłaszcza w przypadku baz kodu z rozbudowanymi użyciami interfejsu SupportSQLite API, które chcą używać BundledSQLiteDriver.

Konwertowanie podklas migracji

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

Kotlin Multiplatform

Podklasy migracji

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

Podklasy specyfikacji automatycznej migracji

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

Tylko na Androidzie

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

Konwertowanie wywołania zwrotnego bazy danych

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

Kotlin Multiplatform

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

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

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

Tylko na Androidzie

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 platformach innych niż Android, będą musiały deklarować parametr typu RoomRawQuery zamiast SupportSQLiteQuery.

Kotlin Multiplatform

Definiowanie surowego 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 todoDao().getTodos(query)
}

Tylko na Androidzie

Definiowanie surowego 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

Biblioteka Room korzysta z bogatej w funkcje asynchronicznej biblioteki kotlinx.coroutines, którą Kotlin oferuje na wielu platformach. Aby zapewnić optymalną funkcjonalność, w przypadku DAO skompilowanych w projekcie KMP wymuszane są funkcje suspend, z wyjątkiem DAO zaimplementowanych w androidMain, aby zachować zgodność wsteczną z istniejącą bazą kodu. W przypadku korzystania z biblioteki Room w KMP wszystkie funkcje DAO skompilowane na platformach innych niż Android muszą być funkcjami suspend.

Kotlin Multiplatform

Zawieszanie zapytań

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

Zawieszanie transakcji

@Transaction
suspend fun transaction() {  }

Tylko na Androidzie

Blokowanie zapytań

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

Blokowanie transakcji

@Transaction
fun blockingTransaction() {  }

Konwertowanie typów reaktywnych na przepływ

Nie wszystkie funkcje DAO muszą być funkcjami zawieszającymi. Funkcji DAO, które zwracają typy reaktywne, takie jak LiveData lub Flowable RxJava, nie należy konwertować na funkcje zawieszające. Niektóre typy, takie jak LiveData, nie są jednak zgodne z KMP. Funkcje DAO z reaktywnymi typami zwracanymi 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 na Androidzie

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 biblioteki Room KMP mogą rozróżniać transakcje zapisu (useWriterConnection) i odczytu (useReaderConnection).

Kotlin Multiplatform

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

Tylko na Androidzie

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

Transakcje zapisu

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

  • immediateTransaction: w trybie Write-Ahead Logging (WAL) (domyślnym) ten typ transakcji uzyskuje blokadę po rozpoczęciu, ale czytelnicy mogą nadal odczytywać dane. Jest to preferowane rozwiązanie w większości przypadków.

  • deferredTransaction: transakcja nie uzyska blokady do czasu pierwszej instrukcji zapisu. Użyj 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 będzie potrzebna żadna operacja zapisu (usuwania).

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

Transakcje odczytu

Używaj transakcji odczytu, aby konsekwentnie odczytywać dane z bazy danych wiele razy. Na przykład gdy masz co najmniej 2 oddzielne zapytania i nie używasz klauzuli JOIN. W połączeniach czytnika dozwolone są tylko transakcje odroczone. Próba rozpoczęcia transakcji natychmiastowej lub wyłącznej w połączeniu czytnika spowoduje zgłoszenie wyjątku, ponieważ są one uważane za operacje „zapisu”.

val database: RoomDatabase = 
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 kodzie wspólnym, a tym samym na platformach innych niż Android.

  • RoomDatabase.Builder.setQueryCallback
  • RoomDatabase.QueryCallback

Planujemy dodać obsługę wywołań zwrotnych zapytań w przyszłej wersji biblioteki Room.

Interfejs API do konfigurowania RoomDatabase za pomocą wywołania zwrotnego zapytania RoomDatabase.Builder.setQueryCallback wraz z interfejsem wywołania zwrotnego RoomDatabase.QueryCallback nie jest dostępny w kodzie wspólnym, a tym samym nie jest dostępny na platformach innych niż Android.

Automatyczne zamykanie bazy danych

Interfejs API do włączania automatycznego zamykania po upływie limitu czasu, RoomDatabase.Builder.setAutoCloseTimeout, jest dostępny tylko na Androidzie i nie jest dostępny na innych platformach.

Wstępnie spakowana baza danych

Te interfejsy API do tworzenia RoomDatabase za pomocą istniejącej bazy danych (czyli wstępnie spakowanej bazy danych) nie są dostępne w kodzie wspólnym, a tym samym na platformach innych niż Android. Te interfejsy API to:

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

Planujemy dodać obsługę wstępnie spakowanych baz danych w przyszłej wersji biblioteki Room.

Unieważnianie wielu instancji

Interfejs API do włączania unieważniania w trybie podzielonym, RoomDatabase.Builder.enableMultiInstanceInvalidation, jest dostępny tylko na Androidzie i nie jest dostępny w kodzie wspólnym ani na innych platformach.