По мере добавления и изменения функций в приложении вам необходимо модифицировать классы сущностей 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 об этом каталоге.
Сначала скопируйте класс 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.7.2" }
Котлин
android { ... sourceSets { // Adds exported schema location as test app assets. getByName("androidTest").assets.srcDir("$projectDir/schemas") } } dependencies { ... testImplementation("androidx.room:room-testing:2.7.2") }
Пакет тестирования предоставляет класс 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 или выше:
- Объявите значения столбцов по умолчанию в соответствующих классах сущностей, используя аннотацию
@ColumnInfo
. - Увеличьте номер версии базы данных на 1.
- Определите путь миграции на новую версию, которая реализует стратегию удаления и повторного создания для добавления необходимых значений по умолчанию в существующие столбцы.
Следующий пример демонстрирует этот процесс:
Котлин
// 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"); } };