W miarę dodawania i zmieniania funkcji w aplikacji musisz modyfikować klasy encji Room i powiązane tabele bazy danych, aby odzwierciedlały te zmiany. Ważne jest, aby zachować dane użytkownika, które są już w bazie danych na urządzeniu, gdy aktualizacja aplikacji zmienia schemat bazy danych.
Room obsługuje zarówno automatyczne, jak i ręczne opcje migracji przyrostowej. Automatyczne migracje działają w przypadku większości podstawowych zmian schematu, ale w przypadku bardziej złożonych zmian może być konieczne ręczne zdefiniowanie ścieżek migracji.
Automatyczne migracje
Aby zadeklarować automatyczną migrację między 2 wersjami bazy danych, dodaj
@AutoMigration adnotację do
właściwości autoMigrations
w @Database:
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 { ... }
Specyfikacje automatycznej migracji
Jeśli Room wykryje niejednoznaczne zmiany schematu i nie będzie w stanie wygenerować
planu migracji bez dodatkowych danych, zgłosi błąd czasu kompilacji i poprosi
o zaimplementowanie
AutoMigrationSpec.
Najczęściej zdarza się to, gdy migracja obejmuje jedną z tych czynności:
- usuwanie lub zmienianie nazwy tabeli;
- usuwanie lub zmienianie nazwy kolumny.
Za pomocą AutoMigrationSpec możesz przekazać do Room dodatkowe informacje potrzebne do prawidłowego wygenerowania ścieżek migracji. Zdefiniuj klasę statyczną, która implementuje AutoMigrationSpec w klasie RoomDatabase, i dodaj do niej jedną lub więcej z tych adnotacji:
Aby użyć implementacji AutoMigrationSpec w automatycznej migracji, ustaw właściwość spec w odpowiedniej adnotacji @AutoMigration:
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 { } ... }
Jeśli po zakończeniu automatycznej migracji aplikacja musi wykonać więcej czynności, możesz zaimplementować
onPostMigrate().
Jeśli zaimplementujesz tę metodę w AutoMigrationSpec, Room wywoła ją po zakończeniu automatycznej migracji.
Migracje ręczne
W przypadkach, gdy migracja obejmuje złożone zmiany schematu, Room może nie być w stanie automatycznie wygenerować odpowiedniej ścieżki migracji. Jeśli np. zdecydujesz się podzielić dane w tabeli na 2 tabele, Room nie będzie wiedzieć, jak to zrobić. W takich przypadkach musisz ręcznie zdefiniować ścieżkę migracji, implementując klasę Migration.
Klasa Migration jawnie definiuje ścieżkę migracji między startVersion i endVersion przez zastąpienie metody Migration.migrate(). Dodaj swoje klasy Migration do narzędzia do tworzenia bazy danych za pomocą
metody
addMigrations():
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();
Podczas definiowania ścieżek migracji możesz używać automatycznych migracji w przypadku niektórych wersji i ręcznych migracji w przypadku innych. Jeśli zdefiniujesz zarówno automatyczną, jak i ręczną migrację dla tej samej wersji, Room użyje migracji ręcznej.
Testowanie migracji
Migracje są często złożone, a nieprawidłowo zdefiniowana migracja może spowodować awarię aplikacji. Aby zachować stabilność aplikacji, przetestuj migracje. Room udostępnia artefakt Maven room-testing, który pomaga w testowaniu zarówno automatycznych, jak i ręcznych migracji. Aby ten artefakt działał, musisz najpierw wyeksportować schemat bazy danych.
Eksportowanie schematów
Room może wyeksportować informacje o schemacie bazy danych do pliku JSON w czasie kompilacji. Wyeksportowane pliki JSON reprezentują historię schematu bazy danych. Przechowuj te pliki w systemie kontroli wersji, aby Room mógł tworzyć starsze wersje bazy danych na potrzeby testowania i generować automatyczne migracje.
Ustawianie lokalizacji schematu za pomocą wtyczki Room Gradle
Jeśli używasz Room w wersji 2.6.0 lub nowszej, możesz zastosować
wtyczkę Room Gradle i użyć rozszerzenia
room, aby określić katalog schematu.
Dynamiczny
plugins {
id 'androidx.room'
}
room {
schemaDirectory "$projectDir/schemas"
}
Kotlin
plugins {
id("androidx.room")
}
room {
schemaDirectory("$projectDir/schemas")
}
Jeśli schemat bazy danych różni się w zależności od wariantu, wersji lub typu kompilacji, musisz określić różne lokalizacje, używając konfiguracji schemaDirectory() kilka razy, a jako pierwszy argument podając variantMatchName. Każda konfiguracja może pasować do co najmniej 1 wariantu na podstawie prostego porównania z nazwą wariantu.
Upewnij się, że są one wyczerpujące i obejmują wszystkie warianty. Możesz też dodać schemaDirectory() bez variantMatchName, aby obsługiwać warianty, które nie pasują do żadnej z pozostałych konfiguracji. Na przykład w aplikacji z 2 wersjami kompilacji (demo i full) oraz 2 typami kompilacji (debug i release) prawidłowe konfiguracje to:
Dynamiczny
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")
}
Ustawianie lokalizacji schematu za pomocą opcji procesora adnotacji
Jeśli używasz Room w wersji 2.5.2 lub starszej albo nie używasz wtyczki Room Gradle, ustaw lokalizację schematu za pomocą opcji procesora adnotacji room.schemaLocation.
Pliki w tym katalogu są używane jako dane wejściowe i wyjściowe w przypadku niektórych zadań Gradle.
Aby zapewnić poprawność i wydajność kompilacji przyrostowych i kompilacji z pamięci podręcznej, musisz użyć
Gradle's
CommandLineArgumentProvider
aby poinformować Gradle o tym katalogu.
Najpierw skopiuj klasę RoomSchemaArgProvider pokazaną poniżej do pliku kompilacji Gradle modułu. Metoda asArguments() w przykładowej klasie przekazuje room.schemaLocation=${schemaDir.path} do KSP. Jeśli używasz KAPT i javac, zmień tę wartość na -Aroom.schemaLocation=${schemaDir.path}.
Dynamiczny
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}")
}
}
Następnie skonfiguruj opcje kompilacji, aby używać RoomSchemaArgProvider z określonym katalogiem schematu:
Dynamiczny
// 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"))
)
}
}
}
}
Testowanie pojedynczej migracji
Zanim zaczniesz testować migracje, dodaj artefakt Maven androidx.room:room-testing z Room do zależności testowych i dodaj lokalizację wyeksportowanego schematu jako folder zasobów:
build.gradle
Dynamiczny
android { ... sourceSets { // Adds exported schema location as test app assets. androidTest.assets.srcDirs += files("$projectDir/schemas".toString()) } } dependencies { ... androidTestImplementation "androidx.room:room-testing:2.8.4" }
Kotlin
android { ... sourceSets { // Adds exported schema location as test app assets. getByName("androidTest").assets.srcDir("$projectDir/schemas") } } dependencies { ... testImplementation("androidx.room:room-testing:2.8.4") }
Pakiet testowy zawiera klasę
MigrationTestHelper, która może odczytywać wyeksportowane pliki schematu. Pakiet implementuje też
JUnit4
TestRule
interfejs, dzięki czemu może zarządzać utworzonymi bazami danych.
W tym przykładzie pokazujemy test pojedynczej migracji:
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. } }
Testowanie wszystkich migracji
Chociaż można przetestować pojedynczą migrację przyrostową, zalecamy uwzględnienie testu, który obejmuje wszystkie migracje zdefiniowane dla bazy danych aplikacji. Pomaga to zapewnić, że nie ma rozbieżności między nowo utworzoną instancją bazy danych a starszą instancją, która przeszła zdefiniowane ścieżki migracji.
W tym przykładzie pokazujemy test wszystkich zdefiniowanych migracji:
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}; }
Eleganckie obsługiwanie brakujących ścieżek migracji
Jeśli Room nie może znaleźć ścieżki migracji, aby uaktualnić istniejącą bazę danych na
urządzeniu do bieżącej wersji, wystąpi
IllegalStateException. Jeśli w przypadku braku ścieżki migracji dopuszczalne jest utracenie istniejących danych, podczas tworzenia bazy danych wywołaj metodę fallbackToDestructiveMigration() narzędzia do tworzenia:
Kotlin
Room.databaseBuilder(applicationContext, MyDb::class.java, "database-name") .fallbackToDestructiveMigration() .build()
Java
Room.databaseBuilder(getApplicationContext(), MyDb.class, "database-name") .fallbackToDestructiveMigration() .build();
Ta metoda informuje Room, aby w przypadku migracji przyrostowej i braku zdefiniowanej ścieżki migracji destrukcyjnie odtworzył tabele w bazie danych aplikacji.
Jeśli chcesz, aby Room wracał do destrukcyjnego odtwarzania tylko w określonych sytuacjach, możesz użyć kilku alternatyw dla fallbackToDestructiveMigration():
- Jeśli określone wersje historii schematu powodują błędy, których nie można rozwiązać
za pomocą ścieżek migracji, użyj
fallbackToDestructiveMigrationFrom()zamiast tego. Ta metoda wskazuje, że chcesz, aby Room wracał do destrukcyjnego odtwarzania tylko podczas migracji z określonych wersji. - Jeśli chcesz, aby Room wracał do destrukcyjnego odtwarzania tylko podczas migracji
z nowszej wersji bazy danych do starszej, użyj
fallbackToDestructiveMigrationOnDowngrade()zamiast tego.
Obsługa wartości domyślnych kolumn podczas uaktualniania do Room 2.2.0
W Room 2.2.0 i nowszych możesz zdefiniować wartość domyślną kolumny za pomocą
adnotacji
@ColumnInfo(defaultValue = "...").
W wersjach starszych niż 2.2.0 jedynym sposobem zdefiniowania wartości domyślnej kolumny jest zdefiniowanie jej bezpośrednio w wykonanej instrukcji SQL, która tworzy wartość domyślną, o której Room nie wie. Oznacza to, że jeśli baza danych została pierwotnie utworzona przez wersję Room starszą niż 2.2.0, uaktualnienie aplikacji do Room 2.2.0 może wymagać podania specjalnej ścieżki migracji dla istniejących wartości domyślnych zdefiniowanych bez użycia interfejsów API Room.
Załóżmy na przykład, że wersja 1 bazy danych definiuje encję 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; }
Załóżmy też, że wersja 2 tej samej bazy danych dodaje nową kolumnę NOT NULL i definiuje ścieżkę migracji z wersji 1 do wersji 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 ''"); } };
Powoduje to rozbieżność w tabeli źródłowej między aktualizacjami a nowymi instalacjami aplikacji. Ponieważ wartość domyślna kolumny tag jest deklarowana tylko w ścieżce migracji z wersji 1 do wersji 2, wszyscy użytkownicy, którzy zainstalują aplikację od wersji 2, nie będą mieć wartości domyślnej dla tag w schemacie bazy danych.
W wersjach Room starszych niż 2.2.0 ta rozbieżność nie jest szkodliwa. Jeśli jednak aplikacja zostanie później uaktualniona do Room 2.2.0 lub nowszej i zmieni klasę encji Song, aby uwzględnić wartość domyślną dla tag za pomocą adnotacji @ColumnInfo, Room będzie mógł zobaczyć tę rozbieżność. Spowoduje to niepowodzenie weryfikacji schematu.
Aby zapewnić spójność schematu bazy danych u wszystkich użytkowników, gdy wartości domyślne kolumn są deklarowane w starszych ścieżkach migracji, wykonaj te czynności przy pierwszym uaktualnieniu aplikacji do Room 2.2.0 lub nowszej:
- Zadeklaruj wartości domyślne kolumn w odpowiednich klasach encji za pomocą adnotacji
@ColumnInfo. - Zwiększ numer wersji bazy danych o 1.
- Zdefiniuj ścieżkę migracji do nowej wersji, która implementuje strategię usuwania i ponownego tworzenia aby dodać niezbędne wartości domyślne do istniejących kolumn.
W tym przykładzie pokazujemy ten proces:
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"); } };