Перенесите базу данных комнат

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

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

Автоматизированные миграции

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

Котлин

// 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() {
  ...
}

Джава

// 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 дополнительную информацию, необходимую для правильного создания путей миграции. Определите статический класс, реализующий AutoMigrationSpec в классе RoomDatabase и аннотируйте его одним или несколькими из следующих элементов:

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

Котлин

@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
  ...
}

Джава

@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 явно определяет путь миграции между startVersion и endVersion переопределяя метод Migration.migrate() . Добавьте свои классы Migration в построитель базы данных с помощью метода addMigrations() :

Котлин

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

Джава

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 предоставляет артефакт Maven room-testing который помогает в процессе тестирования как для автоматической, так и для ручной миграции. Чтобы этот артефакт работал, сначала необходимо экспортировать схему базы данных.

Экспортировать схемы

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

Установите местоположение схемы с помощью плагина Room Gradle

Если вы используете версию Room 2.6.0 или выше, вы можете применить плагин Room Gradle и использовать расширение room , чтобы указать каталог схемы.

классный

plugins {
  id 'androidx.room'
}

room {
  schemaDirectory "$projectDir/schemas"
}

Котлин

plugins {
  id("androidx.room")
}

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

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

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

классный

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 {
  // 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. Для корректности и производительности инкрементных и кэшированных сборок вы должны использовать 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() {
    // 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()]
  }
}

Котлин

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 с указанным каталогом схемы:

классный

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

Котлин

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

Протестируйте одиночную миграцию

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

build.gradle

классный

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

Котлин

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 , поэтому он может управлять созданными базами данных.

В следующем примере демонстрируется тест для одиночной миграции:

Котлин

@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.
    }
}

Джава

@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.
    }
}

Протестируйте все миграции

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

В следующем примере демонстрируется проверка всех определенных миграций:

Котлин

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

Джава

@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() при создании базы данных:

Котлин

Room.databaseBuilder(applicationContext, MyDb::class.java, "database-name")
        .fallbackToDestructiveMigration()
        .build()

Джава

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 не знает. Это означает, что если база данных изначально создана в версии Room ниже 2.2.0, обновление вашего приложения для использования Room 2.2.0 может потребовать от вас предоставления специального пути миграции для существующих значений по умолчанию, которые вы определили без использования API-интерфейсов Room.

Например, предположим, что версия 1 базы данных определяет сущность Song :

Котлин

// Song entity, database version 1, Room 2.1.0.
@Entity
data class Song(
    @PrimaryKey
    val id: Long,
    val title: String
)

Джава

// 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:

Котлин

// 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 ''")
    }
}

Джава

// 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 в своей схеме базы данных.

В версиях Room ниже 2.2.0 это несоответствие безобидно. Однако если позже приложение обновится до версии Room 2.2.0 или более поздней версии и изменит класс сущности Song , включив в него значение по умолчанию для tag с помощью аннотации @ColumnInfo , Room сможет увидеть это несоответствие. Это приводит к неудачным проверкам схемы.

Чтобы обеспечить единообразие схемы базы данных для всех пользователей, когда в предыдущих путях миграции были объявлены значения по умолчанию для столбцов, выполните следующие действия при первом обновлении приложения для использования Room 2.2.0 или более поздней версии:

  1. Объявите значения по умолчанию для столбцов в соответствующих классах сущностей, используя аннотацию @ColumnInfo .
  2. Увеличьте номер версии базы данных на 1.
  3. Определите путь миграции к новой версии, которая реализует стратегию удаления и воссоздания , чтобы добавить необходимые значения по умолчанию в существующие столбцы.

Следующий пример демонстрирует этот процесс:

Котлин

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

Джава

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