שמירת נתונים במסד נתונים מקומי באמצעות Room 2.x

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

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

הגדרת Room 2.x

כדי להשתמש ב-Room 2.x באפליקציה, מוסיפים את יחסי התלות הבאים לקובץ build.gradle של האפליקציה:

dependencies {
    val room_version = "2.6.1"

    implementation("androidx.room:room-runtime:$room_version")
    annotationProcessor("androidx.room:room-compiler:$room_version")

    // To use Kotlin Symbol Processing (KSP)
    // ksp("androidx.room:room-compiler:$room_version")

    // optional - Kotlin Extensions and Coroutines support for Room
    implementation("androidx.room:room-ktx:$room_version")

    // optional - RxJava2 support for Room
    implementation("androidx.room:room-rxjava2:$room_version")

    // optional - Guava support for Room, including Optional and ListenableFuture
    implementation("androidx.room:room-guava:$room_version")

    // optional - Test helpers
    testImplementation("androidx.room:room-testing:$room_version")
}

רכיבים ראשיים

ל-Room יש שלושה רכיבים עיקריים:

  • Database class שמכיל את מסד הנתונים ומשמש כנקודת הגישה הראשית לחיבור הבסיסי לנתונים הקבועים של האפליקציה.
  • ישויות נתונים שמייצגות טבלאות במסד הנתונים של האפליקציה.
  • אובייקטים של גישה לנתונים (DAO) שמספקים שיטות שהאפליקציה יכולה להשתמש בהן כדי לשלוח שאילתות, לעדכן, להוסיף ולמחוק נתונים במסד הנתונים.

איור 1 ממחיש את הקשר בין הרכיבים השונים של Room.

איור 1. דיאגרמה של ארכיטקטורת ספריית החדרים.

דוגמה להטמעה

// Entity
@Entity
data class User(
    @PrimaryKey val uid: Int,
    @ColumnInfo(name = "first_name") val firstName: String?,
    @ColumnInfo(name = "last_name") val lastName: String?
)

// DAO
@Dao
interface UserDao {
    @Query("SELECT * FROM user")
    fun getAll(): List<User>

    @Insert
    fun insertAll(vararg users: User)

    @Delete
    fun delete(user: User)
}

// Database
@Database(entities = [User::class], version = 1)
abstract class AppDatabase : RoomDatabase() {
    abstract fun userDao(): UserDao
}

// Usage
val db = Room.databaseBuilder(
            applicationContext,
            AppDatabase::class.java, "database-name"
        ).build()

val userDao = db.userDao()
val users: List<User> = userDao.getAll()

הגדרת נתונים באמצעות ישויות

כל ישות Room מייצגת טבלה במסד הנתונים. אתם מגדירים כל ישות בתור מחלקה עם ההערה @Entity.

@Entity(tableName = "users")
data class User (
    @PrimaryKey val id: Int,
    @ColumnInfo(name = "first_name") val firstName: String?,
    @ColumnInfo(name = "last_name") val lastName: String?,
    @Ignore val picture: Bitmap? = null
)
  • שמות מותאמים אישית של טבלאות ועמודות: כברירת מחדל, Room משתמש בשם המחלקה כשם הטבלה ובשמות המאפיינים כשמות העמודות. כדי להתאים אישית את ההגדרות, משתמשים במאפיין tableName ב-@Entity ובאנוטציה @ColumnInfo(name = "...").
  • מפתח ראשי: כדי להגדיר מפתח ראשי, משתמשים ב-@PrimaryKey. למפתחות מורכבים, משתמשים במאפיין primaryKeys של @Entity: @Entity(primaryKeys = ["firstName", "lastName"]).
  • התעלמות משדות: כדי למנוע שמירה של שדות, משתמשים ב-@Ignore.

ממירי סוגים

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

class Converters {
  @TypeConverter
  fun fromTimestamp(value: Long?): Date? = value?.let { Date(it) }

  @TypeConverter
  fun dateToTimestamp(date: Date?): Long? = date?.time
}

// Register in your Database class
@Database(entities = [User::class], version = 1)
@TypeConverters(Converters::class)
abstract class AppDatabase : RoomDatabase() { ... }

גישה לנתונים באמצעות DAO

‫DAOs מגדירים שיטות לאינטראקציה עם מסד נתונים. הוספת הערה לממשק או למחלקה מופשטת באמצעות @Dao.

שיטות נוחות

@Dao
interface UserDao {
    @Insert(onConflict = OnConflictStrategy.REPLACE)
    fun insertUsers(vararg users: User)

