Migrer votre base de données Room

Lorsque vous ajoutez ou modifiez des fonctionnalités dans votre application, vous devez modifier vos classes d'entités Room et les tables de base de données sous-jacentes afin de refléter ces modifications. Il est important de conserver les données utilisateur déjà présentes dans la base de données sur l'appareil lorsqu'une mise à jour d'application modifie le schéma de la base de données.

Room prend en charge les options automatisées et manuelles pour une migration incrémentielle. Les migrations automatiques fonctionnent pour la plupart des modifications de schéma de base, mais vous devrez peut-être définir manuellement les chemins de migration pour des modifications plus complexes.

Migrations automatisées

Pour déclarer une migration automatisée entre deux versions de base de données, ajoutez une @AutoMigration annotation à la autoMigrations propriété dans @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
}

Spécifications de la migration automatique

Si Room détecte des modifications de schéma ambiguës et qu'il ne parvient pas à générer de plan de migration sans informations supplémentaires, il génère une erreur au moment de la compilation et vous devez fournir une implémentation AutoMigrationSpec. Cela se produit généralement lorsqu'une migration implique l'une des opérations suivantes :

  • Supprimer ou renommer une table
  • Supprimer ou renommer une colonne

Vous pouvez utiliser AutoMigrationSpec pour fournir les informations supplémentaires dont Room a besoin pour générer correctement les chemins de migration. Définissez une classe qui implémente AutoMigrationSpec dans votre classe RoomDatabase, puis annotez-la avec une ou plusieurs des annotations suivantes :

Si vous souhaitez utiliser l'implémentation de AutoMigrationSpec pour une migration automatisée, définissez la propriété spec dans l'annotation @AutoMigration correspondante :

@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

Si votre application doit effectuer plus de travail une fois la migration automatisée terminée, vous pouvez implémenter onPostMigrate. Si vous implémentez cette fonction dans votre AutoMigrationSpec, Room l'appelle une fois la migration automatisée terminée.

Migrations manuelles

Si une migration implique des modifications de schéma complexes, il se peut que Room ne soit pas en mesure de générer automatiquement un chemin de migration approprié. Par exemple, si vous décidez de diviser les données d'une table en deux, Room ne peut pas déterminer comment effectuer cette division. Dans de tels cas, vous devez définir manuellement un chemin de migration en implémentant une Migration classe.

Une classe Migration définit explicitement un chemin de migration entre une startVersion et une endVersion en remplaçant la fonction migrate. Ajoutez vos Migration classes à votre compilateur de base de données à l'aide de la addMigrations fonction :

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()

Lorsque vous définissez des chemins de migration, vous pouvez utiliser des migrations automatisées pour certaines versions et des migrations manuelles pour d'autres. Si vous définissez à la fois une migration automatisée et une migration manuelle pour la même version, Room utilise la migration manuelle.

Tester les migrations

Les migrations sont souvent complexes. Une migration définie de manière incorrecte peut entraîner le plantage de votre application. Pour préserver la stabilité de votre application, testez vos migrations. Room fournit un artefact Maven room3-testing pour faciliter le processus de test des migrations automatisées et manuelles. Pour que cet artefact fonctionne, vous devez d'abord exporter le schéma de votre base de données.

Exporter des schémas

Room exporte les informations de schéma de votre base de données dans un fichier JSON au moment de la compilation. Les fichiers JSON exportés représentent l'historique des schémas de votre base de données. Stockez ces fichiers dans votre système de contrôle des versions afin de pouvoir recréer des versions antérieures de la base de données à des fins de test et pour permettre la génération de migrations automatisées.

Définir l'emplacement du schéma à l'aide du plug-in Room Gradle

Pour spécifier le répertoire du schéma, appliquez le plug-in Room Gradle et utilisez l' room3 extension.

Groovy

plugins {
  id 'androidx.room3'
}

room3 {
  schemaDirectory "$projectDir/schemas"
}

Kotlin

plugins {
  id("androidx.room3")
}

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

