遷移 Room 資料庫

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

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

自動遷移

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

Kotlin

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

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

Java

// Database class before the version update.
@Database(
  version = 1,
  entities = {User.class}
)
public abstract class AppDatabase extends RoomDatabase {
  ...
}

// Database class after the version update.
@Database(
  version = 2,
  entities = {User.class},
  autoMigrations = {
    @AutoMigration (from = 1, to = 2)
  }
)
public abstract class AppDatabase extends RoomDatabase {
  ...
}

自動遷移規格

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

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

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

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

Kotlin

@Database(
  version = 2,
  entities = [User::class],
  autoMigrations = [
    AutoMigration (
      from = 1,
      to = 2,
      spec = AppDatabase.MyAutoMigration::class
    )
  ]
)
abstract class AppDatabase : RoomDatabase() {
  @RenameTable(fromTableName = "User", toTableName = "AppUser")
  class MyAutoMigration : AutoMigrationSpec
  ...
}

Java

@Database(
  version = 2,
  entities = {AppUser.class},
  autoMigrations = {
    @AutoMigration (
      from = 1,
      to = 2,
      spec = AppDatabase.MyAutoMigration.class
    )
  }
)
public abstract class AppDatabase extends RoomDatabase {
  @RenameTable(fromTableName = "User", toTableName = "AppUser")
  static class MyAutoMigration implements AutoMigrationSpec { }
  ...
}

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

手動遷移

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

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

Kotlin

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

val MIGRATION_2_3 = object : Migration(2, 3) {
  override fun migrate(database: SupportSQLiteDatabase) {
    database.execSQL("ALTER TABLE Book ADD COLUMN pub_year INTEGER")
  }
}

Room.databaseBuilder(applicationContext, MyDb::class.java, "database-name")
  .addMigrations(MIGRATION_1_2, MIGRATION_2_3).build()

Java

static final Migration MIGRATION_1_2 = new Migration(1, 2) {
  @Override
  public void migrate(SupportSQLiteDatabase database) {
    database.execSQL("CREATE TABLE `Fruit` (`id` INTEGER, "
      + "`name` TEXT, PRIMARY KEY(`id`))");
  }
};

static final Migration MIGRATION_2_3 = new Migration(2, 3) {
  @Override
  public void migrate(SupportSQLiteDatabase database) {
    database.execSQL("ALTER TABLE Book "
      + " ADD COLUMN pub_year INTEGER");
  }
};

Room.databaseBuilder(getApplicationContext(), MyDb.class, "database-name")
  .addMigrations(MIGRATION_1_2, MIGRATION_2_3).build();

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

測試遷移

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

匯出結構定義

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

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

如果使用 Room 2.6.0 以上版本,您可以套用 Room Gradle 外掛程式,並透過 room 擴充功能指定結構定義目錄。

Groovy

plugins {
  id 'androidx.room'
}

room {
  schemaDirectory "$projectDir/schemas"
}

Kotlin

plugins {
  id("androidx.room")
}

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

如果資料庫結構定義因變數、變種版本或建構類型而不同,您必須多次使用 schemaDirectory() 設定來指定不同的位置,每個位置均有 variantMatchName 做為第一個引數。根據與變化版本名稱的簡單比較,每項設定可以比對一或多個變化版本。

請確認這些例子詳盡且涵蓋所有變化版本。您也可以加入沒有 variantMatchNameschemaDirectory(),處理與其他設定不相符的變數。舉例來說,如果應用程式具有 demofull 兩個建構變種版本,以及 debugrelease 兩種建構類型,則下列為有效的設定:

Groovy

room {
  // Applies to 'demoDebug' only
  schemaLocation "demoDebug", "$projectDir/schemas/demoDebug"

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

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

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

Kotlin

room {
  // Applies to 'demoDebug' only
  schemaLocation("demoDebug", "$projectDir/schemas/demoDebug")

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

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

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

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

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

這個目錄中的檔案會做為部分 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() {
    // Note: If you're using KAPT and javac, change the line below to
    // return ["-Aroom.schemaLocation=${schemaDir.path}".toString()].
    return ["room.schemaLocation=${schemaDir.path}".toString()]
  }
}

Kotlin

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

  override fun asArguments(): Iterable<String> {
    // Note: If you're using KAPT and javac, change the line below to
    // return listOf("-Aroom.schemaLocation=${schemaDir.path}").
    return listOf("room.schemaLocation=${schemaDir.path}")
  }
}

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

Groovy

// For KSP, configure using KSP extension:
ksp {
  arg(new RoomSchemaArgProvider(new File(projectDir, "schemas")))
}

// For javac or KAPT, configure using android DSL:
android {
  ...
  defaultConfig {
    javaCompileOptions {
      annotationProcessorOptions {
        compilerArgumentProviders(
          new RoomSchemaArgProvider(new File(projectDir, "schemas"))
        )
      }
    }
  }
}

Kotlin

// For KSP, configure using KSP extension:
ksp {
  arg(RoomSchemaArgProvider(File(projectDir, "schemas")))
}