    @Update
    fun updateUsers(vararg users: User)

    @Delete
    fun deleteUsers(vararg users: User)
}
  • insert: הפונקציה insert יכולה להחזיר Long שמייצג את מזהה השורה שנוספה, או List<Long> שמכיל את המזהים של כל השורות שנוספו.
  • Update or Delete: The update or delete method can return an Int representing the number of affected rows.

שיטות שאילתה

מוסיפים הערות לשיטות באמצעות @Query כדי לכתוב הצהרות SQL. הספרייה Room מאמתת שאילתות בזמן ההידור.

@Dao
interface UserDao {
    // Simple query
    @Query("SELECT * FROM user")
    fun loadAllUsers(): Array<User>

    // Return a subset of columns using a POJO or tuple
    @Query("SELECT first_name, last_name FROM user")
    fun loadFullName(): List<NameTuple>

    // Pass parameters
    @Query("SELECT * FROM user WHERE age > :minAge")
    fun loadAllUsersOlderThan(minAge: Int): Array<User>

    // Collection of parameters
    @Query("SELECT * FROM user WHERE region IN (:regions)")
    fun loadUsersFromRegions(regions: List<String>): List<User>

    // Join tables
    @Query("SELECT * FROM book INNER JOIN user ON user.id = book.user_id WHERE user.name = :userName")
    fun findBooksBorrowedByName(userName: String): List<Book>
}

סוגי הערכים שמוחזרים על ידי Multimap

ב-Room 2.4 ואילך, שיטות שאילתה יכולות להחזיר ישירות מפה מרובה באמצעות הסוג Map:

@Query("SELECT * FROM user JOIN book ON user.id = book.user_id")
fun loadUserAndBookNames(): Map<User, List<Book>>

שאילתות אסינכרוניות של DAO

כדי למנוע קפיאות בממשק המשתמש, אי אפשר להריץ שאילתות במסד הנתונים ב-thread הראשי. אפשר להפוך את השאילתות לאסינכרוניות באמצעות אחד מהשילובים הבאים:

שגרות המשך (coroutines) ו-Flow ב-Kotlin

נדרשת התלות room-ktx.

@Dao
interface UserDao {
    // One-shot async query
    @Insert
    suspend fun insertUsers(vararg users: User)

    // Observable query using Flow
    @Query("SELECT * FROM user WHERE id = :id")
    fun loadUserById(id: Int): Flow<User>
}

‫Java עם RxJava

צריך לציין room-rxjava2 או room-rxjava3.

@Dao
interface UserDao {
    @Insert
    fun insertUsers(users: List<User>): Completable

    @Query("SELECT * FROM user WHERE id = :id")
    fun loadUserById(id: Int): Flowable<User>
}

‫Java עם LiveData ו-Guava

נדרש room-guava עבור ListenableFuture.

@Dao
interface UserDao {
    // LiveData for observable queries
    @Query("SELECT * FROM user WHERE id = :id")
    fun loadUserById(id: Int): LiveData<User>

    // Guava ListenableFuture for one-shot queries
    @Insert
    fun insertUsers(users: List<User>): ListenableFuture<Integer>
}

הגדרת קשרי גומלין ב-Room 2.x

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

אחד על אחד

לכל משתמש יש רק ספרייה אחת.

@Entity
data class User(@PrimaryKey val userId: Long, val name: String)

@Entity
data class Library(@PrimaryKey val libraryId: Long, val userOwnerId: Long)

// Intermediate class
data class UserAndLibrary(
    @Embedded val user: User,
    @Relation(
         parentColumn = "userId",
         entityColumn = "userOwnerId"
    )
    val library: Library
)

// DAO Query
@Transaction
@Query("SELECT * FROM User")
fun getUsersAndLibraries(): List<UserAndLibrary>

אחד לרבים

לכל משתמש יכולים להיות הרבה פלייליסטים.

@Entity
data class Playlist(@PrimaryKey val playlistId: Long, val userCreatorId: Long)

data class UserWithPlaylists(
    @Embedded val user: User,
    @Relation(
          parentColumn = "userId",
          entityColumn = "userCreatorId"
    )
    val playlists: List<Playlist>
)

רבים לרבים

פלייליסטים יכולים לכלול הרבה שירים, ושירים יכולים להיכלל בהרבה פלייליסטים. נדרשת טבלת צמתים.

@Entity
data class Song(@PrimaryKey val songId: Long, val songName: String)

@Entity(primaryKeys = ["playlistId", "songId"])
data class PlaylistSongCrossRef(val playlistId: Long, val songId: Long)

