在應用程式中新增及變更功能時,為了配合這些異動,您必須修改 Room 實體類別和基礎資料庫表。裝置的應用程式更新變更了資料庫結構定義時,資料庫裡的使用者資料必須保留。
Room 為逐步遷移資料,提供自動遷移和手動遷移。自動遷移作業可處理大部分的基本結構定義變更,但遇上更複雜的變更時,遷移路徑可能便需要手動設定。
自動遷移
如要在兩個資料庫版本之間,宣告自動遷移作業,請在 @Database
的 autoMigrations
屬性中,新增 @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()
方法,明確定義 startVersion
和 endVersion
之間的遷移路徑。使用 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
檔案中的 CommandLineArgumentProvider
,設定 room.schemaLocation
註解處理工具引數。
首先,定義將 room.schemaLocation
提供給處理工具的 CommandLineArgumentProvider
,如下所示。請注意,下方類別中的 asArguments()
方法會將 -Aroom.schemaLocation=${schemaDir.path}
傳遞至 javac/KAPT。如果您使用的是 KSP,請將其變更為 room.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 KSP, you should change the line below to return
// ["room.schemaLocation=${schemaDir.path}"]
return ["-Aroom.schemaLocation=${schemaDir.path}"]
}
}
Kotlin
class RoomSchemaArgProvider(
@get:InputDirectory
@get:PathSensitive(PathSensitivity.RELATIVE)
val schemaDir: File
) : CommandLineArgumentProvider {
override fun asArguments(): Iterable<String> {
// Note: If you're using KSP, you should change the line below to return
// listOf("room.schemaLocation=${schemaDir.path}")
return listOf("-Aroom.schemaLocation=${schemaDir.path}")
}
}
接著設定編譯選項,以便使用 RoomSchemaArgProvider
搭配指定結構定義目錄 (先確定此目錄已存在):
Groovy
// For javac or KAPT, configure via android DSL
android {
...
defaultConfig {
javaCompileOptions {
annotationProcessorOptions {
compilerArgumentProviders(
new RoomSchemaArgProvider(new File(projectDir, "schemas"))
)
}
}
}
}
// For KSP, configure via KSP extension:
ksp {
arg(new RoomSchemaArgProvider(new File(projectDir, "schemas")))
}
Kotlin
// For javac or KAPT, configure via android DSL
android {
...
defaultConfig {
javaCompileOptions {
annotationProcessorOptions {
compilerArgumentProviders(
RoomSchemaArgProvider(File(projectDir, "schemas"))
)
}
}
}
}
// For KSP, configure via KSP extension:
ksp {
arg(RoomSchemaArgProvider(File(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.5.0" }
Kotlin
android { ... sourceSets { // Adds exported schema location as test app assets. getByName("androidTest").assets.srcDir("$projectDir/schemas") } } dependencies { ... testImplementation("androidx.room:room-testing:2.5.0") }
測試套件提供 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()
作為替代解決方案:
- 在結構定義記錄中,如果有特定版本引發無法以遷移路徑解決的錯誤,請改用
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, 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 或以上版本時,按照下列指示操作:
- 使用
@ColumnInfo
註解,分別在各個實體類別中,宣告資料欄預設值。 - 將資料庫版本號碼加一。
- 定義遷移到新版本的路徑,實作先刪除後重建策略,從而將必要的預設值新增至現有的資料欄。
以下範例會說明這項程序:
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"); } };