遷移 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 檔案匯出。如要匯出結構定義,請在 app/build.gradle 檔案中設定 room.schemaLocation 註解處理工具的屬性:

build.gradle

Groovy

android {
    ...
    defaultConfig {
        ...
        javaCompileOptions {
            annotationProcessorOptions {
                arguments += ["room.schemaLocation":
                             "$projectDir/schemas".toString()]
            }
        }
    }
}

Kotlin

android {
    ...
    defaultConfig {
        ...
        javaCompileOptions {
            annotationProcessorOptions {
                arguments += mapOf("room.schemaLocation" to "$projectDir/schemas")
            }
        }
    }
}

匯出的 JSON 檔案會呈現資料庫結構定義的記錄。建議將這些檔案儲存至版本管控系統,因為Room 可以用這些檔案建立舊版資料庫,進行測試。

單一遷移測試

測試前,須從 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.4.3"
}

Kotlin

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

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

測試套件提供 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 {
            // db has schema version 1. insert some data using SQL queries.
            // You cannot 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);

        // db has schema version 1. insert some data using SQL queries.
        // You cannot 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 will validate 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 will validate 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 先刪除再重建。

如果您只想在特定情況下,使用 Room 先刪除再重建資料表,請使用 fallbackToDestructiveMigration() 作為替代解決方案:

當升級至 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, DB Version 1, Room 2.1.0
@Entity
data class Song(
    @PrimaryKey
    val id: Long,
    val title: String
)

Java

// Song Entity, DB 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, DB 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, DB 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 ''");
    }
};

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

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

為確保在舊遷移路徑宣告資料欄預設值時,所有使用者的資料庫結構定義一致,請在 Room 首次升級至 2.2.0 或以上版本時,按照下列指示操作:

  1. 使用 @ColumnInfo 註解,分別在各個實體類別中,宣告資料欄預設值。
  2. 將資料庫版本號碼加一。
  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");
    }
};