Memigrasikan database Room

Saat menambahkan dan mengubah fitur dalam aplikasi, Anda harus mengubah class entity Room dan tabel database pokok untuk mencerminkan perubahan ini. Penting untuk menyimpan data pengguna yang sudah ada dalam database pada perangkat saat update aplikasi mengubah skema database.

Room mendukung opsi otomatis dan manual untuk migrasi inkremental. Migrasi otomatis berfungsi untuk sebagian besar perubahan skema dasar, tetapi Anda mungkin perlu menentukan jalur migrasi secara manual untuk perubahan yang lebih kompleks.

Migrasi otomatis

Untuk mendeklarasikan migrasi otomatis antara dua versi database, tambahkan an @AutoMigration anotasi ke properti autoMigrations di @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
}

Spesifikasi migrasi otomatis

Jika Room mendeteksi adanya perubahan skema yang ambigu dan tidak dapat menghasilkan paket migrasi tanpa input lain, Room akan menampilkan error waktu kompilasi dan Anda harus menyediakan implementasi AutoMigrationSpec. Masalah ini paling sering terjadi saat migrasi melibatkan salah satu dari hal berikut:

  • Menghapus atau mengganti nama tabel.
  • Menghapus atau mengganti nama kolom.

Anda dapat menggunakan AutoMigrationSpec untuk memberi informasi tambahan yang diperlukan Room untuk membuat jalur migrasi dengan benar. Tentukan class yang mengimplementasikan AutoMigrationSpec di class RoomDatabase Anda dan beri anotasi dengan satu atau beberapa hal berikut:

Agar dapat menggunakan implementasi AutoMigrationSpec untuk migrasi otomatis, tetapkan properti spec dalam anotasi @AutoMigration yang sesuai:

@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

Jika aplikasi Anda perlu melakukan lebih banyak pekerjaan setelah migrasi otomatis selesai, Anda dapat mengimplementasikan onPostMigrate. Jika Anda mengimplementasikan fungsi ini di AutoMigrationSpec, Room akan memanggilnya setelah migrasi otomatis selesai.

Migrasi manual

Jika migrasi melibatkan perubahan skema yang kompleks, Room mungkin tidak dapat menghasilkan jalur migrasi yang sesuai secara otomatis. Misalnya, jika Anda memutuskan untuk membagi data dalam tabel menjadi dua tabel, Room tidak dapat menentukan cara melakukan pemisahan ini. Dalam situasi ini, Anda harus menentukan jalur migrasi secara manual dengan mengimplementasikan class Migration.

Class Migration secara eksplisit menentukan jalur migrasi antara startVersion dan endVersion dengan mengganti fungsi migrate. Tambahkan class Migration ke builder database menggunakan addMigrations fungsi:

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

Saat menentukan jalur migrasi, Anda dapat menggunakan migrasi otomatis untuk sebagian versi dan juga migrasi manual untuk sebagian versi lainnya. Jika Anda menentukan migrasi otomatis dan migrasi manual untuk versi yang sama, Room akan memilih menggunakan migrasi manual.

Menguji migrasi

Migrasi sering kali rumit, dan migrasi yang tidak ditetapkan dengan benar dapat menyebabkan aplikasi Anda error. Untuk mempertahankan stabilitas aplikasi, uji migrasi. Room menyediakan artefak Maven room3-testing untuk membantu proses pengujian migrasi otomatis dan manual. Agar artefak ini berfungsi dengan baik, Anda harus mengekspor skema database terlebih dahulu.

Mengekspor skema

Room mengekspor informasi skema database Anda ke dalam file JSON pada waktu kompilasi. File JSON yang diekspor merepresentasikan histori skema database Anda. Simpan file ini di sistem kontrol versi agar Anda dapat membuat ulang database versi yang lebih rendah untuk pengujian dan mendukung pembuatan migrasi otomatis.

Menetapkan lokasi skema menggunakan Plugin Room Gradle

Untuk menentukan direktori skema, terapkan Plugin Gradle Room dan gunakan room3 ekstensi.

Groovy

plugins {
  id 'androidx.room3'
}

room3 {
  schemaDirectory "$projectDir/schemas"
}

Kotlin

plugins {
  id("androidx.room3")
}

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

