Как перенести базу данных Room

По мере того как вы добавляете и изменяете функции в приложении, вам нужно модифицировать классы объектов Room и базовые таблицы базы данных, чтобы отразить эти изменения. Важно сохранить пользовательские данные, которые уже есть в базе данных на устройстве, когда обновление приложения меняет схему базы данных.

Room поддерживает как автоматический, так и ручной варианты постепенного переноса. Автоматический перенос подходит для большинства базовых изменений схемы, но для более сложных изменений вам может потребоваться вручную определить пути переноса.

Автоматический перенос

Чтобы объявить об автоматическом переносе между двумя версиями базы данных, добавьте аннотацию @AutoMigration к свойству autoMigrations в @Database:

// Database class before the version update.
@Database(
  version = 1,
  entities = [User::class]
)
abstract class AppDatabaseV1 : RoomDatabase() {
  abstract fun userDao(): UserDao
}

// Database class after the version update.
@Database(
  version = 2,
  entities = [User::class],
  autoMigrations = [
    AutoMigration(from = 1, to = 2)
  ]
)
abstract class AppDatabaseV2 : RoomDatabase() {
  abstract fun userDao(): UserDao
}

Спецификации автоматического переноса

Если Room обнаруживает неоднозначные изменения схемы и не может сгенерировать план переноса без дополнительных данных, он выдает ошибку во время компиляции, и вам необходимо предоставить реализацию AutoMigrationSpec. Чаще всего это происходит, когда при переносе используются следующие функции:

  • Удаление или переименование таблицы.
  • Удаление или переименование столбца.

Вы можете использовать AutoMigrationSpec, чтобы предоставить Room дополнительную информацию, необходимую для правильного создания путей переноса. Определите класс, который реализует интерфейс AutoMigrationSpec в классе RoomDatabase, и добавьте к нему одну или несколько из следующих аннотаций:

Чтобы использовать реализацию AutoMigrationSpec для автоматического переноса, задайте свойство spec в соответствующей аннотации @AutoMigration:

@Database(
  version = 2,
  entities = [User::class],
  autoMigrations = [
    AutoMigration (
      from = 1,
      to = 2,
      spec = MigrationSpec1To2::class
    )
  ]
)
abstract class AppDatabaseWithSpec : RoomDatabase() {
  abstract fun userDao(): UserDao
}

@RenameTable(fromTableName = "User", toTableName = "AppUser")
internal class MigrationSpec1To2 : AutoMigrationSpec

Если после автоматического переноса приложению нужно выполнить дополнительные действия, вы можете реализовать onPostMigrate. Если вы реализуете эту функцию в своем приложении AutoMigrationSpec, Room вызовет ее после завершения автоматической миграции.

Перенос вручную

Если при переносе данных в схему вносятся сложные изменения, Room может не сгенерировать подходящий путь переноса автоматически. Например, если вы решите разделить данные в таблице на две таблицы, Room не сможет определить, как это сделать. В таких случаях вам нужно вручную определить путь переноса, реализовав класс Migration.

Класс Migration явно определяет путь переноса между startVersion и endVersion, переопределяя функцию migrate. Добавьте классы Migration в конструктор базы данных, используя функцию addMigrations:

val MIGRATION_1_2 = object : Migration(1, 2) {
  override suspend fun migrate(connection: SQLiteConnection) {
    connection.executeSQL("CREATE TABLE `Fruit` (`id` INTEGER, `name` TEXT, " +
      "PRIMARY KEY(`id`))")
  }
}

val MIGRATION_2_3 = object : Migration(2, 3) {
  override suspend fun migrate(connection: SQLiteConnection) {
    connection.executeSQL("ALTER TABLE Book ADD COLUMN pub_year INTEGER")
  }
}

Room.databaseBuilder<ManualMigrationDatabase>(applicationContext, "database-name")
  .addMigrations(MIGRATION_1_2, MIGRATION_2_3)
  .build()

При определении путей переноса можно использовать автоматический перенос для одних версий и ручной – для других. Если для одной и той же версии вы определите и автоматическую, и ручную миграцию, Room будет использовать ручную.

Тестовые переносы

