Di chuyển cơ sở dữ liệu Room

Khi thêm và thay đổi các tính năng trong ứng dụng của mình, bạn cần sửa đổi các lớp thực thể Room và bảng cơ sở dữ liệu cơ bản để phản ánh những thay đổi này. Bạn cần lưu giữ dữ liệu người dùng đã có trong cơ sở dữ liệu trên thiết bị khi bản cập nhật ứng dụng thay đổi giản đồ cơ sở dữ liệu.

Room hỗ trợ cả các lựa chọn tự động và thủ công để di chuyển dần dần. Quá trình di chuyển tự động hoạt động với hầu hết các thay đổi cơ bản về giản đồ, nhưng bạn có thể cần phải xác định các đường dẫn di chuyển theo cách thủ công cho những thay đổi phức tạp hơn.

Di chuyển tự động

Để khai báo quá trình di chuyển tự động giữa hai phiên bản cơ sở dữ liệu, hãy thêm chú giải @AutoMigration vào thuộc tính autoMigrations trong @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
}

Thông số kỹ thuật di chuyển tự động

Nếu Room phát hiện các thay đổi giản đồ không rõ ràng và không thể tạo kế hoạch di chuyển nếu không có thêm thông tin, thì ứng dụng sẽ gửi lỗi thời gian biên dịch và bạn phải cung cấp một phương thức triển khai AutoMigrationSpec. Thông thường, trường hợp này xảy ra khi quá trình di chuyển liên quan đến một trong những thao tác sau:

  • Xoá hoặc đổi tên bảng.
  • Xoá hoặc đổi tên cột.

Bạn có thể sử dụng AutoMigrationSpec để cung cấp cho Room những thông tin bổ sung cần thiết nhằm tạo đường dẫn di chuyển chính xác. Xác định một lớp triển khai AutoMigrationSpec trong lớp RoomDatabase của bạn và chú giải lớp đó bằng một hoặc nhiều lệnh sau:

Để sử dụng hoạt động triển khai AutoMigrationSpec cho quá trình di chuyển tự động, hãy đặt thuộc tính spec trong chú giải @AutoMigration tương ứng:

@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

Nếu ứng dụng của bạn cần làm nhiều việc hơn sau khi quá trình di chuyển tự động hoàn tất, bạn có thể triển khai onPostMigrate. Nếu bạn triển khai hàm này trong AutoMigrationSpec, thì Room sẽ gọi hàm này sau khi quá trình di chuyển tự động hoàn tất.

Di chuyển thủ công

Nếu quá trình di chuyển kéo theo những thay đổi phức tạp của giản đồ, có thể Room sẽ không tự động tạo được một đường dẫn di chuyển thích hợp. Ví dụ: Nếu bạn quyết định chia dữ liệu trong một bảng thành hai bảng, thì Room không thể xác định cách thực hiện quá trình chia này. Trong những trường hợp này, bạn phải dùng cách thủ công là triển khai một lớp Migration để xác định đường dẫn di chuyển.

Một lớp Migration xác định rõ một đường dẫn di chuyển giữa startVersionendVersion bằng cách ghi đè hàm migrate. Thêm các lớp Migration vào trình tạo cơ sở dữ liệu bằng hàm addMigrations:

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

Khi xác định đường dẫn di chuyển, bạn có thể sử dụng quá trình di chuyển tự động cho một số phiên bản và quá trình di chuyển thủ công cho những phiên bản khác. Nếu bạn xác định cả một quá trình di chuyển tự động lẫn di chuyển thủ công cho cùng một phiên bản, thì Room sẽ sử dụng quá trình di chuyển thủ công.

Kiểm thử các quá trình di chuyển

Quá trình di chuyển thường phức tạp và nếu được xác định không chính xác có thể khiến ứng dụng của bạn gặp sự cố. Để duy trì độ ổn định của ứng dụng, hãy kiểm thử quá trình di chuyển. Room cung cấp cấu phần phần mềm Maven room3-testing để hỗ trợ quá trình kiểm thử cho cả quá trình di chuyển tự động và thủ công. Để cấu phần phần mềm này hoạt động, trước tiên, bạn phải xuất giản đồ của cơ sở dữ liệu.

Xuất giản đồ

Room xuất thông tin giản đồ cơ sở dữ liệu của bạn thành tệp JSON tại thời gian biên dịch. Các tệp JSON đã xuất sẽ đại diện cho nhật ký giản đồ cơ sở dữ liệu của bạn. Lưu trữ các tệp này trong hệ thống quản lý phiên bản để bạn có thể tạo lại các phiên bản cơ sở dữ liệu cũ hơn cho mục đích kiểm thử và hỗ trợ tự động tạo quá trình di chuyển.

Đặt vị trí giản đồ bằng trình bổ trợ Room cho Gradle

Để chỉ định thư mục giản đồ, hãy áp dụng Trình bổ trợ Room cho Gradle và sử dụng tiện ích room3.

Groovy

plugins {
  id 'androidx.room3'
}

room3 {
  schemaDirectory "$projectDir/schemas"
}

Kotlin

plugins {
  id("androidx.room3")
}

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

