Man mano che aggiungi e modifichi le funzionalità della tua app, devi modificare le classi di entità Room e le tabelle del database sottostanti per riflettere queste modifiche. È importante conservare i dati utente già presenti nel database sul dispositivo quando un aggiornamento dell'app modifica lo schema del database.
Room supporta opzioni automatiche e manuali per la migrazione incrementale. Le migrazioni automatiche funzionano per la maggior parte delle modifiche di base dello schema, ma potresti dover definire manualmente i percorsi di migrazione per modifiche più complesse.
Migrazioni automatizzate
Per dichiarare una migrazione automatica tra due versioni del database, aggiungi un'annotazione
@AutoMigration
alla proprietà
autoMigrations
in @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 { ... }
Specifiche della migrazione automatica
Se Room rileva modifiche dello schema ambigue e non riesce a generare un
piano di migrazione senza ulteriori input, genera un errore in fase di compilazione e ti chiede
di implementare un
AutoMigrationSpec
.
Più comunemente, questo si verifica quando una migrazione prevede uno dei seguenti elementi:
- Eliminazione o ridenominazione di una tabella.
- Eliminazione o ridenominazione di una colonna.
Puoi utilizzare AutoMigrationSpec
per fornire a Room le informazioni aggiuntive necessarie per generare correttamente i percorsi di migrazione. Definisci una classe statica che
implementi AutoMigrationSpec
nella classe RoomDatabase
e annotala con
uno o più dei seguenti elementi:
Per utilizzare l'implementazione AutoMigrationSpec
per una migrazione automatica, imposta
la proprietà spec
nell'annotazione @AutoMigration
corrispondente:
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 { } ... }
Se la tua app deve svolgere altro lavoro dopo il completamento della migrazione automatica, puoi implementare
onPostMigrate()
.
Se implementi questo metodo nel tuo AutoMigrationSpec
, Room lo chiama dopo
il completamento della migrazione automatica.
Migrazioni manuali
Nei casi in cui una migrazione comporta modifiche complesse dello schema, Room potrebbe non essere in grado di generare automaticamente un percorso di migrazione appropriato. Ad esempio, se
decidi di dividere i dati di una tabella in due tabelle, Room non può sapere
come eseguire questa divisione. In questi casi, devi definire manualmente
un percorso di migrazione implementando una
classe Migration
.
Una classe Migration
definisce esplicitamente un percorso di migrazione tra un startVersion
e un endVersion
eseguendo l'override del metodo Migration.migrate()
. Aggiungi le classi Migration
al generatore di database utilizzando il metodo
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();
Quando definisci i percorsi di migrazione, puoi utilizzare le migrazioni automatiche per alcune versioni e quelle manuali per altre. Se definisci sia una migrazione automatica sia una migrazione manuale per la stessa versione, Room utilizza la migrazione manuale.
Testare le migrazioni
Le migrazioni sono spesso complesse e una migrazione definita in modo errato può causare
l'arresto anomalo dell'app. Per preservare la stabilità dell'app, testa le
migrazioni. Room fornisce un artefatto Maven room-testing
per facilitare il processo di test per le migrazioni automatiche e manuali. Affinché questo artefatto
funzioni, devi prima esportare lo schema del tuo database.
Schemi di esportazione
Room può esportare le informazioni sullo schema del database in un file JSON in fase di compilazione. I file JSON esportati rappresentano la cronologia dello schema del database. Archivia questi file nel sistema di controllo delle versioni in modo che Room possa creare versioni precedenti del database a scopo di test e per abilitare la generazione automatica della migrazione.
Impostare la posizione dello schema utilizzando il plug-in Gradle di Room
Se utilizzi Room versione 2.6.0 o successive, puoi applicare il
plug-in Gradle di Room e utilizzare l'estensione
room
per specificare la directory dello schema.
Groovy
plugins {
id 'androidx.room'
}
room {
schemaDirectory "$projectDir/schemas"
}
Kotlin
plugins {
id("androidx.room")
}
room {
schemaDirectory("$projectDir/schemas")
}
Se lo schema del database varia in base alla variante, al tipo di build o al sapore, devi specificare posizioni diverse utilizzando più volte la configurazione schemaDirectory()
, ognuna con variantMatchName
come primo argomento. Ogni configurazione può corrispondere a una o più varianti in base a un semplice
confronto con il nome della variante.
Assicurati che siano esaustive e coprano tutte le varianti. Puoi anche includere un
schemaDirectory()
senza un variantMatchName
per gestire le varianti non corrispondenti
a nessuna delle altre configurazioni. Ad esempio, in un'app con due varianti di build demo
e full
e due tipi di build debug
e release
, le seguenti sono configurazioni valide:
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")
}
Impostare la posizione dello schema utilizzando l'opzione del processore di annotazioni
Se utilizzi la versione 2.5.2 o precedente di Room o se non utilizzi il
plug-in Gradle di Room, imposta la posizione dello schema utilizzando l'opzione del processore di annotazioni room.schemaLocation
.
I file in questa directory vengono utilizzati come input e output per alcune attività Gradle.
Per la correttezza e il rendimento delle build incrementali e memorizzate nella cache, devi utilizzare
CommandLineArgumentProvider
per comunicare a Gradle questa directory.
Innanzitutto, copia la classe RoomSchemaArgProvider
mostrata di seguito nel file di build Gradle del modulo. Il metodo asArguments()
nella classe di esempio passa
room.schemaLocation=${schemaDir.path}
a KSP
. Se utilizzi KAPT
e
javac
, modifica questo valore in -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}")
}
}
Poi configura le opzioni di compilazione per utilizzare RoomSchemaArgProvider
con la
directory dello schema specificata:
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"))
)
}
}
}
}
Testare una singola migrazione
Prima di poter testare le migrazioni, aggiungi l'artefatto Maven androidx.room:room-testing
di Room alle dipendenze di test e aggiungi la posizione dello schema esportato come cartella degli asset:
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.7.2" }
Kotlin
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") }
Il pacchetto di test fornisce una
classe MigrationTestHelper
, che può leggere i file dello schema esportati. Il pacchetto implementa anche l'interfaccia
JUnit4
TestRule
, in modo da poter gestire i database creati.
L'esempio seguente mostra un test per una singola migrazione:
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. } }
Test di tutte le migrazioni
Sebbene sia possibile testare una singola migrazione incrementale, ti consigliamo di includere un test che copra tutte le migrazioni definite per il database della tua app. In questo modo, si garantisce che non vi siano discrepanze tra un'istanza del database creata di recente e un'istanza precedente che ha seguito i percorsi di migrazione definiti.
L'esempio seguente mostra un test per tutte le migrazioni definite:
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}; }
Gestire correttamente i percorsi di migrazione mancanti
Se Room non riesce a trovare un percorso di migrazione per eseguire l'upgrade di un database esistente su un
dispositivo alla versione attuale, si verifica un
IllegalStateException
. Se
è accettabile perdere i dati esistenti quando manca un percorso di migrazione, chiama
il
metodo del builder fallbackToDestructiveMigration()
quando crei il database:
Kotlin
Room.databaseBuilder(applicationContext, MyDb::class.java, "database-name") .fallbackToDestructiveMigration() .build()
Java
Room.databaseBuilder(getApplicationContext(), MyDb.class, "database-name") .fallbackToDestructiveMigration() .build();
Questo metodo indica a Room di ricreare in modo distruttivo le tabelle nel database della tua app quando deve eseguire una migrazione incrementale e non è definito alcun percorso di migrazione.
Se vuoi che Room ricorra alla ricreazione distruttiva solo in determinate
situazioni, esistono alcune alternative a fallbackToDestructiveMigration()
:
- Se versioni specifiche della cronologia dello schema causano errori che non puoi risolvere
con i percorsi di migrazione, utilizza
fallbackToDestructiveMigrationFrom()
in alternativa. Questo metodo indica che vuoi che Room esegua il fallback alla ricreazione distruttiva solo durante la migrazione da versioni specifiche. - Se vuoi che Room esegua il fallback alla ricreazione distruttiva solo durante la migrazione
da una versione del database superiore a una inferiore, utilizza
fallbackToDestructiveMigrationOnDowngrade()
invece.
Gestire i valori predefiniti delle colonne durante l'upgrade a Room 2.2.0
In Room 2.2.0 e versioni successive, puoi definire un valore predefinito per una colonna utilizzando
l'annotazione
@ColumnInfo(defaultValue = "...")
.
Nelle versioni precedenti alla 2.2.0, l'unico modo per definire un valore predefinito per una
colonna è definirlo direttamente in un'istruzione SQL eseguita, che crea un
valore predefinito che Room non conosce. Ciò significa che se un database è stato creato originariamente da una versione di Room precedente alla 2.2.0, l'upgrade dell'app per utilizzare Room 2.2.0 potrebbe richiedere di fornire un percorso di migrazione speciale per i valori predefiniti esistenti che hai definito senza utilizzare le API Room.
Ad esempio, supponiamo che la versione 1 di un database definisca un'entità 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; }
Supponiamo inoltre che la versione 2 dello stesso database aggiunga una nuova colonna NOT NULL
e definisca un percorso di migrazione dalla versione 1 alla versione 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 ''"); } };
Ciò causa una discrepanza nella tabella sottostante tra gli aggiornamenti e le nuove
installazioni dell'app. Poiché il valore predefinito per la colonna tag
viene dichiarato solo
nel percorso di migrazione dalla versione 1 alla versione 2, gli utenti che
installano l'app a partire dalla versione 2 non hanno il valore predefinito per tag
nello schema del database.
Nelle versioni di Room precedenti alla 2.2.0, questa discrepanza è innocua. Tuttavia, se
l'app esegue l'upgrade per utilizzare Room 2.2.0 o versioni successive e modifica la classe dell'entità Song
per includere un valore predefinito per tag
utilizzando
l'annotazione @ColumnInfo
, Room
può quindi rilevare questa discrepanza. Ciò comporta errori di convalida
dello schema.
Per garantire che lo schema del database sia coerente per tutti gli utenti quando i valori predefiniti delle colonne vengono dichiarati nei percorsi di migrazione precedenti, esegui le seguenti operazioni la prima volta che esegui l'upgrade dell'app per utilizzare Room 2.2.0 o versioni successive:
- Dichiara i valori predefiniti delle colonne nelle rispettive classi di entità utilizzando l'annotazione
@ColumnInfo
. - Aumenta di 1 il numero di versione del database.
- Definisci un percorso di migrazione alla nuova versione che implementa la strategia di eliminazione e ricreazione per aggiungere i valori predefiniti necessari alle colonne esistenti.
Il seguente esempio mostra questo processo:
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"); } };