Si le schéma de votre base de données diffère selon la variante, le type de produit ou le type de compilation, vous devez spécifier différents emplacements à l'aide de la configuration schemaDirectory plusieurs fois, chacun avec un variantMatchName comme premier argument. Chaque configuration peut correspondre à une ou plusieurs variantes en fonction d'une simple comparaison avec le nom de la variante.

Assurez-vous qu'elles sont exhaustives et couvrent toutes les variantes. Vous pouvez également inclure un schemaDirectory() sans variantMatchName pour gérer les variantes qui ne correspondent à aucune des autres configurations. Par exemple, dans une application avec deux types de produits (demo et full) et deux types de compilation (debug et release), les configurations suivantes sont valides :

Groovy

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

Définir l'emplacement du schéma à l'aide de l'option de processeur d'annotations

Si vous n'utilisez pas le plug-in Room Gradle, définissez l'emplacement du schéma à l'aide de l'option de processeur d'annotations room.schemaLocation.

Gradle utilise les fichiers de ce répertoire comme entrées et sorties pour certaines tâches Gradle. Pour garantir l'exactitude et les performances des builds incrémentiels et mis en cache, vous devez utiliser Gradle's CommandLineArgumentProvider pour informer Gradle de ce répertoire.

Commencez par copier la classe RoomSchemaArgProvider suivante dans le fichier de compilation Gradle de votre module. La fonction asArguments de l'exemple de classe transmet room.schemaLocation=${schemaDir.path} à KSP. Si vous utilisez KAPT et javac, remplacez cette valeur par -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() {
    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}")
  }
}

Configurez ensuite les options de compilation pour utiliser RoomSchemaArgProvider avec le répertoire de schéma spécifié :

Groovy

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

Kotlin

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

Tester une seule migration

Avant de pouvoir tester vos migrations, ajoutez l'artefact androidx.room3:room3-testing à vos dépendances de test, puis ajoutez l'emplacement du schéma exporté en tant que répertoire d'éléments :

Groovy

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

Le package de test fournit une MigrationTestHelper classe, qui peut lire les fichiers de schéma exportés. Le package implémente également l'interface JUnit4 TestRule pour gérer les bases de données créées.

L'exemple suivant présente un test pour une seule migration :

@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()
    }
}

Tester toutes les migrations

Bien que vous puissiez tester une seule migration incrémentielle, vous devez inclure un test qui couvre toutes les migrations définies pour la base de données de votre application. Cela permet de s'assurer qu'il n'y a pas de différence entre une instance de base de données récemment créée et une instance antérieure qui a suivi les chemins de migration définis.

L'exemple suivant présente un test pour toutes les migrations définies :

@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()
    }
}

Gérer correctement les chemins de migration manquants

Si Room ne parvient pas à trouver un chemin de migration pour mettre à niveau une base de données existante sur un appareil, une IllegalStateException se produit. S'il est acceptable de perdre des données existantes lorsqu'un chemin de migration est manquant, appelez la fallbackToDestructiveMigration fonction de compilateur lorsque vous créez la base de données :

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

Cette fonction configure Room pour recréer de manière destructive les tables de la base de données de votre application lorsqu'une migration incrémentielle est nécessaire en l'absence de chemin de migration défini.

Pour n'avoir recours à la recréation destructive que dans certaines situations, utilisez l'une des alternatives suivantes à fallbackToDestructiveMigration :

  • Si des versions spécifiques de votre historique des schémas génèrent des erreurs que vous ne pouvez pas résoudre avec les chemins de migration, utilisez fallbackToDestructiveMigrationFrom plutôt. Cette fonction indique que vous souhaitez que Room ait uniquement recours à la recréation destructive lors de la migration à partir de versions spécifiques.
  • Si vous souhaitez que Room ait uniquement recours à la recréation destructive lors de la migration d'une version de base de données ultérieure vers une version antérieure, utilisez fallbackToDestructiveMigrationOnDowngrade plutôt.