Jika skema database Anda berbeda berdasarkan varian, ragam, atau jenis build, Anda harus menentukan lokasi yang berbeda dengan menggunakan konfigurasi schemaDirectory beberapa kali, masing-masing dengan variantMatchName sebagai argumen pertama. Setiap konfigurasi dapat cocok dengan satu atau beberapa varian berdasarkan perbandingan sederhana dengan nama varian.

Pastikan konfigurasi ini lengkap dan mencakup semua varian. Anda juga dapat menyertakan schemaDirectory() tanpa variantMatchName untuk menangani varian yang tidak cocok dengan konfigurasi lainnya. Misalnya, di aplikasi dengan dua ragam build demo dan full serta dua jenis build debug dan release, konfigurasi berikut valid:

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

Menetapkan lokasi skema menggunakan opsi pemroses anotasi

Jika Anda tidak menggunakan plugin Gradle Room, tetapkan lokasi skema menggunakan opsi pemroses anotasi room.schemaLocation.

Gradle menggunakan file dalam direktori ini sebagai input dan output untuk beberapa tugas Gradle. Untuk kebenaran dan performa build inkremental dan yang di-cache, Anda harus menggunakan Gradle's CommandLineArgumentProvider untuk memberi tahu Gradle tentang direktori ini.

Pertama, salin class RoomSchemaArgProvider berikut ke dalam file build Gradle modul Anda. Fungsi asArguments di class contoh meneruskan room.schemaLocation=${schemaDir.path} ke KSP. Jika Anda menggunakan KAPT dan javac, ubah nilai ini menjadi -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}")
  }
}

Kemudian, konfigurasi opsi kompilasi untuk menggunakan RoomSchemaArgProvider dengan direktori skema yang ditentukan:

Groovy

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

Kotlin

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

Menguji satu migrasi

Sebelum menguji migrasi, tambahkan artefak androidx.room3:room3-testing ke dependensi pengujian Anda, lalu tambahkan lokasi skema yang diekspor sebagai direktori aset:

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

Paket pengujian menyediakan MigrationTestHelper class, yang dapat membaca file skema yang diekspor. Paket ini juga mengimplementasikan antarmuka JUnit4 TestRule untuk mengelola database yang dibuat.

Contoh berikut menunjukkan pengujian untuk satu migrasi:

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

Menguji semua migrasi

Meskipun Anda dapat menguji satu migrasi inkremental, sebaiknya sertakan pengujian yang mencakup semua migrasi yang ditentukan untuk database aplikasi Anda. Hal ini membantu memastikan agar tidak ada perbedaan antara instance database yang baru dibuat dan instance lama yang mengikuti jalur migrasi yang ditentukan.

Contoh berikut menunjukkan pengujian untuk semua migrasi yang ditentukan:

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

Menangani jalur migrasi yang hilang dengan tepat

Jika Room tidak dapat menemukan jalur migrasi untuk mengupgrade database yang sudah ada di perangkat ke versi saat ini, IllegalStateException akan muncul. Jika hilangnya data yang ada saat jalur migrasi tidak ada dapat diterima, panggil fungsi builder fallbackToDestructiveMigration ketika membuat database:

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

Fungsi ini mengonfigurasi Room untuk membuat ulang tabel secara destruktif di database aplikasi Anda ketika perlu melakukan migrasi inkremental dan tidak ada jalur migrasi yang ditentukan.

Untuk kembali ke pembuatan ulang yang destruktif hanya dalam situasi tertentu, gunakan salah satu alternatif berikut untuk fallbackToDestructiveMigration:

  • Jika versi tertentu histori skema menyebabkan error yang tidak dapat Anda atasi dengan jalur migrasi, gunakan fallbackToDestructiveMigrationFrom sebagai gantinya. Fungsi ini menunjukkan bahwa Anda ingin agar Room menggunakan mode pembuatan ulang yang destruktif hanya saat melakukan migrasi dari versi tertentu.
  • Jika Anda ingin Room menggunakan mode pembuatan ulang yang destruktif hanya saat melakukan migrasi dari versi database yang lebih baru ke versi yang lebih lama, gunakan fallbackToDestructiveMigrationOnDowngrade sebagai gantinya.