Migracja bazy danych sal

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 an @AutoMigration adnotację do właściwości autoMigrations w @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
}

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 kompilacji i musisz podać implementację AutoMigrationSpec. Najczęściej zdarza się to, gdy migracja obejmuje jedną z tych czynności:

  • Usuwanie lub zmiana nazwy tabeli.
  • Usuwanie lub zmiana nazwy kolumny.

Możesz użyć AutoMigrationSpec, aby przekazać Room dodatkowe informacje potrzebne do prawidłowego wygenerowania ścieżek migracji. Zdefiniuj klasę, 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:

@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

Jeśli aplikacja musi wykonać więcej czynności po zakończeniu automatycznej migracji, możesz zaimplementować onPostMigrate. Jeśli zaimplementujesz tę funkcję w AutoMigrationSpec, Room wywoła ją po zakończeniu automatycznej migracji.

Migracje ręczne

Jeśli migracja obejmuje złożone zmiany schematu, Room może nie być w stanie automatycznie wygenerować odpowiedniej ścieżki migracji. Jeśli na przykład zdecydujesz się podzielić dane w tabeli na 2 tabele, Room nie będzie w stanie określić, jak to zrobić. W takich sytuacjach musisz ręcznie zdefiniować a ścieżkę migracji, implementując a Migration klasę.

Klasa Migration jawnie definiuje ścieżkę migracji między startVersion a endVersion, zastępując funkcję migrate. Dodaj swoje Migration klasy do narzędzia do tworzenia bazy danych za pomocą addMigrations funkcji:

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

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 room3-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 eksportuje 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 móc odtworzyć starsze wersje bazy danych na potrzeby testowania i obsługi automatycznego generowania migracji.

Ustawianie lokalizacji schematu za pomocą wtyczki Room Gradle

Aby określić katalog schematu, zastosuj wtyczkę Room Gradle i użyj rozszerzenia room3.

Dynamiczny

plugins {
  id 'androidx.room3'
}

room3 {
  schemaDirectory "$projectDir/schemas"
}

Kotlin

plugins {
  id("androidx.room3")
}

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

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

Ustawianie lokalizacji schematu za pomocą opcji procesora adnotacji

Jeśli nie używasz wtyczki Room Gradle, ustaw lokalizację schematu za pomocą opcji procesora adnotacji room.schemaLocation.

Gradle używa plików w tym katalogu jako danych wejściowych i wyjściowych w przypadku niektórych zadań Gradle. Aby zapewnić poprawność i wydajność kompilacji przyrostowych i buforowanych, musisz użyć Gradle's CommandLineArgumentProvider, aby poinformować Gradle o tym katalogu.

Najpierw skopiuj tę klasę RoomSchemaArgProvider do pliku kompilacji Gradle modułu. Funkcja 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() {
    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}")
  }
}

Następnie skonfiguruj opcje kompilacji, aby używać RoomSchemaArgProvider z określonym katalogiem schematu:

Dynamiczny

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

Kotlin

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

Testowanie pojedynczej migracji

Zanim przetestujesz migracje, dodaj artefakt androidx.room3:room3-testing do zależności testowych i dodaj lokalizację wyeksportowanego schematu jako katalog zasobów:

Dynamiczny

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

Pakiet testowy zawiera klasę MigrationTestHelper, która może odczytywać wyeksportowane pliki schematu. Pakiet implementuje też interfejs JUnit4 TestRule do zarządzania utworzonymi bazami danych.

Ten przykład pokazuje test pojedynczej migracji:

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

Testowanie wszystkich migracji

Chociaż możesz przetestować pojedynczą migrację przyrostową, warto uwzględnić test, 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 wcześniejszą instancją, która korzystała ze zdefiniowanych ścieżek migracji.

Ten przykład pokazuje test wszystkich zdefiniowanych migracji:

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

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 utrata istniejących danych w przypadku braku ścieżki migracji jest dopuszczalna, podczas tworzenia bazy danych wywołaj funkcję narzędzia do tworzenia fallbackToDestructiveMigration:

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

Ta funkcja konfiguruje Room tak, aby destrukcyjnie odtwarzała tabele w bazie danych aplikacji, gdy musi przeprowadzić migrację przyrostową, a nie ma zdefiniowanej ścieżki migracji.

Aby przywrócić destrukcyjne odtwarzanie tylko w określonych sytuacjach, użyj jednej z tych 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 funkcja wskazuje, że chcesz, aby Room przywracał destrukcyjne odtwarzanie tylko podczas migracji z określonych wersji.
  • Jeśli chcesz, aby Room przywracał destrukcyjne odtwarzanie tylko podczas migracji z wyższej wersji bazy danych do niższej, użyj fallbackToDestructiveMigrationOnDowngrade zamiast tego.