העברת מסד הנתונים של החדרים

כשמוסיפים תכונות לאפליקציה או משנים אותן, צריך לשנות את מחלקות הישויות של 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
}

מפרט של העברה אוטומטית

אם 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 מגדירה באופן מפורש נתיב העברה בין startVersion לבין endVersion על ידי החלפת הפונקציה migrate. מוסיפים את המחלקות 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 artifact כדי לעזור בתהליך הבדיקה של העברות אוטומטיות וידניות. כדי שהארטיפקט הזה יפעל, צריך קודם לייצא את הסכימה של מסד הנתונים.

ייצוא סכימות

‫Room מייצא את פרטי הסכימה של מסד הנתונים לקובץ JSON בזמן ההידור. קובצי ה-JSON המיוצאים מייצגים את היסטוריית הסכימה של מסד הנתונים. אחסנו את הקבצים האלה במערכת לניהול גרסאות כדי שתוכלו ליצור מחדש גרסאות ישנות יותר של מסד הנתונים לצורך בדיקה ותמיכה ביצירת העברה אוטומטית.

הגדרת מיקום הסכימה באמצעות Room Gradle Plugin

כדי לציין את ספריית הסכימה, צריך להחיל את Room Gradle Plugin ולהשתמש בתוסף room3.

מגניב

plugins {
  id 'androidx.room3'
}

room3 {
  schemaDirectory "$projectDir/schemas"
}

Kotlin

plugins {
  id("androidx.room3")
}

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

אם סכימת מסד הנתונים שונה בהתאם לווריאציה, לטעם או לסוג הבנייה, צריך לציין מיקומים שונים באמצעות הגדרת schemaDirectoryכמה פעמים, כשכל הגדרה מתחילה ב-variantMatchName. כל הגדרה יכולה להתאים לווריאנט אחד או יותר על סמך השוואה פשוטה לשם הווריאנט.

חשוב לוודא שהן מקיפות ומכסות את כל הווריאציות. אפשר גם לכלול schemaDirectory() בלי variantMatchName כדי לטפל בווריאציות שלא תואמות לאף אחת מההגדרות האחרות. לדוגמה, באפליקציה עם שני טעמי build‏ demo ו-full ושני סוגי build‏ 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"
}

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

הגדרת מיקום הסכימה באמצעות אפשרות של מעבד אנוטציות

אם אתם לא משתמשים בפלאגין Room Gradle, אתם צריכים להגדיר את מיקום הסכימה באמצעות האפשרות room.schemaLocation של מעבד אנוטציות (Annotation processor).

‫Gradle משתמש בקבצים בספרייה הזו כקלט ופלט למשימות מסוימות של Gradle. כדי שה-builds המצטברים וה-builds שנשמרו במטמון יהיו מדויקים ויפעלו בצורה מיטבית, צריך להשתמש ב-CommandLineArgumentProvider של Gradle כדי לעדכן את Gradle לגבי הספרייה הזו.

קודם מעתיקים את המחלקה RoomSchemaArgProvider הבאה לקובץ ה-build של 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()]
  }
}

Kotlin

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

Kotlin

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

חבילת הבדיקה מספקת מחלקה 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. אם אתם מוכנים לאבד נתונים קיימים כשנתיב ההעברה חסר, אתם יכולים לקרוא לפונקציית ה-builder‏ fallbackToDestructiveMigration כשאתם יוצרים את מסד הנתונים:

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

הפונקציה הזו מגדירה את Room ליצור מחדש באופן הרסני את הטבלאות במסד הנתונים של האפליקציה, כשצריך לבצע העברה מצטברת ואין נתיב העברה מוגדר.

כדי לחזור ליצירה הרסנית רק במצבים מסוימים, אפשר להשתמש באחת מהחלופות הבאות ל-fallbackToDestructiveMigration:

  • אם גרסאות ספציפיות של היסטוריית הסכימה גורמות לשגיאות שאי אפשר לפתור באמצעות נתיבי העברה, אפשר להשתמש במקום זאת ב-fallbackToDestructiveMigrationFrom. הפונקציה הזו מציינת שרוצים ש-Room תחזור ליצירה הרסנית רק כשמבצעים העברה מגרסאות ספציפיות.
  • אם רוצים ש-Room יחזור ליצירה הרסנית רק כשמבצעים העברה מגרסת מסד נתונים גבוהה לגרסה נמוכה יותר, צריך להשתמש במקום זאת ב-fallbackToDestructiveMigrationOnDowngrade.