Esegui la migrazione del database delle stanze virtuali

Quando aggiungi e modifichi le funzionalità della tua app, devi modificare le classi di entità Room e le tabelle di 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 schema di base, ma potresti dover definire manualmente i percorsi di migrazione per modifiche più complesse.

Migrazioni automatiche

Per dichiarare una migrazione automatica tra due versioni del database, aggiungi un' @AutoMigration annotazione alla autoMigrations proprietà in @Database:

// Database class before the version update.
@Database(
  version = 1,
  entities = [User::class]
)
abstract class AppDatabaseV1 : RoomDatabase() {
  abstract fun userDao(): UserDao
}

// Database class after the version update.
@Database(
  version = 2,
  entities = [User::class],
  autoMigrations = [
    AutoMigration(from = 1, to = 2)
  ]
)
abstract class AppDatabaseV2 : RoomDatabase() {
  abstract fun userDao(): UserDao
}

Specifiche della migrazione automatica

Se Room rileva modifiche di schema ambigue e non riesce a generare un piano di migrazione senza ulteriori input, genera un errore in fase di compilazione e devi fornire un' AutoMigrationSpec implementazione. In genere, questo si verifica quando una migrazione comporta una delle seguenti operazioni:

  • Eliminazione o ridenominazione di una tabella.
  • Eliminazione o ridenominazione di una colonna.

Puoi utilizzare AutoMigrationSpec per fornire a Room le informazioni aggiuntive di cui ha bisogno per generare correttamente i percorsi di migrazione. Definisci una classe che implementa AutoMigrationSpec nella classe RoomDatabase e annotala con una o più delle seguenti opzioni:

Per utilizzare l'implementazione di AutoMigrationSpec per una migrazione automatica, imposta la proprietà spec nell'annotazione @AutoMigration corrispondente:

@Database(
  version = 2,
  entities = [User::class],
  autoMigrations = [
    AutoMigration (
      from = 1,
      to = 2,
      spec = MigrationSpec1To2::class
    )
  ]
)
abstract class AppDatabaseWithSpec : RoomDatabase() {
  abstract fun userDao(): UserDao
}

@RenameTable(fromTableName = "User", toTableName = "AppUser")
internal class MigrationSpec1To2 : AutoMigrationSpec

Se la tua app deve eseguire altre operazioni al termine della migrazione automatica, puoi implementare onPostMigrate. Se implementi questa funzione in AutoMigrationSpec, Room la chiama al termine della migrazione automatica.

Migrazioni manuali

Se una migrazione comporta modifiche di schema complesse, 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ò determinare come eseguire questa divisione. In questi casi, devi definire manualmente un percorso di migrazione implementando una Migration classe.

Una classe Migration definisce in modo esplicito un percorso di migrazione tra una startVersion e una endVersion eseguendo l'override della funzione migrate. Aggiungi le Migration classi al builder del database utilizzando la addMigrations funzione:

val MIGRATION_1_2 = object : Migration(1, 2) {
  override suspend fun migrate(connection: SQLiteConnection) {
    connection.executeSQL("CREATE TABLE `Fruit` (`id` INTEGER, `name` TEXT, " +
      "PRIMARY KEY(`id`))")
  }
}

val MIGRATION_2_3 = object : Migration(2, 3) {
  override suspend fun migrate(connection: SQLiteConnection) {
    connection.executeSQL("ALTER TABLE Book ADD COLUMN pub_year INTEGER")
  }
}