// For javac or KAPT, configure using android DSL:
android {
  ...
  defaultConfig {
    javaCompileOptions {
      annotationProcessorOptions {
        compilerArgumentProviders(
          RoomSchemaArgProvider(File(projectDir, "schemas"))
        )
      }
    }
  }
}

單一遷移測試

測試遷移作業前,請從 Room 將 androidx.room:room-testing 的 Maven 構件加到測試依附元件,然後新增匯出的結構定義位置,做為素材資源資料夾:

build.gradle

Groovy

android {
    ...
    sourceSets {
        // Adds exported schema location as test app assets.
        androidTest.assets.srcDirs += files("$projectDir/schemas".toString())
    }
}

dependencies {
    ...
    androidTestImplementation "androidx.room:room-testing:2.6.1"
}

Kotlin

android {
    ...
    sourceSets {
        // Adds exported schema location as test app assets.
        getByName("androidTest").assets.srcDir("$projectDir/schemas")
    }
}

dependencies {
    ...
    testImplementation("androidx.room:room-testing:2.6.1")
}

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

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

Kotlin

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

    @get:Rule
    val helper: MigrationTestHelper = MigrationTestHelper(
            InstrumentationRegistry.getInstrumentation(),
            MigrationDb::class.java.canonicalName,
            FrameworkSQLiteOpenHelperFactory()
    )

    @Test
    @Throws(IOException::class)
    fun migrate1To2() {
        var db = helper.createDatabase(TEST_DB, 1).apply {
            // Database has schema version 1. Insert some data using SQL queries.
            // You can't use DAO classes because they expect the latest schema.
            execSQL(...)

            // Prepare for the next version.
            close()
        }

        // Re-open the database with version 2 and provide
        // MIGRATION_1_2 as the migration process.
        db = helper.runMigrationsAndValidate(TEST_DB, 2, true, MIGRATION_1_2)

        // MigrationTestHelper automatically verifies the schema changes,
        // but you need to validate that the data was migrated properly.
    }
}

Java

@RunWith(AndroidJUnit4.class)
public class MigrationTest {
    private static final String TEST_DB = "migration-test";

    @Rule
    public MigrationTestHelper helper;

    public MigrationTest() {
        helper = new MigrationTestHelper(InstrumentationRegistry.getInstrumentation(),
                MigrationDb.class.getCanonicalName(),
                new FrameworkSQLiteOpenHelperFactory());
    }

    @Test
    public void migrate1To2() throws IOException {
        SupportSQLiteDatabase db = helper.createDatabase(TEST_DB, 1);

        // Database has schema version 1. Insert some data using SQL queries.
        // You can't use DAO classes because they expect the latest schema.
        db.execSQL(...);

        // Prepare for the next version.
        db.close();

        // Re-open the database with version 2 and provide
        // MIGRATION_1_2 as the migration process.
        db = helper.runMigrationsAndValidate(TEST_DB, 2, true, MIGRATION_1_2);

        // MigrationTestHelper automatically verifies the schema changes,
        // but you need to validate that the data was migrated properly.
    }
}

測試所有遷移

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

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

Kotlin

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

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

    @get:Rule
    val helper: MigrationTestHelper = MigrationTestHelper(
            InstrumentationRegistry.getInstrumentation(),
            AppDatabase::class.java.canonicalName,
            FrameworkSQLiteOpenHelperFactory()
    )

    @Test
    @Throws(IOException::class)
    fun migrateAll() {
        // Create earliest version of the database.
        helper.createDatabase(TEST_DB, 1).apply {
            close()
        }

        // Open latest version of the database. Room validates the schema
        // once all migrations execute.
        Room.databaseBuilder(
            InstrumentationRegistry.getInstrumentation().targetContext,
            AppDatabase::class.java,
            TEST_DB
        ).addMigrations(*ALL_MIGRATIONS).build().apply {
            openHelper.writableDatabase.close()
        }
    }
}

Java

@RunWith(AndroidJUnit4.class)
public class MigrationTest {
    private static final String TEST_DB = "migration-test";

    @Rule
    public MigrationTestHelper helper;

    public MigrationTest() {
        helper = new MigrationTestHelper(InstrumentationRegistry.getInstrumentation(),
                AppDatabase.class.getCanonicalName(),
                new FrameworkSQLiteOpenHelperFactory());
    }

    @Test
    public void migrateAll() throws IOException {
        // Create earliest version of the database.
        SupportSQLiteDatabase db = helper.createDatabase(TEST_DB, 1);
        db.close();

        // Open latest version of the database. Room validates the schema
        // once all migrations execute.
        AppDatabase appDb = Room.databaseBuilder(
                InstrumentationRegistry.getInstrumentation().getTargetContext(),
                AppDatabase.class,
                TEST_DB)
                .addMigrations(ALL_MIGRATIONS).build();
        appDb.getOpenHelper().getWritableDatabase();
        appDb.close();
    }

    // Array of all migrations.
    private static final Migration[] ALL_MIGRATIONS = new Migration[]{
            MIGRATION_1_2, MIGRATION_2_3, MIGRATION_3_4};
}

妥善處理缺失的遷移路徑

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

Kotlin

