Esegui la migrazione del database delle stanze virtuali

Man mano che aggiungi e modifichi funzionalità nella tua app, devi modificare le classi di entità room e le tabelle di database sottostanti per riflettere queste modifiche. Quando un aggiornamento dell'app modifica lo schema del database, è importante conservare i dati utente già presenti nel database sul dispositivo.

La stanza supporta le opzioni automatiche e manuali per la migrazione incrementale. Le migrazioni automatiche funzionano per la maggior parte delle modifiche di base allo schema, ma potrebbe essere necessario definire manualmente i percorsi di migrazione per modifiche più complesse.

Migrazioni automatiche

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 per la migrazione automatica

Se la stanza rileva modifiche ambigue allo schema e non può generare un piano di migrazione senza ulteriori input, genera un errore in fase di compilazione e chiede di implementare un elemento AutoMigrationSpec. Più comunemente, ciò si verifica quando una migrazione comporta uno dei seguenti problemi:

  • 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 implementa AutoMigrationSpec nella tua 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 più attività al termine della migrazione automatica, puoi implementare onPostMigrate(). Se implementi questo metodo in AutoMigrationSpec, la stanza virtuale lo chiama al termine della migrazione automatica.

Migrazioni manuali

Se una migrazione comporta modifiche complesse allo schema, Room potrebbe non essere in grado di generare automaticamente un percorso di migrazione appropriato. Ad esempio, se decidi di suddividere i dati di una tabella in due tabelle, Room non può indicare come eseguire questa suddivisione. In casi come questi, devi definire manualmente un percorso di migrazione implementando una classe Migration.

Una classe Migration definisce esplicitamente un percorso di migrazione tra startVersion e endVersion eseguendo l'override del metodo Migration.migrate(). Aggiungi le tue 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 le migrazioni manuali per altre. Se definisci una migrazione automatica e una manuale per la stessa versione, la stanza virtuale utilizza la migrazione manuale.

Testa migrazioni

Le migrazioni sono spesso complesse e una migrazione definita in modo errato può causare l'arresto anomalo dell'app. Per preservare la stabilità della tua app, testa le migrazioni. La stanza fornisce un artefatto Maven di room-testing per facilitare il processo di test per le migrazioni manuali e automatiche. Affinché questo artefatto funzioni, devi prima esportare lo schema del database.

Esporta schemi

La stanza virtuale può esportare le informazioni di schema del database in un file JSON in fase di compilazione. I file JSON esportati rappresentano la cronologia degli schemi del database. Archivia questi file nel tuo sistema di controllo della versione in modo che Room possa creare versioni precedenti del database a scopo di test e per consentire la generazione automatica della migrazione.

Impostare la posizione dello schema usando il plug-in Room Gradle

Se usi la versione 2.6.0 o successive della stanza, puoi applicare il plug-in Room Gradle e utilizzare l'estensione room per specificare la directory dello schema.

trendy

plugins {
  id 'androidx.room'
}

room {
  schemaDirectory "$projectDir/schemas"
}

Kotlin

plugins {
  id("androidx.room")
}

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

Se lo schema del database è diverso in base alla variante, alla versione o al tipo di build, devi specificare località diverse utilizzando più volte la configurazione schemaDirectory(), ciascuna con un 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 questi dati siano esaustivi e coprano tutte le varianti. Puoi anche includere un elemento schemaDirectory() senza variantMatchName per gestire le varianti non corrispondenti ad altre configurazioni. Ad esempio, in un'app con due versioni di build demo e full e due tipi di build debug e release, le seguenti sono configurazioni valide:

trendy

room {
  // Applies to 'demoDebug' only
  schemaLocation "demoDebug", "$projectDir/schemas/demoDebug"

  // Applies to 'demoDebug' and 'demoRelease'
  schemaLocation "demo", "$projectDir/schemas/demo"

  // Applies to 'demoDebug' and 'fullDebug'
  schemaLocation "debug", "$projectDir/schemas/debug"

  // Applies to variants that aren't matched by other configurations.
  schemaLocation "$projectDir/schemas"
}

Kotlin