Room.databaseBuilder<ManualMigrationDatabase>(applicationContext, "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 sia una migrazione automatica sia una migrazione manuale per la stessa versione, Room utilizza la migrazione manuale.

Test delle 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 room3-testing per facilitare il processo di test sia per le migrazioni automatiche sia per quelle manuali. Affinché questo artefatto funzioni, devi prima esportare lo schema del database.

Esportazione degli schemi

Room esporta 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 della versione in modo da poter ricreare le versioni precedenti del database per i test e supportare la generazione automatica della migrazione.

Impostazione della località dello schema utilizzando il plug-in Gradle di Room

Per specificare la directory dello schema, applica il plug-in Gradle di Room e utilizza l' room3 estensione.

Alla moda

plugins {
  id 'androidx.room3'
}

room3 {
  schemaDirectory "$projectDir/schemas"
}

Kotlin

plugins {
  id("androidx.room3")
}

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

Se lo schema del database varia in base alla variante, al flavor o al tipo di build, devi specificare località diverse utilizzando più volte la configurazione schemaDirectory, ognuna 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 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 flavor di build demo e full e due tipi di build debug e release, le seguenti sono configurazioni valide:

Alla moda

room3 {
  // 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

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

Impostazione della località dello schema utilizzando l'opzione del processore di annotazioni

Se non utilizzi il plug-in Gradle di Room, imposta la località dello schema utilizzando l'opzione del processore di annotazioni room.schemaLocation.

Gradle utilizza i file in questa directory come input e output per alcune attività Gradle. Per la correttezza e il rendimento delle build incrementali e memorizzate nella cache, devi utilizzare Gradle's CommandLineArgumentProvider per informare Gradle di questa directory.

Innanzitutto, copia la seguente classe RoomSchemaArgProvider nel file di build Gradle del modulo. La funzione 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}.

Alla moda

class RoomSchemaArgProvider implements CommandLineArgumentProvider {

  @InputDirectory
  @PathSensitive(PathSensitivity.RELATIVE)
  File schemaDir

  RoomSchemaArgProvider(File schemaDir) {
    this.schemaDir = schemaDir
  }

  @Override
  Iterable<String> asArguments() {
    return ["room.schemaLocation=${schemaDir.path}".toString()]
  }
}

Kotlin

class RoomSchemaArgProvider(
  @get:InputDirectory
  @get:PathSensitive(PathSensitivity.RELATIVE)
  val schemaDir: File
) : CommandLineArgumentProvider {

  override fun asArguments(): Iterable<String> {
    return listOf("room.schemaLocation=${schemaDir.path}")
  }
}

Poi configura le opzioni di compilazione per utilizzare RoomSchemaArgProvider con la directory dello schema specificata:

Alla moda

ksp {
  arg(new RoomSchemaArgProvider(new File(projectDir, "schemas")))
}

Kotlin

ksp {
  arg(RoomSchemaArgProvider(File(projectDir, "schemas")))
}

Test di una singola migrazione

Prima di poter testare le migrazioni, aggiungi l'artefatto androidx.room3:room3-testing alle dipendenze di test e aggiungi la località dello schema esportato come directory degli asset:

Alla moda

android {
    ...
    sourceSets {
        // Adds exported schema location as test app assets if not using
        // the Room Gradle Plugin.
        androidTest.assets.srcDirs += files("$projectDir/schemas".toString())
    }
}

dependencies {
    ...
    androidTestImplementation "androidx.room3:room3-testing:3.0.1"
}

Kotlin

android {
    ...
    sourceSets {
        // Adds exported schema location as test app assets if not using
        // the Room Gradle Plugin.
        getByName("androidTest").assets.srcDir("$projectDir/schemas")
    }
}

dependencies {
    ...
    testImplementation("androidx.room3:room3-testing:3.0.1")
}

Il pacchetto di test fornisce una MigrationTestHelper classe, che può leggere i file di schema esportati. Il pacchetto implementa anche l'interfaccia JUnit4 TestRule per gestire i database creati.

L'esempio seguente mostra un test per una singola migrazione:

@RunWith(AndroidJUnit4::class)
class MigrationTest {
    private val TEST_DB = "migration-test"

    private val instrumentation = InstrumentationRegistry.getInstrumentation()

    @get:Rule
    val helper = MigrationTestHelper(
        instrumentation = instrumentation,
        databaseClass = MigrationDb::class,
        driver = AndroidSQLiteDriver(),
        file = instrumentation.targetContext.getDatabasePath(TEST_DB),
    )

    @Test
    fun migrate1To2() = runTest {
        val connection = helper.createDatabase(1)
        // Database has schema version 1. Insert some data using SQL queries.
        // You can't use DAO classes because they expect the latest schema.
        connection.execSQL("INSERT INTO User (id, name) VALUES (1, 'John Doe')")
        connection.close()

        // Re-open the database with version 2 and provide MIGRATION_1_2
        val migratedConnection = helper.runMigrationsAndValidate(2, listOf(MIGRATION_1_2))

        // MigrationTestHelper automatically verifies the schema changes,
        // but you need to validate that the data was migrated properly.
        val hasData = migratedConnection.prepare("SELECT COUNT(*) FROM User").use {
          it.step()
          it.getLong(0) > 0
        }
        assertTrue("Expected data was not migrated", hasData)
        migratedConnection.close()
    }
}

Test di tutte le migrazioni

Sebbene tu possa testare una singola migrazione incrementale, devi includere un test che copra tutte le migrazioni definite per il database dell'app. In questo modo, non ci sono discrepanze tra un'istanza di 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:

@RunWith(AndroidJUnit4::class)
class MigrationTest {
    private val TEST_DB = "migration-test"

    private val instrumentation = InstrumentationRegistry.getInstrumentation()

    // Array of all migrations.
    private val ALL_MIGRATIONS = arrayOf(MIGRATION_1_2, MIGRATION_2_3, MIGRATION_3_4)

    @get:Rule
    val helper: MigrationTestHelper = MigrationTestHelper(
        instrumentation = instrumentation,
        databaseClass = MigrationDb::class,
        driver = AndroidSQLiteDriver(),
        file = instrumentation.targetContext.getDatabasePath(TEST_DB),
    )

    @Test
    fun migrateAll() = runTest {
        // Create earliest version of the database.
        val connection = helper.createDatabase(1)
        connection.close()

        // Create latest version of the database.
        val db = Room.databaseBuilder<AppDatabase>(instrumentation.targetContext, TEST_DB)
          .setDriver(AndroidSQLiteDriver())
          .addMigrations(*ALL_MIGRATIONS)
          .build()
        // Open the database, Room validates the schema once all migrations
        // execute.
        db.useReaderConnection { connection ->
          // Perform additional validation
        }

        db.close()
    }
}

Gestione corretta dei 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 la funzione del builder fallbackToDestructiveMigration quando crei il database:

Room.databaseBuilder<FallbackMigrationDatabase>(applicationContext, "database-name")
        .fallbackToDestructiveMigration()
        .build()

Questa funzione configura Room in modo che ricrei in modo distruttivo le tabelle nel database dell'app quando deve eseguire una migrazione incrementale e non è presente alcun percorso di migrazione definito.

Per eseguire il fallback alla ricreazione distruttiva solo in determinate situazioni, utilizza una delle seguenti alternative a fallbackToDestructiveMigration:

  • Se versioni specifiche della cronologia dello schema causano errori che non puoi risolvere con i percorsi di migrazione, utilizza fallbackToDestructiveMigrationFrom invece. Questa funzione 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.