data class PlaylistWithSongs(
    @Embedded val playlist: Playlist,
    @Relation(
         parentColumn = "playlistId",
         entityColumn = "songId",
         associateBy = Junction(PlaylistSongCrossRef::class)
    )
    val songs: List<Song>
)

קשרים מוטמעים

שאילתת משתמשים, הפלייליסטים שלהם וכל השירים בפלייליסטים האלה.

data class UserWithPlaylistsAndSongs(
      @Embedded val user: User,
      @Relation(
          entity = Playlist::class,
          parentColumn = "userId",
          entityColumn = "userCreatorId"
      )
      val playlists: List<PlaylistWithSongs> // Nesting PlaylistWithSongs
  )

ניהול מסדי נתונים

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

תצוגות של מסדי נתונים

לעטוף שאילתה מורכבת בכיתה עם ההערה @DatabaseView.

@DatabaseView("SELECT user.id, user.name, department.name AS departmentName FROM user INNER JOIN department ON user.departmentId = department.id")
data class UserDetail(val id: Long, val name: String, val departmentName: String)

// Register in Database class
@Database(entities = [User::class], views = [UserDetail::class], version = 1)
abstract class AppDatabase : RoomDatabase() { ... }

מילוי מראש של מסד הנתונים

מאכלסים את מסד הנתונים במהלך האתחול מקובץ נכס או ממערכת הקבצים.

Room.databaseBuilder(appContext, AppDatabase::class.java, "Sample.db")
    .createFromAsset("database/myapp.db")
    .build()

מיגרציות

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

val MIGRATION_1_2 = object : Migration(1, 2) {
  override fun migrate(database: SupportSQLiteDatabase) {
    database.execSQL("ALTER TABLE User ADD COLUMN age INTEGER NOT NULL DEFAULT 0")
  }
}

Room.databaseBuilder(applicationContext, AppDatabase::class.java, "database-name")
  .addMigrations(MIGRATION_1_2)
  .build()
  • העברות אוטומטיות: אם אתם משתמשים ב-Room בגרסה 2.4.0 ואילך, אתם יכולים להשתמש ב-@AutoMigration כדי להעביר באופן אוטומטי שינויים בסיסיים בסכימה. כדי להשתמש בו, צריך להגדיר את exportSchema ל-true בהגדרות מסד הנתונים: @Database(version = 2, autoMigrations = [AutoMigration(from = 1, to = 2)]).
  • חזרה הרסנית למצב קודם: אם מקובל לאבד נתונים כשנתיבי ההעברה חסרים, צריך להתקשר אל .fallbackToDestructiveMigration כשיוצרים את מסד הנתונים.

בדיקת העברות

כדי לאמת העברות, משתמשים ב-MigrationTestHelper מתוך ארטיפקט room-testing. כדי לתמוך בזה, צריך לוודא שמייצאים סכימות בהגדרה של build.gradle.

@RunWith(AndroidJUnit4::class)
class MigrationTest {
    @get:Rule
    val helper: MigrationTestHelper = MigrationTestHelper(
            InstrumentationRegistry.getInstrumentation(),
            AppDatabase::class.java.canonicalName,
            FrameworkSQLiteOpenHelperFactory()
    )

    @Test
    fun migrate1To2() {
        var db = helper.createDatabase("test-db", 1).apply {
            execSQL("INSERT INTO User VALUES (1, 'John')")
            close()
        }
        db = helper.runMigrationsAndValidate("test-db", 2, true, MIGRATION_1_2)
        // Verify data was migrated correctly
    }
}

העברה מ-SQLite ל-Room

כדי להעביר את האפליקציה מ-SQLite ל-Room, פועלים לפי השלבים הבאים:

  1. מעדכנים את התלויות כדי לכלול את Room.
  2. מוסיפים הערות למחלקות של המודל באמצעות @Entity, @PrimaryKey ו-@ColumnInfo.
  3. יוצרים אובייקטים של DAO כדי להחליף את שיטות השאילתות של העוזר.
  4. יוצרים מחלקה RoomDatabase שמפנה לישויות ול-DAO. מגדילים את מספר הגרסה.
  5. הגדרת נתיב העברה ריק כי הסכימה לא משתנה, רק המסגרת: kotlin val MIGRATION_1_2 = object : Migration(1, 2) { override fun migrate(database: SupportSQLiteDatabase) {} }
  6. מעדכנים את יצירת המופע כדי להשתמש ב-Room.databaseBuilder עם נתיב ההעברה.