room {
  // Applies to 'demoDebug' only
  schemaLocation("demoDebug", "$projectDir/schemas/demoDebug")

  // Applies to 'demoDebug' and 'demoRelease'
  schemaLocation("demo", "$projectDir/schemas/demo")

  // Applies to 'demoDebug' and 'fullDebug'
  schemaLocation("debug", "$projectDir/schemas/debug")

  // Applies to variants that aren't matched by other configurations.
  schemaLocation("$projectDir/schemas")
}

Imposta la posizione dello schema utilizzando l'opzione del processore di annotazioni

Se utilizzi la versione 2.5.2 o precedenti di Room o non usi il plug-in Room Gradle, imposta la posizione dello schema usando 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 le prestazioni delle build incrementali e memorizzate nella cache, devi utilizzare Gradle CommandLineArgumentProvider per informare Gradle in merito a questa directory.

Innanzitutto, copia la classe RoomSchemaArgProvider mostrata di seguito nel file di build Gradle del tuo 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}.

trendy

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

Quindi configura le opzioni di compilazione in modo da utilizzare RoomSchemaArgProvider con la directory dello schema specificata:

trendy

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

Testa una singola migrazione

Prima di poter testare le migrazioni, aggiungi l'elemento Maven androidx.room:room-testing dalla stanza alle dipendenze di test e aggiungi la posizione dello schema esportato come cartella degli asset:

build.gradle

trendy

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

Kotlin

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

Il pacchetto di test fornisce una classe MigrationTestHelper, che può leggere i file di schema esportati. Il pacchetto implementa anche l'interfaccia JUnit4 TestRule, per 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.
    }
}

Testa 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. Questo contribuisce a garantire che non ci siano discrepanze tra un'istanza del database creata di recente e un'istanza meno recente 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};
}

Gestisci con attenzione i percorsi di migrazione mancanti

Se la stanza 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 di creazione 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 dell'app quando deve eseguire una migrazione incrementale e non esiste un percorso di migrazione definito.

Se vuoi che la stanza virtuale riprenda solo le attività ricreative distruttive in determinate situazioni, esistono alcune alternative a fallbackToDestructiveMigration():

  • Se versioni specifiche della cronologia dello schema causano errori che non è possibile risolvere con i percorsi di migrazione, utilizza invece fallbackToDestructiveMigrationFrom(). Questo metodo indica che vuoi che la stanza virtuale ritorni alla creazione distruttiva solo durante la migrazione da versioni specifiche.
  • Se vuoi che la stanza virtuale utilizzi una ricreazione distruttiva solo durante la migrazione da una versione superiore a una precedente, utilizza fallbackToDestructiveMigrationOnDowngrade().

Gestisci i valori predefiniti delle colonne quando esegui l'upgrade alla stanza 2.2.0

Nella stanza 2.2.0 e 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, in modo da creare un valore predefinito che la Room non è a conoscenza. Ciò significa che se un database è stato originariamente creato da una versione della stanza precedente alla 2.2.0, l'upgrade dell'app per utilizzare la stanza 2.2.0 potrebbe richiedere di fornire un percorso di migrazione speciale per i valori predefiniti esistenti definiti senza utilizzare le API Room.

Ad esempio, supponi 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 anche 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 ''");
    }
};

Questo 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, tutti gli utenti che installano l'app a partire dalla versione 2 non hanno il valore predefinito per tag nello schema di database.

Nelle versioni di Room precedenti alla 2.2.0, questa discrepanza è innocua. Tuttavia, se in un secondo momento l'app esegue l'upgrade per utilizzare la camera 2.2.0 o versioni successive e modifica la classe dell'entità Song in modo da includere un valore predefinito per tag utilizzando l'annotazione @ColumnInfo, la stanza virtuale può rilevare questa discrepanza. Ciò comporta convalide dello schema non riuscite.

Per garantire che lo schema del database sia coerente per tutti gli utenti quando vengono dichiarati i valori predefiniti della colonna nei percorsi di migrazione precedenti, segui questa procedura la prima volta che esegui l'upgrade dell'app per utilizzare la stanza 2.2.0 o successive:

  1. Dichiara i valori predefiniti delle colonne nelle rispettive classi di entità utilizzando l'annotazione @ColumnInfo.
  2. Aumenta il numero di versione del database di 1.
  3. Definire un percorso di migrazione alla nuova versione che implementa la strategia di rilascio e ricreazione per aggiungere i valori predefiniti necessari alle colonne esistenti.

L'esempio seguente illustra 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");
    }
};