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 anotasi @AutoMigration ke properti autoMigrations di @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 {
  ...
}

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 meminta Anda untuk mengimplementasikan 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 statis yang mengimplementasikan AutoMigrationSpec di class RoomDatabase Anda, lalu anotasikan dengan satu atau beberapa hal berikut:

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

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 { }
  ...
}

Jika aplikasi perlu melakukan lebih banyak hal setelah migrasi otomatis selesai, Anda dapat mengimplementasikan onPostMigrate(). Jika Anda mengimplementasikan metode 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 mengetahui cara melakukan pemisahan ini. Dalam kasus seperti 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 metode Migration.migrate(). Tambahkan class Migration ke builder database Anda menggunakan metode 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();

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 room-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 dapat 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 Room dapat membuat versi database yang lebih rendah untuk tujuan pengujian dan mengaktifkan pembuatan migrasi otomatis.

Menetapkan lokasi skema menggunakan Plugin Room Gradle

Jika menggunakan Room versi 2.6.0 atau yang lebih tinggi, Anda dapat menerapkan Plugin Room Gradle dan menggunakan ekstensi room untuk menentukan direktori skema.

Groovy

plugins {
  id 'androidx.room'
}

room {
  schemaDirectory "$projectDir/schemas"
}

Kotlin

plugins {
  id("androidx.room")
}

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

Jika skema database berbeda berdasarkan varian, ragam, atau jenis build, Anda harus menentukan lokasi yang berbeda 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 penjelasan ini lengkap dan mencakup semua varian. Anda juga dapat menyertakan schemaDirectory() tanpa variantMatchName untuk menangani varian yang tidak cocok dengan konfigurasi lainnya. Misalnya, dalam aplikasi dengan dua ragam build demo dan full, serta dua jenis build debug dan release, hal berikut adalah konfigurasi yang valid:

Groovy

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

Menetapkan lokasi skema menggunakan opsi pemroses anotasi

Jika Anda menggunakan Room versi 2.5.2 atau yang lebih lama, atau jika tidak menggunakan Plugin Room Gradle, tetapkan lokasi skema menggunakan opsi pemroses anotasi room.schemaLocation.

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

Pertama, salin class RoomSchemaArgProvider yang ditampilkan di bawah ini ke dalam file build Gradle modul Anda. Metode asArguments() di class contoh akan 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() {
    // 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}")
  }
}

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

Groovy

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

Menguji satu migrasi

Sebelum menguji migrasi, tambahkan artefak Maven androidx.room:room-testing dari Room ke dependensi pengujian Anda, lalu tambahkan lokasi skema yang diekspor sebagai folder aset:

build.gradle

Groovy

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

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

Contoh berikut menunjukkan pengujian untuk satu migrasi:

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

Menguji semua migrasi

Meskipun mungkin untuk menguji satu migrasi inkremental, sebaiknya Anda menyertakan 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:

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

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 data yang sudah ada hilang ketika jalur migrasi hilang, panggil metode builder fallbackToDestructiveMigration() saat Anda membuat database:

Kotlin

Room.databaseBuilder(applicationContext, MyDb::class.java, "database-name")
        .fallbackToDestructiveMigration()
        .build()

Java

Room.databaseBuilder(getApplicationContext(), MyDb.class, "database-name")
        .fallbackToDestructiveMigration()
        .build();

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

Jika Anda hanya ingin Room menggunakan mode pembuatan ulang yang destruktif dalam situasi tertentu, ada beberapa alternatif untuk fallbackToDestructiveMigration():

  • Jika versi tertentu histori skema menyebabkan error yang tidak dapat Anda atasi dengan jalur migrasi, gunakan fallbackToDestructiveMigrationFrom() sebagai gantinya. Metode 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.

Menangani nilai default kolom saat upgrade ke Room 2.2.0

Di Room 2.2.0 dan versi yang lebih tinggi, Anda dapat menentukan nilai default untuk kolom menggunakan anotasi @ColumnInfo(defaultValue = "..."). Pada versi yang lebih lama dari 2.2.0, satu-satunya cara untuk menentukan nilai default untuk kolom adalah dengan menentukannya secara langsung dalam pernyataan SQL yang dijalankan, yang menghasilkan nilai default yang tidak diketahui oleh Room. Hal ini berarti bahwa jika database awalnya dibuat oleh versi Room sebelum 2.2.0, melakukan upgrade aplikasi untuk menggunakan Room 2.2.0 mungkin memerlukan jalur migrasi khusus untuk nilai default yang sudah ada, yang Anda tetapkan tanpa menggunakan Room API.

Sebagai contoh, anggap saja versi 1 database mendefinisikan entity 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;
}

Misalkan versi 2 dari database yang sama menambahkan kolom NOT NULL baru dan menentukan jalur migrasi dari versi 1 ke versi 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 ''");
    }
};

Hal ini menyebabkan perbedaan dalam tabel yang mendasari antara update dan penginstalan aplikasi yang baru. Karena nilai default untuk kolom tag hanya dideklarasikan di jalur migrasi dari versi 1 hingga versi 2, setiap pengguna yang menginstal aplikasi mulai dari versi 2 tidak memiliki nilai default untuk tag dalam skema databasenya.

Pada versi Room yang lebih lama dari 2.2.0, perbedaan ini tidak berbahaya. Namun, jika aplikasi nanti diupgrade untuk menggunakan Room 2.2.0 atau versi yang lebih tinggi dan mengubah class entity Song agar menyertakan nilai default untuk tag menggunakan anotasi @ColumnInfo, Room dapat melihat perbedaan ini. Hal ini menyebabkan kegagalan validasi skema.

Untuk membantu memastikan skema database konsisten di semua pengguna ketika nilai default kolom dideklarasikan dalam jalur migrasi sebelumnya, lakukan hal berikut saat pertama kali Anda mengupgrade aplikasi untuk menggunakan Room versi 2.2.0 atau versi yang lebih baru:

  1. Deklarasikan nilai default kolom di class entity masing-masing menggunakan anotasi @ColumnInfo.
  2. Tingkatkan nomor versi database satu tingkat.
  3. Tentukan jalur migrasi ke versi baru yang mengimplementasikan strategi penurunan dan pembuatan ulang untuk menambahkan nilai default yang diperlukan ke kolom yang ada.

Contoh berikut menunjukkan proses ini:

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