Nếu giản đồ cơ sở dữ liệu của bạn khác nhau dựa trên biến thể, phiên bản hoặc loại bản dựng, thì bạn phải chỉ định các vị trí khác nhau bằng cách sử dụng cấu hình schemaDirectory nhiều lần, mỗi lần có một variantMatchName làm đối số đầu tiên. Mỗi cấu hình có thể khớp với một hoặc nhiều biến thể dựa trên phép so sánh đơn giản với tên biến thể.

Đảm bảo rằng các trường hợp này là đầy đủ và bao gồm tất cả các biến thể. Bạn cũng có thể thêm một schemaDirectory() mà không có variantMatchName để xử lý các biến thể không khớp với bất kỳ cấu hình nào khác. Ví dụ: trong một ứng dụng có 2 phiên bản bản dựng demofull cùng 2 loại bản dựng debugrelease, các cấu hình sau đây là hợp lệ:

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

Đặt vị trí giản đồ bằng tuỳ chọn trình xử lý chú giải

Nếu không dùng trình bổ trợ Room cho Gradle, hãy đặt vị trí giản đồ bằng cách sử dụng tuỳ chọn trình xử lý chú giải room.schemaLocation.

Gradle sử dụng các tệp trong thư mục này làm dữ liệu đầu vào và đầu ra cho một số tác vụ Gradle. Để đảm bảo độ chính xác và hiệu suất của các bản dựng gia tăng và lưu trong bộ nhớ đệm, bạn phải sử dụng CommandLineArgumentProvider của Gradle để thông báo cho Gradle về thư mục này.

Trước tiên, hãy sao chép lớp RoomSchemaArgProvider sau vào tệp bản dựng Gradle của mô-đun. Hàm asArguments trong lớp mẫu sẽ truyền room.schemaLocation=${schemaDir.path} đến KSP. Nếu bạn đang sử dụng KAPTjavac, hãy thay đổi giá trị này thành -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}")
  }
}

Sau đó, hãy định cấu hình các lựa chọn biên dịch để sử dụng RoomSchemaArgProvider cùng với thư mục giản đồ được chỉ định:

Groovy

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

Kotlin

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

Kiểm thử một quá trình di chuyển

Để có thể kiểm thử các quy trình di chuyển, hãy thêm cấu phần phần mềm androidx.room3:room3-testing vào các phần phụ thuộc kiểm thử và thêm vị trí của giản đồ đã xuất làm thư mục tài sản:

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

Gói kiểm thử cung cấp một lớp MigrationTestHelper, có thể đọc các tệp giản đồ được xuất. Gói này cũng triển khai giao diện TestRule JUnit4 để quản lý các cơ sở dữ liệu đã tạo.

Ví dụ sau đây minh hoạ hoạt động kiểm thử cho một quá trình di chuyển:

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

Kiểm thử tất cả quá trình di chuyển

Mặc dù có thể kiểm thử một quá trình di chuyển gia tăng, nhưng bạn nên kiểm thử mọi quá trình di chuyển được xác định cho cơ sở dữ liệu của ứng dụng. Việc này giúp đảm bảo rằng không có sự khác biệt giữa phiên bản cơ sở dữ liệu được tạo gần đây và phiên bản cũ hơn đi theo đường dẫn di chuyển đã xác định.

Ví dụ sau đây minh hoạ một hoạt động kiểm thử cho tất cả các quá trình di chuyển đã xác định:

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

Xử lý linh hoạt đường dẫn di chuyển bị thiếu

Nếu Room không tìm thấy đường dẫn di chuyển để nâng cấp cơ sở dữ liệu hiện có trên thiết bị lên phiên bản hiện tại, thì IllegalStateException sẽ xảy ra. Nếu bạn có thể chấp nhận mất dữ liệu hiện có khi thiếu đường dẫn di chuyển, hãy gọi hàm trình tạo fallbackToDestructiveMigration khi bạn tạo cơ sở dữ liệu:

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

Hàm này định cấu hình Room để tạo lại toàn bộ các bảng trong cơ sở dữ liệu của ứng dụng (có thể gây thiệt hại) khi cần thực hiện quá trình di chuyển gia tăng và không có đường dẫn di chuyển đã xác định nào.

Để chỉ tìm đến hoạt động tạo lại gây thiệt hại trong một số tình huống nhất định, hãy sử dụng một trong các phương án thay thế sau cho fallbackToDestructiveMigration:

  • Nếu các phiên bản cụ thể của nhật ký giản đồ gây ra các lỗi mà bạn không thể giải quyết bằng đường dẫn di chuyển, hãy sử dụng fallbackToDestructiveMigrationFrom thay thế. Hàm này cho biết rằng bạn chỉ muốn Room tìm đến hoạt động tạo lại gây thiệt hại khi di chuyển từ các phiên bản cụ thể.
  • Nếu bạn chỉ muốn Room tìm đến hoạt động tạo lại gây thiệt hại khi di chuyển từ phiên bản cơ sở dữ liệu cao hơn sang phiên bản cơ sở dữ liệu thấp hơn, hãy sử dụng fallbackToDestructiveMigrationOnDowngrade thay thế.