پایگاه داده اتاق خود را مهاجرت کنید

همانطور که ویژگی‌هایی را به برنامه خود اضافه و تغییر می‌دهید، باید کلاس‌های موجودیت Room و جداول پایگاه داده اصلی خود را برای انعکاس این تغییرات تغییر دهید. حفظ داده‌های کاربر که از قبل در پایگاه داده دستگاه وجود دارد، هنگامی که به‌روزرسانی برنامه، طرحواره پایگاه داده را تغییر می‌دهد، مهم است.

روم از هر دو گزینه خودکار و دستی برای مهاجرت تدریجی پشتیبانی می‌کند. مهاجرت‌های خودکار برای اکثر تغییرات اساسی طرحواره کار می‌کنند، اما ممکن است برای تغییرات پیچیده‌تر نیاز به تعریف دستی مسیرهای مهاجرت داشته باشید.

مهاجرت‌های خودکار

برای اعلام یک مهاجرت خودکار بین دو نسخه از پایگاه داده، یک حاشیه‌نویسی @AutoMigration به ویژگی autoMigrations در @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
}

مشخصات مهاجرت خودکار

اگر Room تغییرات مبهم در طرحواره را تشخیص دهد و نتواند بدون ورودی بیشتر، طرح مهاجرت ایجاد کند، خطای زمان کامپایل ایجاد می‌کند و شما باید پیاده‌سازی AutoMigrationSpec را ارائه دهید. معمولاً این اتفاق زمانی می‌افتد که مهاجرت شامل یکی از موارد زیر باشد:

  • حذف یا تغییر نام جدول
  • حذف یا تغییر نام یک ستون

شما می‌توانید از AutoMigrationSpec برای ارائه اطلاعات اضافی به Room که برای تولید صحیح مسیرهای مهاجرت نیاز دارد، استفاده کنید. کلاسی را تعریف کنید که AutoMigrationSpec را در کلاس RoomDatabase شما پیاده‌سازی کند و آن را با یک یا چند مورد از موارد زیر حاشیه‌نویسی کنید:

برای استفاده از پیاده‌سازی AutoMigrationSpec برای یک مهاجرت خودکار، ویژگی spec را در حاشیه‌نویسی مربوطه @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

اگر برنامه شما پس از اتمام مهاجرت خودکار نیاز به انجام کارهای بیشتری دارد، می‌توانید onPostMigrate پیاده‌سازی کنید. اگر این تابع را در AutoMigrationSpec خود پیاده‌سازی کنید، Room پس از اتمام مهاجرت خودکار آن را فراخوانی می‌کند.

مهاجرت‌های دستی

اگر یک مهاجرت شامل تغییرات پیچیده در طرحواره باشد، Room ممکن است نتواند مسیر مهاجرت مناسبی را به طور خودکار ایجاد کند. برای مثال، اگر تصمیم بگیرید داده‌های یک جدول را به دو جدول تقسیم کنید، Room نمی‌تواند نحوه انجام این تقسیم را تعیین کند. در این شرایط، باید با پیاده‌سازی یک کلاس Migration ، مسیر مهاجرت را به صورت دستی تعریف کنید.

یک کلاس Migration با بازنویسی تابع migrate ، مسیر مهاجرت بین startVersion و endVersion را به طور صریح تعریف می‌کند. کلاس‌های Migration خود را با استفاده از تابع 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()

وقتی مسیرهای مهاجرت خود را تعریف می‌کنید، می‌توانید برای برخی نسخه‌ها از مهاجرت‌های خودکار و برای برخی دیگر از مهاجرت‌های دستی استفاده کنید. اگر برای یک نسخه هم مهاجرت خودکار و هم مهاجرت دستی تعریف کنید، Room از مهاجرت دستی استفاده می‌کند.

مهاجرت‌های آزمایشی

مهاجرت‌ها اغلب پیچیده هستند و یک مهاجرت نادرست تعریف شده می‌تواند باعث از کار افتادن برنامه شما شود. برای حفظ پایداری برنامه خود، مهاجرت‌های خود را آزمایش کنید. Room یک مصنوع room3-testing Maven ارائه می‌دهد تا به فرآیند آزمایش برای مهاجرت‌های خودکار و دستی کمک کند. برای اینکه این مصنوع کار کند، ابتدا باید طرحواره پایگاه داده خود را صادر کنید.

طرحواره‌های خروجی

Room اطلاعات طرحواره پایگاه داده شما را در زمان کامپایل به یک فایل JSON صادر می‌کند. فایل‌های JSON صادر شده، تاریخچه طرحواره پایگاه داده شما را نشان می‌دهند. این فایل‌ها را در سیستم کنترل نسخه خود ذخیره کنید تا بتوانید نسخه‌های پایین‌تر پایگاه داده را برای آزمایش و پشتیبانی از تولید مهاجرت خودکار، دوباره ایجاد کنید.

تنظیم مکان طرحواره با استفاده از افزونه Room Gradle

برای مشخص کردن دایرکتوری schema، افزونه Room Gradle را اعمال کنید و از افزونه room3 استفاده کنید.

گرووی

plugins {
  id 'androidx.room3'
}

room3 {
  schemaDirectory "$projectDir/schemas"
}

کاتلین

plugins {
  id("androidx.room3")
}

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

اگر طرحواره پایگاه داده شما بر اساس نوع، طعم یا نوع ساخت متفاوت است، باید مکان‌های مختلف را با استفاده چندین بار از پیکربندی schemaDirectory مشخص کنید، که هر کدام یک variantMatchName به عنوان اولین آرگومان دارند. هر پیکربندی می‌تواند بر اساس مقایسه ساده با نام نوع، یک یا چند نوع را مطابقت دهد.

