انتقال پایگاه داده Room

هم‌زمان با افزودن و تغییر دادن ویژگی‌ها در برنامه‌تان، باید کلاس‌های نهاد 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
}

مشخصات انتقال خودکار

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

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

می‌توانید از AutoMigrationSpec برای ارائه اطلاعات تکمیلی به «اتاق» استفاده کنید تا بتواند مسیرهای انتقال را به‌درستی تولید کند. کلاسی را تعریف کنید که 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 نمی‌تواند تعیین کند که چگونه این تقسیم را انجام دهد. در این شرایط، باید با پیاده‌سازی کلاس Migration، مسیر انتقال را به‌صورت دستی تعریف کنید.

کلاس Migration به‌طور صریح مسیر انتقال بین startVersion و endVersion را با ملغی کردن تابع migrate تعریف می‌کند. بااستفاده از تابع addMigrations، کلاس‌های Migration خود را به سازنده پایگاه داده اضافه کنید:

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

برای مشخص کردن دایرکتوری طرح‌واره، افزایه 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 استفاده می‌کند. برای صحت و عملکرد ساخت‌های افزایشی و ذخیره‌شده در حافظه نهان، باید از 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 با دایرکتوری طرحواره مشخص‌شده استفاده کنید:

شیک

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

کاتلین

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

آزمایش کردن یک انتقال

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

شیک

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

کاتلین

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

بسته آزمایش کلاسی به‌نام 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()

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

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

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