遷移 Room 資料庫

在應用程式中新增及變更功能時,為了配合這些異動,您必須修改 Room 實體類別和基礎資料庫表。當應用程式更新變更了資料庫的結構定義,請務必保存裝置資料庫中現有的使用者資料。

Room 為逐步遷移資料,提供自動遷移和手動遷移。自動遷移作業可處理大部分的基本結構定義變更,但遇上更複雜的變更時,遷移路徑可能便需要手動設定。

自動遷移

如要在兩個資料庫版本之間,宣告自動遷移作業,請在 @DatabaseautoMigrations 屬性中,新增 @AutoMigration 註解:

// Database class before the version update.
@Database(
  version = 1,
  entities = [User::class]
)
abstract class AppDatabaseV1 : RoomDatabase() {
  abstract fun userDao(): UserDao
}

// Database class after the version update.
@Database(
  version = 2,
  entities = [User::class],
  autoMigrations = [
    AutoMigration(from = 1, to = 2)
  ]
)
abstract class AppDatabaseV2 : RoomDatabase() {
  abstract fun userDao(): UserDao
}

自動遷移規格

如果 Room 偵測到不明確的結構定義異動,且無法在未輸入更多內容的情況下產生遷移計畫,則系統會擲回編譯時間錯誤訊息,您必須提供 AutoMigrationSpec 實作項目。一般而言,遷移失敗可能的原因如下:

  • 刪除或重新命名資料表。
  • 刪除或重新命名資料欄。

您可以使用 AutoMigrationSpec 為 Room 提供其他必要資訊,以正確產生遷移路徑。請定義會在 RoomDatabase 類別中實作 AutoMigrationSpec 的類別,並使用下列一或多個項目註解:

在自動遷移作業中,如要使用 AutoMigrationSpec 實作,請在對應的 @AutoMigration 註解中設定 spec 屬性:

@Database(
  version = 2,
  entities = [User::class],
  autoMigrations = [
    AutoMigration (
      from = 1,
      to = 2,
      spec = MigrationSpec1To2::class
    )
  ]
)
abstract class AppDatabaseWithSpec : RoomDatabase() {
  abstract fun userDao(): UserDao
}

@RenameTable(fromTableName = "User", toTableName = "AppUser")
internal class MigrationSpec1To2 : AutoMigrationSpec

自動化遷移作業完成後,如果您的應用程式需執行更多工作,可以實作 onPostMigrate。如果在 AutoMigrationSpec 中實作這個函式,自動遷移作業完成後,Room 會進行呼叫。

手動遷移

如果遷移過程涉及複雜的結構定義變更,Room 可能無法自動產生合適的遷移路徑。舉例來說,如果您決定將資料表裡的資料分拆成二份資料表,Room 就無法判斷應如何分割。在這種情況下,您必須導入 Migration 類別,手動定義遷移路徑。

Migration 類別會透過覆寫 migrate 函式,明確定義 startVersionendVersion 之間的遷移路徑。使用 addMigrations 函式,將 Migration 類別新增至資料庫建構工具:

val MIGRATION_1_2 = object : Migration(1, 2) {
  override suspend fun migrate(connection: SQLiteConnection) {
    connection.executeSQL("CREATE TABLE `Fruit` (`id` INTEGER, `name` TEXT, " +
      "PRIMARY KEY(`id`))")
  }
}

val MIGRATION_2_3 = object : Migration(2, 3) {
  override suspend fun migrate(connection: SQLiteConnection) {
    connection.executeSQL("ALTER TABLE Book ADD COLUMN pub_year INTEGER")
  }
}

Room.databaseBuilder<ManualMigrationDatabase>(applicationContext, "database-name")
  .addMigrations(MIGRATION_1_2, MIGRATION_2_3)
  .build()

定義遷移路徑時,某些版本可以使用自動遷移,而其他版本則需手動遷移。如果同一版本中,同時設定了自動和手動遷移,則 Room 會採取手動遷移。

測試遷移

遷移作業通常很複雜,遷移定義若有錯會使應用程式停擺。為了維持應用程式穩定,請進行遷移測試。Room 會提供 room3-testing 的 Maven 構件,協助您測試自動和手動的遷移作業。為了讓這個構件順利運作,您必須先匯出資料庫的結構定義。

匯出結構定義

在編譯期間,Room 會將資料庫的結構定義資訊匯出為 JSON 檔案。匯出的 JSON 檔案會呈現資料庫結構定義的記錄。請將這些檔案儲存在版本管控系統,以便重新建立舊版資料庫用於測試,並支援自動產生遷移作業。

