Room zu Kotlin Multiplaform migrieren

In diesem Dokument wird beschrieben, wie Sie eine vorhandene Room-Implementierung zu einer Implementierung migrieren, die Kotlin Multiplatform (KMP) verwendet.

Der Schwierigkeitsgrad der Migration von Raumnutzungen in einer vorhandenen Android-Codebasis zu einem gemeinsamen freigegebenen KMP-Modul kann je nach den verwendeten Room APIs oder davon, ob die Codebasis bereits Coroutines verwendet, stark variieren. In diesem Abschnitt finden Sie einige Anleitungen und Tipps, wenn Sie versuchen, die Nutzung von Room zu einem gemeinsamen Modul zu migrieren.

Machen Sie sich zuerst mit den Unterschieden und fehlenden Funktionen zwischen der Android-Version von Room und der KMP-Version sowie mit der Einrichtung vertraut. Im Wesentlichen beinhaltet eine erfolgreiche Migration, die Nutzung der SupportSQLite* APIs zu refaktorieren und durch SQLite-Treiber-APIs zu ersetzen sowie Room-Deklarationen (@Database-annotierte Klasse, DAOs, Entitäten usw.) in gemeinsamen Code zu verschieben.

Lesen Sie die folgenden Informationen, bevor Sie fortfahren:

In den nächsten Abschnitten werden die verschiedenen Schritte für eine erfolgreiche Migration beschrieben.

Von Support-SQLite zum SQLite-Treiber migrieren

Die APIs in androidx.sqlite.db sind ausschließlich für Android verfügbar und jegliche Nutzungen müssen mit SQLite-Treiber APIs refaktoriert werden. Aus Gründen der Abwärtskompatibilität und solange RoomDatabase mit einem SupportSQLiteOpenHelper.Factory konfiguriert ist (d.h. es ist kein SQLiteDriver festgelegt), verhält sich Room im "Kompatibilitätsmodus", in dem sowohl die Unterstützung von SQLite- als auch die SQLite-Treiber-APIs wie erwartet funktionieren. Dies ermöglicht inkrementelle Migrationen, sodass Sie nicht alle Ihre Support-SQLite-Nutzungen in einer einzigen Änderung in den SQLite-Treiber konvertieren müssen.

Die folgenden Beispiele zeigen gängige Anwendungsbereiche von Support SQLite und die entsprechenden SQLite-Treiber:

Unterstützung von SQLite (von)

Abfrage ohne Ergebnis ausführen

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

Abfrage mit Ergebnis, aber ohne Argumente ausführen

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

Abfrage mit Ergebnis und Argumenten ausführen

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

SQLite-Treiber (to)

Abfrage ohne Ergebnis ausführen

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

Abfrage mit Ergebnis, aber ohne Argumente ausführen

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

Abfrage mit Ergebnis und Argumenten ausführen

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

Datenbank-Transaktions-APIs sind direkt in SupportSQLiteDatabase mit beginTransaction(), setTransactionSuccessful() und endTransaction() verfügbar. Sie sind auch über „Room“ mit runInTransaction() verfügbar. Migrieren Sie diese Anwendungen zu SQLite Driver APIs.

Unterstützung von SQLite (von)

Transaktion ausführen (mit RoomDatabase)

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

Transaktion ausführen (mit SupportSQLiteDatabase)

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

SQLite-Treiber (to)

Transaktion ausführen (mit RoomDatabase)

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

Transaktion ausführen (mit 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")
}

Verschiedene Callback-Überschreibungen müssen auch zu ihren Treiber-Entsprechungen migriert werden:

Unterstützung von SQLite (von)

Unterklassen für Migration

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

Unterklassen für die automatische Migrationsspezifikation

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

Unterklassen für Datenbank-Callbacks

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

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

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

SQLite-Treiber (to)

Unterklassen für Migration

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

Unterklassen für die automatische Migrationsspezifikation

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

Unterklassen für Datenbank-Callbacks

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

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

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