Room.databaseBuilder(applicationContext, MyDb::class.java, "database-name")
        .fallbackToDestructiveMigration()
        .build()

Java

Room.databaseBuilder(getApplicationContext(), MyDb.class, "database-name")
        .fallbackToDestructiveMigration()
        .build();

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

如果您只想在特定情況下,讓 Room 改回使用破壞性重建方式,可以採用 fallbackToDestructiveMigration() 的一些替代做法:

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

當升級至 Room 2.2.0 以上版本時,處理資料欄的預設值

在 Room 2.2.0 以上版本中,可以使用註解 @ColumnInfo(defaultValue = "...") 定義某欄的預設值。在 2.2.0 之前的版本,要定義資料欄的預設值,就只能直接在執行的 SQL 陳述式中定義資料欄,藉此建立 Room 不知道的預設值。也就是說,如果最初是用 Room 2.2.0 之前的版本來建立資料庫,當將應用程式升級以使用 2.2.0 版本時,因為預設值並非使用 Room API 建立,您可能需要為其提供特定遷移路徑。

舉例來說,假設資料庫第 1 版本定義了 Song 實體:

Kotlin

// Song entity, database version 1, Room 2.1.0.
@Entity
data class Song(
    @PrimaryKey
    val id: Long,
    val title: String
)

Java

// Song entity, database version 1, Room 2.1.0.
@Entity
public class Song {
    @PrimaryKey
    final long id;
    final String title;
}

此外,假設相同資料庫第 2 版新增了 NOT NULL 資料欄,並定義從第 1 版到第 2 版的遷移路徑:

Kotlin

// Song entity, database version 2, Room 2.1.0.
@Entity
data class Song(
    @PrimaryKey
    val id: Long,
    val title: String,
    val tag: String // Added in version 2.
)

// Migration from 1 to 2, Room 2.1.0.
val MIGRATION_1_2 = object : Migration(1, 2) {
    override fun migrate(database: SupportSQLiteDatabase) {
        database.execSQL(
            "ALTER TABLE Song ADD COLUMN tag TEXT NOT NULL DEFAULT ''")
    }
}

Java

// Song entity, database version 2, Room 2.1.0.
@Entity
public class Song {
    @PrimaryKey
    final long id;
    final String title;
    @NonNull
    final String tag; // Added in version 2.
}


// Migration from 1 to 2, Room 2.1.0.
static final Migration MIGRATION_1_2 = new Migration(1, 2) {
    @Override
    public void migrate(SupportSQLiteDatabase database) {
        database.execSQL(
            "ALTER TABLE Song ADD COLUMN tag TEXT NOT NULL DEFAULT ''");
    }
};

這會導致更新的應用程式與新安裝的應用程式之間,基礎資料表出現差異。因為tag 資料欄的預設值只在第 1 版到第 2 版的遷移路徑中宣告,任何從第 2 版應用程式開始安裝的使用者,資料庫結構定義中都不會有 tag 的預設值。

如果 Room 版本低於 2.2.0,出現這種差異並不會造成危害。不過,如果應用程式日後升級至使用 Room 2.2.0 以上版本,並且透過 @ColumnInfo 註解變更 Song 實體類別,為 tag 加入預設值,那麼 Room 就能偵測到這項差異。這將導致結構定義驗證失敗。

在您初次將應用程式更新為使用 Room 以上版本時,請先按照以下步驟操作,確保在先前的遷移路徑中宣告資料欄預設值時,所有使用者的資料庫結構定義皆保持一致。

  1. 使用 @ColumnInfo 註解,分別在各個實體類別中,宣告資料欄預設值。
  2. 將資料庫版本號碼加 1。
  3. 定義實作先刪除再重建策略的新版本遷移路徑,將必要的預設值新增至現有資料欄。

以下範例會說明這項程序:

Kotlin

// Migration from 2 to 3, Room 2.2.0.
val MIGRATION_2_3 = object : Migration(2, 3) {
    override fun migrate(database: SupportSQLiteDatabase) {
        database.execSQL("""
                CREATE TABLE new_Song (
                    id INTEGER PRIMARY KEY NOT NULL,
                    name TEXT,
                    tag TEXT NOT NULL DEFAULT ''
                )
                """.trimIndent())
        database.execSQL("""
                INSERT INTO new_Song (id, name, tag)
                SELECT id, name, tag FROM Song
                """.trimIndent())
        database.execSQL("DROP TABLE Song")
        database.execSQL("ALTER TABLE new_Song RENAME TO Song")
    }
}

Java

// Migration from 2 to 3, Room 2.2.0.
static final Migration MIGRATION_2_3 = new Migration(2, 3) {
    @Override
    public void migrate(SupportSQLiteDatabase database) {
        database.execSQL("CREATE TABLE new_Song (" +
                "id INTEGER PRIMARY KEY NOT NULL," +
                "name TEXT," +
                "tag TEXT NOT NULL DEFAULT '')");
        database.execSQL("INSERT INTO new_Song (id, name, tag) " +
                "SELECT id, name, tag FROM Song");
        database.execSQL("DROP TABLE Song");
        database.execSQL("ALTER TABLE new_Song RENAME TO Song");
    }
};