使用 Room Gradle 外掛程式設定結構定義位置

如要指定結構定義目錄,請套用 Room Gradle 外掛程式,並使用 room3 擴充功能。

Groovy

plugins {
  id 'androidx.room3'
}

room3 {
  schemaDirectory "$projectDir/schemas"
}

Kotlin

plugins {
  id("androidx.room3")
}

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

如果資料庫結構定義會因變數、風味或建構類型而異,您必須多次使用 schemaDirectory 設定指定不同位置,每次都以 variantMatchName 做為第一個引數。根據與變體名稱的簡單比較結果,每項設定可比對一或多個變體。

請務必詳盡列出所有變體。您也可以加入沒有 variantMatchNameschemaDirectory(),處理任何其他設定不相符的變體。舉例來說,在具有兩個建構變種版本 demofull,以及兩個建構類型 debugrelease 的應用程式中,下列都是有效的設定:

Groovy

room3 {
  // Applies to 'demoDebug' only
  schemaDirectory "demoDebug", "$projectDir/schemas/demoDebug"

  // Applies to 'demoDebug' and 'demoRelease'
  schemaDirectory "demo", "$projectDir/schemas/demo"

  // Applies to 'demoDebug' and 'fullDebug'
  schemaDirectory "debug", "$projectDir/schemas/debug"

  // Applies to variants that aren't matched by other configurations.
  schemaDirectory "$projectDir/schemas"
}

Kotlin

room3 {
  // Applies to 'demoDebug' only
  schemaDirectory("demoDebug", "$projectDir/schemas/demoDebug")

  // Applies to 'demoDebug' and 'demoRelease'
  schemaDirectory("demo", "$projectDir/schemas/demo")

  // Applies to 'demoDebug' and 'fullDebug'
  schemaDirectory("debug", "$projectDir/schemas/debug")

  // Applies to variants that aren't matched by other configurations.
  schemaDirectory("$projectDir/schemas")
}

使用註解處理工具選項設定結構定義位置

如果您未使用 Room Gradle 外掛程式,請使用 room.schemaLocation 註解處理工具選項設定結構定義位置。

Gradle 會將這個目錄中的檔案做為某些 Gradle 工作的輸入和輸出內容。為確保漸進式和快取建構作業的正確性與效能,您必須使用 Gradle 的 CommandLineArgumentProvider,向 Gradle 說明這個目錄。

首先,請將下列 RoomSchemaArgProvider 類別複製到模組的 Gradle 建構檔案中。範例類別中的 asArguments 函式會將 room.schemaLocation=${schemaDir.path} 傳遞至 KSP。如果您使用 KAPTjavac,請改為將這個值變更為 -Aroom.schemaLocation=${schemaDir.path}

Groovy

class RoomSchemaArgProvider implements CommandLineArgumentProvider {

  @InputDirectory
  @PathSensitive(PathSensitivity.RELATIVE)
  File schemaDir

  RoomSchemaArgProvider(File schemaDir) {
    this.schemaDir = schemaDir
  }

  @Override
  Iterable<String> asArguments() {
    return ["room.schemaLocation=${schemaDir.path}".toString()]
  }
}

Kotlin

class RoomSchemaArgProvider(
  @get:InputDirectory
  @get:PathSensitive(PathSensitivity.RELATIVE)
  val schemaDir: File
) : CommandLineArgumentProvider {

  override fun asArguments(): Iterable<String> {
    return listOf("room.schemaLocation=${schemaDir.path}")
  }
}

接著設定編譯選項,以便使用 RoomSchemaArgProvider 搭配指定結構定義目錄:

Groovy

ksp {
  arg(new RoomSchemaArgProvider(new File(projectDir, "schemas")))
}

Kotlin

ksp {
  arg(RoomSchemaArgProvider(File(projectDir, "schemas")))
}

單一遷移測試

測試遷移作業前,請將 androidx.room3:room3-testing 構件新增至測試依附元件,然後新增匯出的結構定義位置,做為素材資源目錄:

Groovy

android {
    ...
    sourceSets {
        // Adds exported schema location as test app assets if not using
        // the Room Gradle Plugin.
        androidTest.assets.srcDirs += files("$projectDir/schemas".toString())
    }
}

dependencies {
    ...
    androidTestImplementation "androidx.room3:room3-testing:3.0.1"
}

Kotlin