Zusammenfassend lässt sich sagen, dass die Verwendung von SQLiteDatabase durch SQLiteConnection ersetzt wird, wenn ein RoomDatabase nicht verfügbar ist, z. B. in Callback-Überschreibungen (onMigrate, onCreate usw.). Wenn ein RoomDatabase verfügbar ist, greifen Sie mit RoomDatabase.useReaderConnection und RoomDatabase.useWriterConnection anstelle von RoomDatabase.openHelper.writtableDatabase auf die zugrunde liegende Datenbankverbindung zu.

Blockierende DAO-Funktionen in Sperren von Funktionen umwandeln

Die KMP-Version von Room benötigt Koroutinen, um E/A-Vorgänge für die konfigurierte CoroutineContext auszuführen. Dies bedeutet, dass Sie alle blockierenden DAO-Funktionen migrieren müssen, um Funktionen anzuhalten.

DAO-Funktion blockieren (von)

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

DAO-Funktion anhalten (to)

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

Das Migrieren vorhandener DAO-Blockierfunktionen in Anhalten von Funktionen kann kompliziert sein, wenn die vorhandene Codebasis nicht bereits Koroutinen enthält. Informationen zum Verwenden von Koroutinen in Ihrer Codebasis finden Sie unter Koroutinen in Android.

Reaktive Rückgabetypen in Ablauf umwandeln

Nicht alle DAO-Funktionen müssen Aussetzerfunktionen sein. DAO-Funktionen, die reaktive Typen wie LiveData oder Flowable von RxJava zurückgeben, sollten nicht in Aussetzenfunktionen konvertiert werden. Einige Typen wie LiveData sind jedoch nicht KMP-kompatibel. DAO-Funktionen mit reaktiven Rückgabetypen müssen zu Koroutinenabläufen migriert werden.

Inkompatibler KMP-Typ (von)

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

Kompatibler KMP-Typ (zu)

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

Informationen zum Einstieg in die Verwendung von Abläufen in Ihrer Codebasis finden Sie unter Abläufe in Android.

Koroutinenkontext festlegen (optional)

Ein RoomDatabase kann optional mit freigegebenen Anwendungs-Executors konfiguriert werden, indem RoomDatabase.Builder.setQueryExecutor() zum Ausführen von Datenbankvorgängen verwendet wird. Da Executors nicht KMP-kompatibel sind, ist die setQueryExecutor() API von Room nicht für allgemeine Quellen verfügbar. Stattdessen muss RoomDatabase mit einer CoroutineContext konfiguriert werden. Ein Kontext kann mit RoomDatabase.Builder.setCoroutineContext() festgelegt werden. Wenn keiner festgelegt ist, verwendet RoomDatabase standardmäßig Dispatchers.IO.

SQLite-Treiber einrichten

Nachdem die Support-SQLite-Nutzungen zu SQLite-Treiber-APIs migriert wurden, muss ein Treiber mit RoomDatabase.Builder.setDriver konfiguriert werden. Der empfohlene Treiber ist BundledSQLiteDriver. Eine Beschreibung der verfügbaren Treiberimplementierungen finden Sie unter Treiberimplementierungen.

Benutzerdefinierte SupportSQLiteOpenHelper.Factory, die mit RoomDatabase.Builder.openHelperFactory() konfiguriert wurden, werden in KMP nicht unterstützt. Die vom benutzerdefinierten Open Helper bereitgestellten Features müssen mit SQLite-Treiberschnittstellen noch einmal implementiert werden.

Raumdeklarationen verschieben

Nachdem die meisten Migrationsschritte abgeschlossen sind, können Sie die Raumdefinitionen in einen gemeinsamen Quellsatz verschieben. Beachten Sie, dass die Strategien expect oder actual verwendet werden können, um raumbezogene Definitionen schrittweise zu verschieben. Wenn beispielsweise nicht alle blockierenden DAO-Funktionen zum Anhalten von Funktionen migriert werden können, ist es möglich, eine expect @Dao-annotierte Schnittstelle zu deklarieren, die im allgemeinen Code leer ist, aber blockierende Funktionen in Android enthält.

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