ذخیره داده‌ها در یک پایگاه داده محلی با استفاده از Room 2.x

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

کتابخانه‌ی Room persistence یک لایه‌ی انتزاعی روی SQLite فراهم می‌کند تا به شما امکان دسترسی روان به پایگاه داده را بدهد و در عین حال از تمام قدرت SQLite بهره ببرد.

راه‌اندازی اتاق ۲.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")
}

اجزای اولیه

اتاق سه جزء اصلی دارد:

  • کلاس پایگاه داده که پایگاه داده را در خود نگه می‌دارد و به عنوان نقطه دسترسی اصلی برای اتصال اساسی به داده‌های دائمی برنامه شما عمل می‌کند.
  • موجودیت‌های داده‌ای که جداول موجود در پایگاه داده برنامه شما را نشان می‌دهند.
  • اشیاء دسترسی به داده (DAO) که روش‌هایی را ارائه می‌دهند که برنامه شما می‌تواند برای پرس‌وجو، به‌روزرسانی، درج و حذف داده‌ها در پایگاه داده از آنها استفاده کند.

شکل ۱ رابطه بین اجزای مختلف Room را نشان می‌دهد.

شکل ۱. نمودار معماری کتابخانه اتاقی.

پیاده‌سازی نمونه

// 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 ، را در یک ستون ذخیره کنید. برای تبدیل انواع داده سفارشی به و از انواع داده‌ای که Room می‌تواند ذخیره کند، از متدهای @TypeConverter استفاده کنید.

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ها

DAOها متدهایی برای تعامل با پایگاه داده تعریف می‌کنند. رابط یا کلاس انتزاعی را با @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)
}
  • درج : متد درج می‌تواند یک Long که نشان‌دهنده شناسه ردیف درج‌شده است، یا یک List<Long> حاوی شناسه‌های همه ردیف‌های درج‌شده را برگرداند.
  • به‌روزرسانی یا حذف : متدهای به‌روزرسانی یا حذف می‌توانند یک Int را برگردانند که نشان‌دهنده تعداد سطرهای تحت تأثیر است.

روش‌های پرس‌وجو

متدها را با @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>
}

انواع بازگشتی چندنقشه‌ای

در اتاق ۲.۴ و بالاتر، متدهای پرس‌وجو می‌توانند مستقیماً با استفاده از نوع Map یک نقشه چندگانه (multimap) را برگردانند:

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

پرس‌وجوهای ناهمزمان DAO

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

کوروتین‌ها و جریان کاتلین

به وابستگی 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>
}

جاوا با 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>
}

جاوا با LiveData و Guava

برای ListenableFuture room-guava نیاز است.

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

تعریف روابط در اتاق ۲.x

برای جلوگیری از بارگذاری کند (lazy loading) در نخ رابط کاربری (UI thread)، نمی‌توانید از ارجاع مستقیم به اشیاء بین موجودیت‌ها استفاده کنید. در عوض، روابط را با استفاده از کلاس‌های داده میانی با @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 با مسیر مهاجرت استفاده کند.