迁移 Room 数据库

随着您在应用中添加和更改功能,您需要修改 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() 方法,明确定义了 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
  schemaDirectory "demoDebug", "$projectDir/schemas/demoDebug"

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

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

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

Kotlin

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

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

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

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

使用注解处理器选项设置模式位置

如果您使用的是 Room 2.5.2 或更低版本,或者您没有使用 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() 的方法

升级到 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 或更高版本,并更改 Song 实体类以使用 @ColumnInfo 注解包含 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");
    }
};