迁移 Room 数据库

当您在应用中添加和更改功能时,需要修改 Room 实体类以反映这些更改。但是,如果应用更新更改了数据库架构,那么保留设备内置数据库中已有的用户数据就非常重要。

Room 持久性库支持通过 Migration 类进行增量迁移以满足此需求。每个 Migration 子类通过替换 Migration.migrate() 方法定义 startVersionendVersion 之间的迁移路径。当应用更新需要升级数据库版本时,Room 会从一个或多个 Migration 子类运行 migrate() 方法,以在运行时将数据库迁移到最新版本:

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 发现问题,就会抛出包含不匹配信息的异常。

如需了解详情,请参阅 GitHub 上的 Room 迁移示例

测试迁移

迁移通常十分复杂,迁移定义错误可能会导致应用崩溃。为了保持应用的稳定性,您应测试迁移。Room 提供了一个 room-testing Maven 工件来协助完成此测试过程。不过,为使此工件正常工作,您必须先导出数据库的架构。

导出架构

Room 可以在编译时将数据库的架构信息导出为 JSON 文件。如需导出架构,请在 app/build.gradle 文件中设置 room.schemaLocation 注释处理器属性:

build.gradle

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

导出的 JSON 文件代表数据库的架构历史记录。您应将这些文件存储在版本控制系统中,因为此系统允许 Room 出于测试目的创建较旧版本的数据库。

测试单次迁移

测试迁移之前,您必须先将 Room 中的 androidx.room:room-testing Maven 工件添加至测试依赖项中,并将导出的架构的位置添加为资源目录:

build.gradle

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

dependencies {
    ...
      testImplementation "androidx.room:room-testing:2.2.5"
}

测试软件包提供了可读取导出的架构文件的 MigrationTestHelper 类。该软件包还实现了 JUnit4 TestRule 接口,因此可以管理创建的数据库。

以下示例演示了针对单次迁移的测试:

Kotlin

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

    @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)

    @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().getTargetContext(),
                AppDatabase.class,
                TEST_DB
        ).addMigrations(*ALL_MIGRATIONS).build().apply {
            getOpenHelper().getWritableDatabase()
            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 在特定情况下回退到破坏性重新创建,可以使用 fallbackToDestructiveMigration() 的一些替代选项:

  • 如果特定版本的架构历史记录导致迁移路径出现无法解决的问题,请改用 fallbackToDestructiveMigrationFrom()。此方法表示您仅在从特定版本迁移时才希望 Room 回退到破坏性重新创建。
  • 如果您仅在从较高数据库版本迁移到较低数据库版本时才希望 Room 回退到破坏性重新创建,请改用 fallbackToDestructiveMigrationOnDowngrade()

升级到 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, 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 ''");
    }
};

这会导致更新的应用与全新安装的应用之间存在底层表差异。由于 tag 列的默认值仅在从版本 1 到版本 2 的迁移路径中进行了声明,因此从版本 2 开始安装该应用的用户的数据库架构中没有 tag 的默认值。

在版本低于 2.2.0 的 Room 中,此差异不会产生任何不良后果。但是,如果应用稍后升级以使用 Room 2.2.0 或更高版本,并使用 @ColumnInfo 注释更改 Song 实体类以包含 tag 的默认值,那么 Room 现在就会发现此差异。这会导致架构验证失败。

如需确保在早期迁移路径中声明列默认值的情况下在所有用户之间保持数据库架构的一致性,请在首次升级应用以使用 Room 2.2.0 或更高版本时执行以下操作:

  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");
    }
};