將 Room 遷移至 Kotlin Multiplaform

本文件說明如何將現有的 Room 實作遷移至使用 Kotlin Multiplatform (KMP) 的實作。

視使用的 Room API 或程式碼集是否已使用協同程式,將現有 Android 程式碼集中的 Room 使用情形遷移至通用共用 KMP 模組,難度可能大不相同。本節提供了一些指南和提示,協助您將 Room 的使用情形遷移至通用模組。

請務必先熟悉 Room 與 KMP 版本在 Android 版本和 KMP 版本間的差異和缺少的功能,以及相關的設定。基本上,要成功的遷移作業,就必須重構 SupportSQLite* API 的使用方法,並將其替換為 SQLite Driver API,以及將 Room 宣告 (@Database 註解類別、DAO、實體等) 移至通用程式碼。

請先詳閱下列資訊,再繼續操作:

以下各節將說明成功遷移所需的各種步驟。

從支援 SQLite 遷移至 SQLite 驅動程式

androidx.sqlite.db 中的 API 僅供 Android 系統使用,且所有使用情形都必須以 SQLite 驅動程式 API 重構。為了提供回溯相容性,且只要 RoomDatabaseSupportSQLiteOpenHelper.Factory 設定 (即未設定 SQLiteDriver),Room 會在「相容模式」中運作,同時支援 SQLite 和 SQLite Driver API 都能如預期運作。這會啟用漸進式遷移作業,這樣您就不需要在單次變更中將所有支援 SQLite 用法轉換為 SQLite 驅動程式。

下列是支援 SQLite 和 SQLite 驅動程式的常見用法:

支援 SQLite (來自)

執行沒有結果的查詢

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

執行含有結果的查詢,但沒有引數

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

使用結果和引數執行查詢

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

SQLite 驅動程式 (目的地)

執行沒有結果的查詢

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

執行含有結果的查詢,但沒有引數

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

使用結果和引數執行查詢

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

可直接在 SupportSQLiteDatabase 中使用資料庫交易 API,搭配 beginTransaction()setTransactionSuccessful()endTransaction()。你也可以使用「runInTransaction()」透過 Room 查看這些配件。請將這些使用方式遷移至 SQLite Driver API。

支援 SQLite (來自)

執行交易 (使用 RoomDatabase)

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

執行交易 (使用 SupportSQLiteDatabase)

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

SQLite 驅動程式 (目的地)

執行交易 (使用 RoomDatabase)

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

執行交易 (使用 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")
}

不同的回呼覆寫也必須遷移至其驅動程式對應的項目:

支援 SQLite (來自)

遷移子類別

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

自動遷移規格子類別

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

資料庫回呼子類別

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

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

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

SQLite 驅動程式 (目的地)

遷移子類別

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

自動遷移規格子類別

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

資料庫回呼子類別

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

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

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

總結來說,在無法使用 RoomDatabase 時,請將 SQLiteDatabase 的用法替換為 SQLiteConnection,例如在回呼覆寫 (onMigrateonCreate 等) 中。如果 RoomDatabase 可用,請使用 RoomDatabase.useReaderConnectionRoomDatabase.useWriterConnection (而非 RoomDatabase.openHelper.writtableDatabase) 存取基礎資料庫連線。

將封鎖 DAO 函式轉換為暫停函式

KMP 版本的 Room 仰賴協同程式,在已設定的 CoroutineContext 上執行 I/O 作業。這表示您必須遷移所有封鎖的 DAO 函式來暫停函式。

封鎖 DAO 函式 (來源)

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

暫停 DAO 函式 (目的地)

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

如果現有的程式碼集尚未納入協同程式,將現有的 DAO 阻斷函式遷移至暫停函式可能會變得複雜。請參閱「Android 中的協同程式」,開始在程式碼集中使用協同程式。

將回應式傳回類型轉換為流程

並非所有 DAO 函式都必須暫停函式。傳回回應類型的 DAO 函式 (例如 LiveData 或 RxJava 的 Flowable) 不應轉換為暫停函式。但部分類型 (例如 LiveData) 與 KMP 不相容。具有回應傳回類型的 DAO 函式必須遷移至協同程式流程。

不相容的 KMP 類型 (來源)

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

相容的 KMP 類型 (目的地)

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

請參閱 Android 中的流程,開始在程式碼集使用 Flows。

設定協同程式結構定義 (選用)

您可以選擇將 RoomDatabase 設為使用 RoomDatabase.Builder.setQueryExecutor() 的共用應用程式執行工具,以執行資料庫作業。由於執行工具與 KMP 不相容,因此一般來源無法使用 Room 的 setQueryExecutor() API。而 RoomDatabase 必須以 CoroutineContext 進行設定。您可以使用 RoomDatabase.Builder.setCoroutineContext() 設定結構定義,如果未設定,RoomDatabase 將預設為使用 Dispatchers.IO

設定 SQLite 驅動程式

支援 SQLite 用量功能遷移至 SQLite 驅動程式 API 後,您必須使用 RoomDatabase.Builder.setDriver 設定驅動程式。建議的驅動程式為 BundledSQLiteDriver。如需可用的驅動程式實作說明,請參閱「驅動程式實作」。

使用 RoomDatabase.Builder.openHelperFactory() 設定的自訂 SupportSQLiteOpenHelper.Factory 在 KMP 中不支援,需要使用 SQLite 驅動程式介面重新實作自訂開啟輔助程式提供的功能。

移動 Room 宣告

完成大部分遷移步驟後,您可以將 Room 定義移至常用來源集。請注意,expect / actual 策略可用來逐步移動 Room 相關定義。舉例來說,如果並非所有封鎖的 DAO 函式都能遷移至暫停函式,則可宣告在通用程式碼中為空白,但在 Android 中加入封鎖函式的 expect @Dao 註解介面。

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