Android NDK开发详解应用数据和文件之迁移 Room 数据库
当您在应用中添加和更改功能时,需要修改 Room 实体类和底层数据库表以反映这些更改。如果应用更新更改了数据库架构,那么保留设备内置数据库中已有的用户数据就非常重要。
Room 同时支持以自动和手动方式进行的增量迁移。自动迁移适用于大多数基本架构更改,不过您可能需要针对更复杂的更改手动定义迁移路径。
自动迁移
注意:Room 在 2.4.0-alpha01 及更高版本中支持自动迁移。如果您的应用使用的是较低版本的 Room,则必须手动定义迁移。
如需声明两个数据库版本之间的自动迁移,请将 @AutoMigration 注解添加到 @Database 中的 autoMigrations 属性:
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 自动迁移依赖于为旧版和新版数据库生成的数据库架构。如果 exportSchema 被设为 false,或者如果您尚未使用新版本号编译数据库,自动迁移将会失败。
自动迁移规范
如果 Room 检测到架构更改不明确,并且无法在没有更多输入的情况下生成迁移计划,则会抛出编译时间错误并要求您实现 AutoMigrationSpec。这往往出现在迁移涉及以下情形之一时:
删除或重命名表。
删除或重命名列。
您可以使用 AutoMigrationSpec 为 Room 提供正确生成迁移路径所需的额外信息。定义一个在 RoomDatabase 类中实现 AutoMigrationSpec 的静态类,并为其添加以下一项或多项注解:
@DeleteTable
@RenameTable
@DeleteColumn
@RenameColumn
如需使用 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 将在自动迁移完成后调用该方法。
注意:使用 Kotlin 时,如果您有多项属于同一类型的迁移任务,则必须针对多个注解使用一个容器,例如 @RenameTable.Entries。
手动迁移
如果迁移涉及复杂的架构更改,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=
s
c
h
e
m
a
D
i
r
.
p
a
t
h
传递给
j
a
v
a
c
或
K
A
P
T
。如果您使用的是
K
S
P
,请将其更改为
r
o
o
m
.
s
c
h
e
m
a
L
o
c
a
t
i
o
n
=
{schemaDir.path} 传递给 javac 或 KAPT。如果您使用的是 KSP,请将其更改为 room.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, change the line below to return
// ["room.schemaLocation=${schemaDir.path}".toString()].
return ["-Aroom.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 KSP, change the line below to return
// listOf("room.schemaLocation=${schemaDir.path}").
return listOf("-Aroom.schemaLocation=${schemaDir.path}")
}
}
接下来,配置编译选项来使用 RoomSchemaArgProvider,并传入指定的架构目录(需先确保该目录已存在):
Groovy
// For javac or KAPT, configure using android DSL:
android {
…
defaultConfig {
javaCompileOptions {
annotationProcessorOptions {
compilerArgumentProviders(
new RoomSchemaArgProvider(new File(projectDir, “schemas”))
)
}
}
}
}
// For KSP, configure using KSP extension:
ksp {
arg(new RoomSchemaArgProvider(new File(projectDir, “schemas”)))
}
Kotlin
// For javac or KAPT, configure using android DSL:
android {
...
defaultConfig {
javaCompileOptions {
annotationProcessorOptions {
compilerArgumentProviders(
RoomSchemaArgProvider(File(projectDir, "schemas"))
)
}
}
}
}
// For KSP, configure using 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.6.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.6.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 {
// 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 在尝试执行没有定义迁移路径的迁移时会从用户数据库表中永久删除所有数据。
如果您只想让 Room 在特定情况下采用破坏性重新创建,可以使用 fallbackToDestructiveMigration() 的一些替代选项:
如果特定版本的架构历史记录导致迁移路径出现无法解决的问题,请改用 fallbackToDestructiveMigrationFrom()。此方法表示您仅在从特定版本迁移时才希望 Room 采用破坏性重新创建。
如果您仅在从较高数据库版本迁移到较低数据库版本时才希望 Room 采用破坏性重新创建,请改用 fallbackToDestructiveMigrationOnDowngrade()。
注意:在 2.2.0 及更高版本中,Room 可以在某些回退迁移情况下使用预打包的数据库文件,而无需留空数据库。如需了解详情,请参阅预填充 Room 数据库。
升级到 Room 2.2.0 时处理列默认值
在 Room 2.2.0 及更高版本中,您可以使用注解 @ColumnInfo(defaultValue = “…”) 定义列的默认值。在低于 2.2.0 的版本中,为列定义默认值的唯一方法是直接在执行的 SQL 语句中定义默认值,这样创建的默认值是 Room 不知道的值。这意味着,如果数据库最初由版本低于 2.2.0 的 Room 创建,升级应用以使用 Room 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 的默认值。
在版本低于 2.2.0 的 Room 中,此差异不会产生任何不良后果。但是,如果应用稍后升级以使用 Room 2.2.0 或更高版本,并使用 @ColumnInfo 注解更改 Song 实体类以包含 tag 的默认值,Room 随后便可发现此差异。这会导致架构验证失败。
如需确保在早期迁移路径中声明列默认值的情况下在所有用户之间保持数据库架构的一致性,请在首次升级应用以使用 Room 2.2.0 或更高版本时执行以下操作:
使用 @ColumnInfo 注解在各自的实体类中声明列默认值。
将数据库版本号增加 1。
定义实现了删除并重新创建策略的新版本的迁移路径,将必要的默认值添加到现有列。
注意:如果应用的数据库采用破坏性迁移,或者如果没有迁移路径添加带有默认值的列,就不需要此过程。
以下示例演示了此过程:
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");
}
};