Миграции часто бывают сложными, и неправильно определенная миграция может привести к сбою в работе приложения. Чтобы обеспечить стабильность приложения, протестируйте перенос. Room предоставляет артефакт Maven room3-testing, который помогает тестировать как автоматические, так и ручные переносы. Чтобы этот артефакт работал, сначала необходимо экспортировать схему базы данных.

Схемы экспорта

Room экспортирует информацию о схеме базы данных в JSON-файл во время компиляции. Экспортированные JSON-файлы представляют историю схемы вашей базы данных. Храните эти файлы в системе управления версиями, чтобы вы могли воссоздавать более ранние версии базы данных для тестирования и автоматического создания миграций.

Как задать местоположение схемы с помощью плагина Gradle для Room

Чтобы указать каталог схемы, примените плагин Room Gradle и используйте расширение room3.

Яркий

plugins {
  id 'androidx.room3'
}

room3 {
  schemaDirectory "$projectDir/schemas"
}

Kotlin

plugins {
  id("androidx.room3")
}

room3 {
  schemaDirectory("$projectDir/schemas")
}

Если схема вашей базы данных различается в зависимости от варианта, версии или типа сборки, вам нужно указать разные местоположения, используя конфигурацию schemaDirectory несколько раз, каждый раз с аргументом variantMatchName в качестве первого. Каждая конфигурация может соответствовать одному или нескольким вариантам на основе простого сравнения с названием варианта.

Убедитесь, что они охватывают все варианты. Вы также можете добавить schemaDirectory() без variantMatchName, чтобы обрабатывать варианты, не соответствующие другим конфигурациям. Например, если в приложении есть два варианта сборки demo и full и два типа сборки debug и release, то допустимы следующие конфигурации:

Яркий