android {
    ...
    sourceSets {
        // Adds exported schema location as test app assets if not using
        // the Room Gradle Plugin.
        getByName("androidTest").assets.srcDir("$projectDir/schemas")
    }
}

dependencies {
    ...
    testImplementation("androidx.room3:room3-testing:3.0.1")
}

測試套件提供 MigrationTestHelper 類別,可讀取匯出的結構定義檔。套件也會實作 JUnit4 TestRule 介面,用於管理既有資料庫。

以下示範測試單項遷移作業:

@RunWith(AndroidJUnit4::class)
class MigrationTest {
    private val TEST_DB = "migration-test"

    private val instrumentation = InstrumentationRegistry.getInstrumentation()

    @get:Rule
    val helper = MigrationTestHelper(
        instrumentation = instrumentation,
        databaseClass = MigrationDb::class,
        driver = AndroidSQLiteDriver(),
        file = instrumentation.targetContext.getDatabasePath(TEST_DB),
    )

    @Test
    fun migrate1To2() = runTest {
        val connection = helper.createDatabase(1)
        // Database has schema version 1. Insert some data using SQL queries.
        // You can't use DAO classes because they expect the latest schema.
        connection.execSQL("INSERT INTO User (id, name) VALUES (1, 'John Doe')")
        connection.close()

        // Re-open the database with version 2 and provide MIGRATION_1_2
        val migratedConnection = helper.runMigrationsAndValidate(2, listOf(MIGRATION_1_2))

        // MigrationTestHelper automatically verifies the schema changes,
        // but you need to validate that the data was migrated properly.
        val hasData = migratedConnection.prepare("SELECT COUNT(*) FROM User").use {
          it.step()
          it.getLong(0) > 0
        }
        assertTrue("Expected data was not migrated", hasData)
        migratedConnection.close()
    }
}

測試所有遷移

雖然可以只測試單項逐步遷移作業,但建議您一次測試應用程式資料庫定義的全部遷移作業。如此一來,可確保近期建立的資料庫執行個體和遵循已定義遷移路徑的舊有執行個體沒有差異。

以下示範一次測試所有已定義的遷移作業:

@RunWith(AndroidJUnit4::class)
class MigrationTest {
    private val TEST_DB = "migration-test"

    private val instrumentation = InstrumentationRegistry.getInstrumentation()

    // Array of all migrations.
    private val ALL_MIGRATIONS = arrayOf(MIGRATION_1_2, MIGRATION_2_3, MIGRATION_3_4)

    @get:Rule
    val helper: MigrationTestHelper = MigrationTestHelper(
        instrumentation = instrumentation,
        databaseClass = MigrationDb::class,
        driver = AndroidSQLiteDriver(),
        file = instrumentation.targetContext.getDatabasePath(TEST_DB),
    )

    @Test
    fun migrateAll() = runTest {
        // Create earliest version of the database.
        val connection = helper.createDatabase(1)
        connection.close()

        // Create latest version of the database.
        val db = Room.databaseBuilder<AppDatabase>(instrumentation.targetContext, TEST_DB)
          .setDriver(AndroidSQLiteDriver())
          .addMigrations(*ALL_MIGRATIONS)
          .build()
        // Open the database, Room validates the schema once all migrations
        // execute.
        db.useReaderConnection { connection ->
          // Perform additional validation
        }

        db.close()
    }
}

妥善處理缺失的遷移路徑

如果裝置上現有的資料庫要升級至最新版本,而 Room 沒有找到遷移路徑,則會發生 IllegalStateException。若缺乏遷移路徑導致遺失現有資料,請在建立資料庫時,呼叫 fallbackToDestructiveMigration 建構工具函式:

Room.databaseBuilder<FallbackMigrationDatabase>(applicationContext, "database-name")
        .fallbackToDestructiveMigration()
        .build()

如果需要執行逐步遷移作業,但沒有已定義的遷移路徑,此函式會設定 Room,以破壞性方式重建應用程式資料庫中的資料表。

如要只在特定情況下改回使用破壞性重建方式,請採用 fallbackToDestructiveMigration 的下列替代做法:

  • 在結構定義記錄中,如果有特定版本會導致錯誤發生,且無法透過遷移路徑排解,請改用 fallbackToDestructiveMigrationFrom。這個函式表示在遷移特定版本時,才要求 Room 進行刪除再重建。
  • 如果您只有從較高的資料庫版本遷移至較低版本時才需要 Room 進行刪除再重建,請改用 fallbackToDestructiveMigrationOnDowngrade