مطمئن شوید که این موارد جامع هستند و همه متغیرها را پوشش می‌دهند. همچنین می‌توانید یک schemaDirectory() بدون variantMatchName برای مدیریت متغیرهایی که با هیچ یک از پیکربندی‌های دیگر مطابقت ندارند، اضافه کنید. به عنوان مثال، در برنامه‌ای با دو نوع ساخت demo و full و دو نوع ساخت debug و release ، پیکربندی‌های معتبر زیر وجود دارند:

گرووی

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

کاتلین

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

تنظیم مکان طرحواره با استفاده از گزینه پردازنده حاشیه نویسی

اگر از افزونه Room Gradle استفاده نمی‌کنید، مکان طرحواره را با استفاده از گزینه پردازنده حاشیه‌نویسی room.schemaLocation تنظیم کنید.

Gradle از فایل‌های موجود در این دایرکتوری به عنوان ورودی و خروجی برای برخی از وظایف Gradle استفاده می‌کند. برای صحت و عملکرد buildهای افزایشی و cache شده، باید CommandLineArgumentProvider مربوط به Gradle برای اطلاع‌رسانی به Gradle در مورد این دایرکتوری استفاده کنید.

ابتدا، کلاس RoomSchemaArgProvider زیر را در فایل ساخت Gradle ماژول خود کپی کنید. تابع asArguments در کلاس نمونه room.schemaLocation=${schemaDir.path} را به KSP ارسال می‌کند. اگر KAPT و javac استفاده می‌کنید، این مقدار را به -Aroom.schemaLocation=${schemaDir.path} تغییر دهید.

گرووی

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

کاتلین

class RoomSchemaArgProvider(
  @get:InputDirectory
  @get:PathSensitive(PathSensitivity.RELATIVE)
  val schemaDir: File
) : CommandLineArgumentProvider {

  override fun asArguments(): Iterable<String> {
    return listOf("room.schemaLocation=${schemaDir.path}")
  }
}

سپس گزینه‌های کامپایل را برای استفاده از RoomSchemaArgProvider با دایرکتوری schema مشخص شده پیکربندی کنید:

گرووی

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

کاتلین

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

یک مهاجرت واحد را آزمایش کنید

قبل از اینکه بتوانید مهاجرت‌های خود را آزمایش کنید، فایل androidx.room3:room3-testing را به وابستگی‌های آزمایشی خود اضافه کنید و مکان طرحواره‌ی اکسپورت شده را به عنوان دایرکتوری asset اضافه کنید:

گرووی

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

کاتلین

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

بسته‌ی تست، یک کلاس MigrationTestHelper ارائه می‌دهد که می‌تواند فایل‌های طرحواره‌ی خروجی گرفته شده را بخواند. این بسته همچنین رابط JUnit4 TestRule را برای مدیریت پایگاه‌های داده‌ی ایجاد شده پیاده‌سازی می‌کند.

مثال زیر تستی را برای یک مهاجرت واحد نشان می‌دهد:

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

آزمایش همه مهاجرت‌ها

اگرچه می‌توانید یک مهاجرت افزایشی واحد را آزمایش کنید، اما باید آزمایشی را در نظر بگیرید که تمام مهاجرت‌های تعریف‌شده برای پایگاه داده برنامه شما را پوشش دهد. این کار به اطمینان از عدم وجود اختلاف بین یک نمونه پایگاه داده اخیراً ایجاد شده و یک نمونه قبلی که مسیرهای مهاجرت تعریف‌شده را دنبال کرده است، کمک می‌کند.

مثال زیر آزمایشی را برای همه مهاجرت‌های تعریف‌شده نشان می‌دهد:

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

مسیرهای مهاجرت از دست رفته را با ظرافت مدیریت کنید

اگر Room نتواند مسیر مهاجرتی برای ارتقاء پایگاه داده موجود در دستگاه به نسخه فعلی پیدا کند، خطای IllegalStateException رخ می‌دهد. اگر از دست دادن داده‌های موجود در صورت عدم وجود مسیر مهاجرت قابل قبول است، هنگام ایجاد پایگاه داده، تابع سازنده fallbackToDestructiveMigration را فراخوانی کنید:

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

این تابع Room را طوری پیکربندی می‌کند که وقتی نیاز به انجام یک مهاجرت افزایشی است و هیچ مسیر مهاجرت تعریف‌شده‌ای وجود ندارد، جداول موجود در پایگاه داده برنامه شما را به صورت مخرب از نو ایجاد کند.

برای اینکه فقط در شرایط خاص به حالت مخرب برگردید، از یکی از جایگزین‌های زیر برای fallbackToDestructiveMigration استفاده کنید:

  • اگر نسخه‌های خاصی از تاریخچه طرحواره شما باعث خطاهایی می‌شوند که نمی‌توانید با مسیرهای مهاجرت آنها را حل کنید، به جای آن fallbackToDestructiveMigrationFrom استفاده کنید. این تابع نشان می‌دهد که می‌خواهید Room فقط هنگام مهاجرت از نسخه‌های خاص، به حالت تخریبی بازگردد.
  • اگر می‌خواهید Room فقط هنگام مهاجرت از نسخه پایگاه داده بالاتر به نسخه پایین‌تر، به حالت تخریبی بازگردد، به جای آن fallbackToDestructiveMigrationOnDowngrade استفاده کنید.