room3 {
  // 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

room3 {
  // 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 Gradle, задайте местоположение схемы с помощью параметра процессора аннотаций room.schemaLocation.

Gradle использует файлы в этом каталоге в качестве входных и выходных данных для некоторых задач Gradle. Чтобы обеспечить корректность и производительность инкрементных и кешированных сборок, необходимо использовать CommandLineArgumentProvider Gradle, чтобы сообщить Gradle об этом каталоге.

Сначала скопируйте приведенный ниже класс RoomSchemaArgProvider в файл сборки Gradle модуля. Функция asArguments в примере класса передает room.schemaLocation=${schemaDir.path} в KSP. Если вы используете KAPT и javac, измените это значение на -Aroom.schemaLocation=${schemaDir.path}.

Яркий

class RoomSchemaArgProvider implements CommandLineArgumentProvider {

  @InputDirectory
  @PathSensitive(PathSensitivity.RELATIVE)
  File schemaDir

  RoomSchemaArgProvider(File schemaDir) {
    this.schemaDir = schemaDir
  }

  @Override
  Iterable<String> asArguments() {
    return ["room.schemaLocation=${schemaDir.path}".toString()]
  }
}

Kotlin

class RoomSchemaArgProvider(
  @get:InputDirectory
  @get:PathSensitive(PathSensitivity.RELATIVE)
  val schemaDir: File
) : CommandLineArgumentProvider {

  override fun asArguments(): Iterable<String> {
    return listOf("room.schemaLocation=${schemaDir.path}")
  }
}

Затем настройте параметры компиляции, чтобы использовать RoomSchemaArgProvider с указанным каталогом схемы:

Яркий

ksp {
  arg(new RoomSchemaArgProvider(new File(projectDir, "schemas")))
}

Kotlin

ksp {
  arg(RoomSchemaArgProvider(File(projectDir, "schemas")))
}

Как протестировать перенос

Прежде чем тестировать перенос, добавьте androidx.room3:room3-testing артефакт в зависимости для тестирования и укажите местоположение экспортированной схемы в качестве каталога объектов:

Яркий

android {
    ...
    sourceSets {
        // Adds exported schema location as test app assets if not using
        // the Room Gradle Plugin.
        androidTest.assets.srcDirs += files("$projectDir/schemas".toString())
    }
}

dependencies {
    ...
    androidTestImplementation "androidx.room3:room3-testing:3.0.3"
}

Kotlin

android {
    ...
    sourceSets {
        // Adds exported schema location as test app assets if not using
        // the Room Gradle Plugin.
        getByName("androidTest").assets.srcDir("$projectDir/schemas")
    }
}

dependencies {
    ...
    testImplementation("androidx.room3:room3-testing:3.0.3")
}

В пакет тестирования входит класс MigrationTestHelper, который может считывать экспортированные файлы схемы. Пакет также реализует интерфейс JUnit4 TestRule для управления созданными базами данных.

В примере ниже показано, как провести тестирование для одного перехода:

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

    private val instrumentation = InstrumentationRegistry.getInstrumentation()

    @get:Rule
    val helper = MigrationTestHelper(
        instrumentation = instrumentation,
        databaseClass = MigrationDb::class,
        driver = AndroidSQLiteDriver(),
        file = instrumentation.targetContext.getDatabasePath(TEST_DB),
    )

    @Test
    fun migrate1To2() = runTest {
        val connection = helper.createDatabase(1)
        // Database has schema version 1. Insert some data using SQL queries.
        // You can't use DAO classes because they expect the latest schema.
        connection.execSQL("INSERT INTO User (id, name) VALUES (1, 'John Doe')")
        connection.close()

        // Re-open the database with version 2 and provide MIGRATION_1_2
        val migratedConnection = helper.runMigrationsAndValidate(2, listOf(MIGRATION_1_2))

        // MigrationTestHelper automatically verifies the schema changes,
        // but you need to validate that the data was migrated properly.
        val hasData = migratedConnection.prepare("SELECT COUNT(*) FROM User").use {
          it.step()
          it.getLong(0) > 0
        }
        assertTrue("Expected data was not migrated", hasData)
        migratedConnection.close()
    }
}

Как проверить перенос

Хотя вы можете протестировать одну инкрементную миграцию, мы рекомендуем включить в тест все миграции, определенные для базы данных вашего приложения. Это помогает избежать расхождений между недавно созданным экземпляром базы данных и более ранним экземпляром, который следовал определенным путям миграции.

В примере ниже показано, как проверить все определенные переносы:

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

    private val instrumentation = InstrumentationRegistry.getInstrumentation()

    // Array of all migrations.
    private val ALL_MIGRATIONS = arrayOf(MIGRATION_1_2, MIGRATION_2_3, MIGRATION_3_4)

    @get:Rule
    val helper: MigrationTestHelper = MigrationTestHelper(
        instrumentation = instrumentation,
        databaseClass = MigrationDb::class,
        driver = AndroidSQLiteDriver(),
        file = instrumentation.targetContext.getDatabasePath(TEST_DB),
    )

    @Test
    fun migrateAll() = runTest {
        // Create earliest version of the database.
        val connection = helper.createDatabase(1)
        connection.close()

        // Create latest version of the database.
        val db = Room.databaseBuilder<AppDatabase>(instrumentation.targetContext, TEST_DB)
          .setDriver(AndroidSQLiteDriver())
          .addMigrations(*ALL_MIGRATIONS)
          .build()
        // Open the database, Room validates the schema once all migrations
        // execute.
        db.useReaderConnection { connection ->
          // Perform additional validation
        }

        db.close()
    }
}

Корректная обработка отсутствующих путей переноса

Если Room не может найти путь переноса для обновления существующей базы данных на устройстве до текущей версии, возникает ошибка IllegalStateException. Если при отсутствии пути переноса допустима потеря существующих данных, вызовите функцию создания fallbackToDestructiveMigration при создании базы данных:

Room.databaseBuilder<FallbackMigrationDatabase>(applicationContext, "database-name")
        .fallbackToDestructiveMigration()
        .build()

Эта функция настраивает Room на деструктивное воссоздание таблиц в базе данных приложения, когда необходимо выполнить инкрементный перенос и не задан путь переноса.

Чтобы использовать деструктивное воссоздание только в определенных ситуациях, выберите один из следующих вариантов вместо fallbackToDestructiveMigration:

  • Если определенные версии истории схемы вызывают ошибки, которые нельзя устранить с помощью путей переноса, используйте fallbackToDestructiveMigrationFrom. Эта функция указывает, что Room должен использовать деструктивное воссоздание только при переходе с определенных версий.
  • Если вы хотите, чтобы Room прибегал к деструктивному воссозданию только при переходе с более новой версии базы данных на более старую, используйте fallbackToDestructiveMigrationOnDowngrade.