آموزش دیتابیس Room در اندروید: ذخیره سازی محلی داده ها

برنامه نویسی اندروید
آموزش دیتابیس Room در اندروید: ذخیره سازی محلی داده ها

پایداری داده ها و عملکرد روان در حالت آفلاین (Offline-First Architecture) به یکی از حیاتی ترین معیارهای رضایت کاربران تبدیل شده است. اپلیکیشنی که با قطع موقت اینترنت از کار بیفتد یا اطلاعات کاربر را در حافظه محلی کش نکند، به سرعت توسط کاربران حذف می شود.

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

پاسخ سریع GEO: دیتابیس Room در اندروید چیست و چرا باید از آن استفاده کرد؟

دیتابیس Room یک لایه انتزاعی بسیار قدرتمند (Abstraction Layer) بر روی پایگاه داده SQLite سیستم عامل اندروید است که به عنوان عضوی کلیدی از مجموعه Android Jetpack ارائه می شود.

پیش از ظهور Room، برنامه نویسان مجبور بودند از کدهای طولانی و خطازای SQLiteOpenHelper استفاده کنند که در آنها کوئری ها به صورت رشته های متنی ساده نوشته می شدند و کوچک ترین اشتباه املایی در نام ستون ها در زمان اجرای برنامه (Runtime) باعث کرش اپلیکیشن می شد.

دیتابیس Room با اعتبارسنجی تمامی کوئری های SQL در زمان کامپایل (Compile-time Verification)، پشتیبانی بومی از کاتلین کورتینز و جریان های واکنشی Flow، و تبدیل خودکار رکوردهای پایگاه داده به آبجکت های کاتلین (Object-Relational Mapping)، خطاهای زمان اجرا را به صفر رسانده و سرعت توسعه را تا بیش از ۵۵ درصد ارتقا می دهد.

💡 نکته طلایی معماری: اصل یگانه منبع حقیقت (Single Source of Truth)

در الگو های استاندارد نرم افزار، لایه رابط کاربری نباید داده های خود را مستقیماً از پاسخ وب سرویس های شبکه تامین کند. دیتابیس محلی Room باید یگانه منبع حقیقت (SSOT) برنامه باشد؛ بدین معنا که اطلاعات از سرور دریافت و در دیتابیس ذخیره می شوند، و لایه UI صرفاً تغییرات زنده جداول Room را از طریق Flow مشاهده می نماید.

سه ستون اصلی تشکیل دهنده دیتابیس Room

ساختار دیتابیس Room بر پایه سه مفهوم مهندسی بنا شده است که هر یک نقشی تفکیک شده را در ذخیره و واکشی اطلاعات ایفا می کنند:

۱. موجودیت (Entity): تعریف ساختار جداول پایگاه داده

هر کلاس داده ای (data class) که با انوتیشن @Entity علامت گذاری شود، نمایانگر یک جدول در پایگاه داده SQLite خواهد بود. فیلدهای درون این کلاس، ستون های جدول را شکل می دهند. شما می توانید نام جدول، کلیدهای اصلی، ایندکس های جستجو و روابط را با انوتیشن های استاندارد مشخص کنید.

۲. شیء دسترسی به داده (Data Access Object - DAO): رابط تعامل با پایگاه داده

کامپوننت DAO مهم ترین بخش دیتابیس Room است. این جزء به صورت یک اینترفیس تعریف می شود و متدهای تعامل با داده ها شامل عملیات CRUD (درج، خواندن، به روزرسانی و حذف) را در خود جای می دهد. کامپایلر Room در زمان بیلد، کدهای اجرایی این اینترفیس را به صورت بهینه تولید می کند و خطاهای املایی SQL را درجا گزارش می دهد.

۳. کلاس پایگاه داده (RoomDatabase): هاب مرکزی و هماهنگ کننده اتصالات

کلاس پایگاه داده به صورت یک کلاس انتزاعی (abstract) تعریف شده و از RoomDatabase ارث بری می کند. این کلاس فهرست تمام Entityها و نسخه (version) دیتابیس را نگه داری می نماید و نقطه ورودی اصلی ارتباط برنامه با فایل SQLite زیرین است.

معماری ارکان اصلی دیتابیس Room در اندروید
شکل ۱: معماری سه ستون اصلی دیتابیس Room؛ هماهنگی میان موجودیت ها (Entity)، اینترفیس های DAO و کلاس مرکزی پایگاه داده بر بستر موتور SQLite

همان طور که در دیاگرام معماری فوق به تصویر کشیده شده است، لایه انتزاعی Room به عنوان یک سپر محافظ هوشمند روی SQLite عمل می کند. لایه رابط کاربری و ViewModel تنها با اشیای داده ای نوع-ایمن سر و کار دارند و صحت سینتکس تمام کوئری های SQL در زمان کامپایل تضمین می گردد.

پیشنهاد مطالعه
مدیریت همزمانی در کاتلین با کورتین ها و Flow به زبان ساده
آموزش جامع مدیریت همزمانی در کاتلین با کورتین ها (Coroutines) و Flow؛ بررسی همزمانی ساختاریافته، جاب های نظارتی، جریان های سرد و داغ (StateFlow)، و م…
مشاهده مقاله ←

تعریف پیشرفته Entityها: کلیدها، ایندکس ها و کلید خارجی

برای جلوگیری از افت کارایی در جداول با بیش از ۱۰۰ هزار سطر، تعریف درست ایندکس ها و روابط میان جداول امری ضروری است:

در قطعه کد زیر، نحوه پیاده سازی موجودیت پایه کاربر با ایندکس گذاری اختصاصی بر روی ستون ایمیل جهت بهینه سازی سرعت کوئری های جستجو نمایش داده شده است:

@Entity(
    tableName = "users_table",
    indices = [
        Index(value = ["email"], unique = true),
        Index(value = ["created_at"])
    ]
)
data class UserEntity(
    @PrimaryKey(autoGenerate = true)
    val userId: Long = 0L,
    
    @ColumnInfo(name = "full_name")
    val fullName: String,
    
    @ColumnInfo(name = "email")
    val email: String,
    
    @ColumnInfo(name = "avatar_url")
    val avatarUrl: String?,
    
    @ColumnInfo(name = "created_at")
    val createdAt: Long = System.currentTimeMillis()
)

برای برقراری رابطه با سایر جداول، از کلید خارجی (ForeignKey) و ویژگی حذف آبشاری (CASCADE) استفاده می کنیم تا با حذف هر کاربر، تراکنش های مالی مرتبط با او به صورت خودکار از دیتابیس پاک شوند:

@Entity(
    tableName = "transactions_table",
    foreignKeys = [
        ForeignKey(
            entity = UserEntity::class,
            parentColumns = ["userId"],
            childColumns = ["owner_user_id"],
            onDelete = ForeignKey.CASCADE
        )
    ],
    indices = [Index(value = ["owner_user_id"])]
)
data class TransactionEntity(
    @PrimaryKey(autoGenerate = true)
    val transactionId: Long = 0L,
    
    @ColumnInfo(name = "owner_user_id")
    val ownerUserId: Long,
    
    @ColumnInfo(name = "amount_tomans")
    val amountTomans: Long,
    
    @ColumnInfo(name = "is_successful")
    val isSuccessful: Boolean
)

در کد فوق، استفاده از onDelete = ForeignKey.CASCADE تضمین می کند که در صورت حذف یک کاربر از جدول users_table، تمامی تراکنش های وابسته به او در جدول transactions_table به طور خودکار پاکسازی شوند تا رکوردهای یتیم (Orphan Records) پایگاه داده را آلوده نکنند.

پیاده سازی DAO با کاتلین کورتینز و جریان های واکنشی Flow

یکی از شگفت انگیزترین ویژگی های دیتابیس Room، یکپارچگی عمیق با Coroutines و Flow است. متدهایی که داده ها را تغییر می دهند با کلمه کلیدی suspend نشانه گذاری می شوند تا روی ترد پس زمینه اجرا شوند و متدهای واکشی لیست می توانند خروجی Flow<List<T>> برگردانند تا با هر تغییری در دیتابیس، خروجی جدید به صورت خودکار منتشر شود:

@Dao
interface UserDao {

    @Insert(onConflict = OnConflictStrategy.REPLACE)
    suspend fun insertUser(user: UserEntity): Long

    @Insert(onConflict = OnConflictStrategy.REPLACE)
    suspend fun insertUsersList(users: List<UserEntity>)

    @Update
    suspend fun updateUser(user: UserEntity)

    @Delete
    suspend fun deleteUser(user: UserEntity)

    // واکشی کاربر با شناسه معین
    @Query("SELECT * FROM users_table WHERE userId = :id LIMIT 1")
    suspend fun getUserById(id: Long): UserEntity?

    // انتشار زنده تغییرات تمام کاربران با Flow
    @Query("SELECT * FROM users_table ORDER BY created_at DESC")
    fun observeAllUsers(): Flow<List<UserEntity>>

    // جستجوی متنی با استفاده از عملگر LIKE
    @Query("SELECT * FROM users_table WHERE full_name LIKE '%' || :query || '%' OR email LIKE '%' || :query || '%'")
    fun searchUsers(query: String): Flow<List<UserEntity>>

    // حذف داده های قدیمی در قالب یک تراکنش
    @Query("DELETE FROM users_table WHERE created_at < :thresholdTimestamp")
    suspend fun deleteOldUsers(thresholdTimestamp: Long): Int
}
خط لوله داده های واکنشی Flow در دیتابیس Room
شکل ۲: خط لوله واکنشی Room با کاتلین Flow؛ ردیابی هوشمند تغییرات با InvalidationTracker و انتشار خودکار داده ها به رابط کاربری در جت پک کامپوز

در خط لوله داده های واکنشی شکل بالا، ردیاب تغییرات درونی دیتابیس موسوم به InvalidationTracker به محض ثبت، حذف یا ویرایش سطرهای جدول، کوئری مربوطه را مجددا در پس زمینه اجرا کرده و آخرین داده ها را در قالب استریم Flow بدون نیاز به رفرش دستی به لایه رابط کاربری تزریق می کند.

⚠️ هشدار پرفورمنس: هرگز متدهای کوئری را روی Main Thread اجرا نکنید!

دیتابیس Room به صورت پیش فرض اجازه اجرای هیچ گونه کوئری روی رشته اصلی برنامه (Main UI Thread) را نمی دهد و در صورت تلاش برای این کار، خطای IllegalStateException: Cannot access database on the main thread صادر می شود. همواره متدهای DAO را درون withContext(Dispatchers.IO) یا از طریق توابع تعلیق پذیر کورتین اجرا نمایید.

مدیریت انواع داده های پیچیده با TypeConverters

پایگاه داده SQLite به صورت ذاتی تنها از انواع داده اولیه نظیر INTEGER ،TEXT ،REAL و BLOB پشتیبانی می کند. اگر کلاسی داشته باشید که شامل یک لیست از رشته ها، مقادیر تاریخ (Date) یا آبجکت های تودرتو باشد، Room بدون مبدل نوع داده نمی تواند آن را ذخیره کند.

برای حل این مساله، از انوتیشن @TypeConverter استفاده می کنیم تا انواع داده سفارشی را به فرمت های قابل ذخیره سازی نظیر JSON متنی تبدیل نماییم:

class RoomDataConverters {

    private val jsonParser = Json { ignoreUnknownKeys = true }

    // تبدیل تاریخ Date به میلی ثانیه Long و برعکس
    @TypeConverter
    fun fromTimestamp(value: Long?): Date? {
        return value?.let { Date(it) }
    }

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

    // تبدیل لیست برچسب ها به رشته JSON متنی و برعکس
    @TypeConverter
    fun fromStringList(value: List<String>?): String {
        return jsonParser.encodeToString(value ?: emptyList())
    }

    @TypeConverter
    fun toStringList(value: String?): List<String> {
        return if (value.isNullOrBlank()) emptyList() else jsonParser.decodeFromString(value)
    }
}
تبدیل انواع داده های پیچیده با TypeConverters در Room
شکل ۳: تبدیل ساختارهای داده ای پیچیده در Room؛ سریال سازی انواع سفارشی کاتلین به ستون های استاندارد SQLite و بالعکس

طرح واره فوق جریان پردازش دوطرفه انوتیشن های @TypeConverter را نشان می دهد؛ در هنگام نوشتن داده، مدل های غنی کاتلین نظیر تاریخ یا لیست های متنی به ستون های پایه SQLite مثل TEXT یا INTEGER تبدیل می شوند و هنگام خواندن، فرآیند عکس آن برای ساخت مجدد اشیای کاتلین اجرا می گردد.

تعریف کلاس Database و الگوی یگانه (Singleton Pattern)

ایجاد نمونه های متعدد از کلاس پایگاه داده به شدت حافظه رم را هدر می دهد و می تواند باعث بروز خطاهای همزمانی (Concurrency Locks) روی فایل دیتابیس شود. بنابراین کلاس دیتابیس همواره باید به صورت سینگلتون تعریف شود:

@Database(
    entities = [UserEntity::class, TransactionEntity::class],
    version = 1,
    exportSchema = true
)
@TypeConverters(RoomDataConverters::class)
abstract class AppDatabase : RoomDatabase() {

    abstract fun userDao(): UserDao
    abstract fun transactionDao(): TransactionDao

    companion object {
        @Volatile
        private var INSTANCE: AppDatabase? = null

        fun getDatabase(context: Context): AppDatabase {
            return INSTANCE ?: synchronized(this) {
                val instance = Room.databaseBuilder(
                    context.applicationContext,
                    AppDatabase::class.java,
                    "vahid_hoseini_master_db.sqlite"
                )
                .fallbackToDestructiveMigrationOnDowngrade()
                .build()
                
                INSTANCE = instance
                instance
            }
        }
    }
}

مایگریشن های دیتابیس (Database Migrations): ارتقا بدون از دست رفتن داده ها

هنگامی که نسخه جدیدی از اپلیکیشن را منتشر می کنید و نیاز به اضافه کردن یک ستون یا تغییر ساختار یک جدول دارید، شماره نسخه دیتابیس (version) باید افزایش یابد. اگر استراتژی مایگریشن تعیین نکرده باشید و اپلیکیشن را ارتقا دهید، دیتابیس کرش خواهد کرد.

۱. مایگریشن های دستی (Manual Migrations)

در این روش، دستورات مستقیم SQL ALTER TABLE را درون آبجکت های Migration تعریف می کنید:

val MIGRATION_1_2 = object : Migration(1, 2) {
    override fun migrate(db: SupportSQLiteDatabase) {
        // اضافه کردن ستون شماره موبایل به جدول کاربران در نسخه دوم
        db.execSQL("ALTER TABLE users_table ADD COLUMN phone_number TEXT DEFAULT '' NOT NULL")
    }
}

val MIGRATION_2_3 = object : Migration(2, 3) {
    override fun migrate(db: SupportSQLiteDatabase) {
        // ایجاد جدول جدید برای ذخیره کش اعلان ها
        db.execSQL("CREATE TABLE IF NOT EXISTS notifications_table (notification_id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, message_text TEXT NOT NULL, is_read INTEGER NOT NULL DEFAULT 0)")
    }
}

۲. مایگریشن های خودکار در Room نسخه های مدرن (AutoMigrations)

از نسخه ۲.۴ دیتابیس Room به بعد، برای تغییرات ساده نظیر اضافه کردن ستون ها نیازی به نوشتن دستی دستورات SQL نیست و Room می تواند با مقایسه شمای دیتابیس، تغییرات را خودکار اعمال کند:

@Database(
    entities = [UserEntity::class, TransactionEntity::class],
    version = 3,
    autoMigrations = [
        AutoMigration(from = 1, to = 2),
        AutoMigration(from = 2, to = 3)
    ],
    exportSchema = true
)
abstract class AppDatabase : RoomDatabase() ...
استراتژی مایگریشن خودکار و دستی دیتابیس Room
شکل ۴: مقایسه راهکارهای مایگریشن در Room؛ استفاده از AutoMigration مبتنی بر فایل های شمای JSON در برابر اسکریپت های دستی Migration

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

⚠️ هشدار فاجعه بار: هرگز از fallbackToDestructiveMigration در پروداکشن استفاده نکنید!

متد fallbackToDestructiveMigration() در صورت تغییر نسخه دیتابیس، کل جداول و اطلاعات کاربر را بدون سوال پاک کرده و از نو می سازد! استفاده از این متد فقط برای فاز آزمایشی مجاز است؛ در اپلیکیشن های واقعی حتماً از مایگریشن های تعریف شده یا AutoMigration استفاده کنید تا داده های کاربران حفظ شود.

مقایسه دیتابیس Room با سایر راهکارهای ذخیره سازی در اندروید

برای انتخاب آگاهانه ترین گزینه ذخیره سازی اطلاعات در پروژه های اندرویدی، ویژگی های Room را در کنار رویکردهای دیگر در جدول زیر بسنجید:

فاکتور مقایسه دیتابیس Room (توصیه گوگل) رویکرد قدیمی SQLite خام پایگاه های داده NoSQL (مانند Realm) دیتااستور (Jetpack DataStore)
اعتبارسنجی در زمان کامپایل ۱۰۰ درصد کوئری ها در زمان کامپایل تست می شوند صفر (کوئری ها صرفاً رشته متنی هستند) بسته به فریم ورک نسبی است نیازی به کوئری ندارد (مبتنی بر کلید-مقدار)
پشتیبانی از کاتلین Flow و LiveData بومی و یکپارچه بدون نیاز به ابزار واسط نیازمند کدهای دست ساز و شنودگرهای پیچیده پشتیبانی می شود با ساختارهای اختصاصی کاملاً واکنشی بر پایه Flow
بهترین کاربرد مهندسی داده های رابطه ای ساختاریافته، کش آفلاین سازمانی تنها در پروژه های به شدت قدیمی جاوا پروژه های با مدل های درختی و غیر رابطه ای تنظیمات کاربر، توکن های احراز هویت و ترجیحات
حجم فایل های خروجی و بار کتابخانه بسیار سبک (تولید کدهای خالص در زمان کامپایل) بدون سربار (بخشی از SDK اندروید) سنگین (افزایش حجم APK به دلیل کدهای C++) فوق العاده سبک و سریع
پیچیدگی مایگریشن و ارتقا ساده و خودکار با AutoMigrations بسیار سخت و مستعد باگ های پیش بینی نشده متوسط نیازی به مایگریشن پیچیده ندارد
✓ بهترین اقدام پیشنهادی: ادغام دیتابیس Room با معماری تمیز

برای دستیابی به بالاترین استاندارد مهندسی، دیتابیس Room را به عنوان منبع داده محلی (Local Data Source) در کنار آموزش معماری تمیز در اندروید به کار بگیرید. همچنین اگر می خواهید فونداسیون کدهای پایگاه داده را با رویکرد سنتی مقایسه کنید، مقاله آموزش دیتابیس SQLite در اندروید را در پایگاه آموزشی مطالعه فرمایید.

بهینه سازی جریان داده های دیتابیس در رابط کاربری کامپوز با StateFlow

اتصال مستقیم جریان های واکنشی Flow دیتابیس Room به کامپوننت های مدرن Jetpack Compose، توسعه صفحات پویا و بلادرنگ را فوق العاده لذت بخش می کند. با این حال، اگر تبدیل Flow به State به شیوه نادرست انجام شود، باعث بازترسیم های مکرر (Recomposition) و افت نرخ فریم اپلیکیشن خواهد شد.

روش اصولی در سال ۲۰۲۶ این است که جریان داده های DAO ابتدا در لایه ViewModel با عملگر stateIn به یک StateFlow با استراتژی SharingStarted.WhileSubscribed(5000) تبدیل شود. این پارامتر ۵۰۰۰ میلی ثانیه تضمین می کند که در هنگام چرخش صفحه، اشتراک جریان دیتابیس قطع نشود و با رفتن برنامه به پس زمینه، اجرای کوئری های سنگین پس از ۵ ثانیه متوقف گردد تا باتری دستگاه تخلیه نشود:

class UsersCatalogViewModel(
    private val userDao: UserDao
) : ViewModel() {

    // تبدیل بهینه جریان Room به StateFlow با مدیریت هوشمندانه مصرف باتری
    val usersListState: StateFlow<List<UserEntity>> = userDao.observeAllUsers()
        .stateIn(
            scope = viewModelScope,
            started = SharingStarted.WhileSubscribed(5000L),
            initialValue = emptyList()
        )
}

// در فایل Composable: دریافت ایمن با آگاهی کامل از چرخه حیات
@Composable
fun UsersCatalogScreen(viewModel: UsersCatalogViewModel) {
    val users by viewModel.usersListState.collectAsStateWithLifecycle()

    LazyColumn(modifier = Modifier.fillMaxSize()) {
        items(
            items = users,
            key = { it.userId } // تعریف کلید اختصاصی برای ممانعت از بازترسیم بیهوده آیتم ها
        ) { user ->
            UserItemRow(user = user)
        }
    }
}

علاوه بر این، با افزودن مدیفایر Modifier.animateItemPlacement() به ردیف های لیست، هنگامی که رکوردی در دیتابیس حذف یا جابجا می شود، انیمیشن های نرم و بدون لگ به صورت نیتیو اجرا می شوند. این تفکیک وظایف و ارتباط بلادرنگ میان دیتابیس Room و فریم ورک کامپوز، نمونه ای بی نقص از پیاده سازی الگو های مدرن در سال ۲۰۲۶ است.

استراتژی تست دیتابیس: تست های درون حافظه ای (InMemory Testing)

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

در گام نخست، پیکربندی کلاس تست را با ایجاد یک دیتابیس سبک درون رم (inMemoryDatabaseBuilder) انجام می دهیم تا تضمین شود هر تست در محیطی ایزوله اجرا شده و پس از پایان اجرا به طور کامل پاکسازی می گردد:

@RunWith(AndroidJUnit4::class)
class UserDaoTest {

    private lateinit var database: AppDatabase
    private lateinit var userDao: UserDao

    @Before
    fun createDb() {
        val context = ApplicationProvider.getApplicationContext<Context>()
        // ساخت دیتابیس سبک درون رم برای اجرای تست های بدون تداخل
        database = Room.inMemoryDatabaseBuilder(context, AppDatabase::class.java)
            .allowMainThreadQueries()
            .build()
        userDao = database.userDao()
    }

    @After
    fun closeDb() {
        database.close()
    }
}

سپس متدهای ارزیابی عملیات ثبت، دریافت واکنشی از طریق کاتلین Flow و سنجش حذف سطر را با استفاده از متد runTest پیاده سازی می نماییم:

@Test
fun insertUserAndReadByFlow() = runTest {
    val user = UserEntity(
        userId = 101L,
        fullName = "وحید حسینی",
        email = "info@vahidhoseini.ir",
        avatarUrl = null
    )
    userDao.insertUser(user)

    val userList = userDao.observeAllUsers().first()
    assertEquals(1, userList.size)
    assertEquals("وحید حسینی", userList.first().fullName)
}

@Test
fun deleteUserRemovesRecordSuccessfully() = runTest {
    val user = UserEntity(userId = 202L, fullName = "کاربر تست", email = "test@example.com", avatarUrl = null)
    userDao.insertUser(user)
    userDao.deleteUser(user)

    val retrieved = userDao.getUserById(202L)
    assertNull(retrieved)
}

پیاده سازی روابط چندجدوله با انوتیشن های Relation و Embedded

در دنیای واقعی، داده های اپلیکیشن به ندرت در یک جدول منفرد ایزوله می مانند. مدل سازی روابط یک به یک (One-to-One)، یک به چند (One-to-Many) و چند به چند (Many-to-Many) از توانمندی های برجسته دیتابیس Room است.

برخلاف ORMهای کلاسیک که از لودینگ تنبل (Lazy Loading) استفاده می کنند و باعث خطای کرش روی ترد UI می شوند، Room شما را ملزم می کند که روابط را به صورت صریح با انوتیشن @Relation مدل سازی نمایید.

برای نمونه، مدل سازی یک کاربر همراه با فهرست تمامی تراکنش های مالی او به شکل زیر انجام می پذیرد:

// کلاس رابطه ای یک به چند برای ترکیب کاربر و تراکنش ها
data class UserWithTransactions(
    @Embedded val user: UserEntity,
    @Relation(
        parentColumn = "userId",
        entityColumn = "owner_user_id"
    )
    val transactions: List<TransactionEntity>
)

// متد استعلام در اینترفیس DAO همراه با انوتیشن @Transaction
@Dao
interface UserBillingDao {
    @Transaction
    @Query("SELECT * FROM users_table WHERE userId = :userId")
    fun getUserWithTransactions(userId: Long): Flow<UserWithTransactions>
}

استفاده از انوتیشن @Transaction بر روی متد getUserWithTransactions الزامی است؛ زیرا Room باید دو کوئری مستقل را به صورت اتمیک (Atomic) اجرا کرده و نتایج را ترکیب کند. این کار از بروز وضعیت مسابقه (Race Condition) در زمان خواندن همزمان داده ها جلوگیری می نماید.

استراتژی کشینگ پیشرفته آفلاین با الگوی NetworkBoundResource

یکی از شاخص ترین الگو های توصیه شده توسط تیم مهندسی گوگل، الگوی NetworkBoundResource است. این الگو تضمین می کند که اپلیکیشن در سریع ترین زمان ممکن (کمتر از ۱۰۰ میلی ثانیه) آخرین داده های ذخیره شده در دیتابیس محلی Room را به کاربر نشان دهد و سپس در پس زمینه، آخرین داده ها را از سرور استعلام کرده و دیتابیس را به روز کند.

inline fun <ResultType, RequestType> networkBoundResource(
    crossinline query: () -> Flow<ResultType>,
    crossinline fetch: suspend () -> RequestType,
    crossinline saveFetchResult: suspend (RequestType) -> Unit,
    crossinline shouldFetch: (ResultType) -> Boolean = { true }
) = flow {
    val data = query().first()

    val flow = if (shouldFetch(data)) {
        emit(Resource.Loading(data))
        try {
            val remoteData = fetch()
            saveFetchResult(remoteData)
            query().map { Resource.Success(it) }
        } catch (throwable: Throwable) {
            query().map { Resource.Error(throwable, it) }
        }
    } else {
        query().map { Resource.Success(it) }
    }

    emitAll(flow)
}

با این معماری واکنشی، کاربر هیچ گاه با صفحه سفید لودینگ مواجه نمی شود، مصرف ترافیک اینترنت تا ۴۰ درصد کاهش می یابد و تجربه کاربری در شرایط ناپایداری شبکه کاملاً نرم و یکپارچه باقی می ماند.

رمزنگاری پایگاه داده محلی با SQLCipher و تضمین امنیت اطلاعات

در اپلیکیشن های مالی، پیام رسان ها، کیف پول های ارز دیجیتال و سامانه های سازمانی، ذخیره سازی متن خام (Plaintext) درون فایل دیتابیس SQLite یک نقص امنیتی بزرگ محسوب می شود؛ چرا که با روت کردن دستگاه یا استخراج فایل پشتیبان (ADB Backup)، مهاجم به سادگی به کل اطلاعات کاربران دسترسی پیدا می کند.

برای ممانعت قطعی از این آسیب پذیری، می توان دیتابیس Room را به صورت سخت افزاری با استاندارد رمزنگاری پیشرفته AES-۲۵۶ رمزگذاری کرد. برای این منظور، از کتابخانه SQLCipher for Android استفاده می شود:

// پیکربندی Room با کلید رمزنگاری سخت افزاری SQLCipher
val passphraseBytes = "کلید_فوق_محرمانه_۲۵۶_بیتی_از_اندروید_کی_استور".toByteArray()
val factory = SupportFactory(passphraseBytes)

val encryptedDatabase = Room.databaseBuilder(
    context.applicationContext,
    AppDatabase::class.java,
    "encrypted_secure_vault.db"
)
.openHelperFactory(factory)
.build()

با این تنظیمات، تمام بایت های ذخیره شده روی دیسک به صورت رمزنگاری شده ذخیره می شوند و بدون کلید اصلی که درون Android Keystore محافظت می شود، امکان رمزگشایی حتی ۱ بایت از اطلاعات وجود نخواهد داشت.

ابزار Database Inspector در اندروید استودیو و تحلیل کوئری ها

یکی از فوق العاده ترین قابلیت های محیط توسعه اندروید استودیو، ابزار Database Inspector است. این ابزار به شما امکان می دهد بدون نیاز به روت کردن دستگاه یا استخراج فایل دیتابیس، ساختار جداول را به صورت بلادرنگ مشاهده، ویرایش و تحلیل نمایید:

  • قابلیت Live Updates: با فعال کردن این گزینه، به محض درج، ویرایش یا حذف یک رکورد توسط اپلیکیشن یا اجرای متدهای DAO، سطر مربوطه درون جدول اندروید استودیو در لحظه هایلایت شده و تغییر می کند.
  • اجرای کوئری های دلخواه (Query Console): می توانید دستورات پیچیده SQL را مستقیماً تایپ کرده و خروجی آن را درجا مشاهده فرمایید.
  • تحلیل کارایی با EXPLAIN QUERY PLAN: با قرار دادن این عبارت قبل از کوئری های خود، می توانید بررسی کنید که آیا پایگاه داده از ایندکس های ایجاد شده استفاده می کند یا در حال اسکن سنگین کل جدول است.
پیشنهاد مطالعه
آموزش معماری تمیز در مهندسی نرم افزار و سیستم های بزرگ
آموزش جامع معماری تمیز (Clean Architecture) در مهندسی نرم افزار؛ اصول طراحی لایه ای، تفکیک وابستگی ها، الگو های Domain-Driven و تست پذیری با مثال های…
مشاهده مقاله ←

۷ اشتباه مهندسی رایج در پیاده سازی دیتابیس Room

در پروژه هایی پروداکشن، عدم رعایت نکات ظریف زیر می‌تواند باعث افت محسوس عملکرد دیتابیس یا کرش های مکرر گردد:

  1. فراموش کردن ایندکس گذاری روی ستون های جستجو: جستجو روی ستون هایی که ایندکس ندارند باعث اسکن کامل جدول (Full Table Scan) می شود که در جداول پرجمعیت تا صدها میلی ثانیه پردازنده را قفل می کند.
  2. استفاده از کوئری SELECT * در کوئری های سنگین: اگر صرفاً به دو فیلد از جدول نیاز دارید، ساختن کلاس داده ای سبک تر (Projection) به جای واکشی تمام ستون ها، مصرف حافظه رم را تا ۸۰ درصد کاهش می دهد.
  3. عدم تعیین OnConflictStrategy در متد Insert: اگر استراتژی برخورد با کلیدهای تکراری مشخص نشود، با درج اولین رکورد تکراری برنامه با خطای SQLiteConstraintException کرش می کند.
  4. باز کردن اتصالات متعدد به دیتابیس: عدم استفاده از الگوی سینگلتون یا تزریق وابستگی Hilt باعث می شود چندین نمونه از SQLiteDatabase فایل را باز کنند و خطای Database Locked صادر شود.
  5. استفاده نکردن از تراکنش ها در درج های گروهی: درج ۱۰۰۰ ردیف بدون تراکنش بیش از ۱۰ ثانیه طول می کشد؛ در حالی که اجرای آنها درون یک تراکنش یا با متد درج لیستی DAO در کسری از ثانیه تکمیل می شود.
  6. ذخیره سازی فایل های حجیم یا تصاویر درون دیتابیس: ذخیره فایل های باینری (BLOB) بالای چند کیلوبایت درون SQLite خطایی فاحش است. تصاویر و فایل ها باید در فایل سیستم داخلی ذخیره شده و فقط آدرس متنی (File Path) آنها درون دیتابیس نگه داری شود.
  7. نادیده گرفتن خطاهای دانگرید نسخه دیتابیس: اگر نسخه دیتابیس کاربر بالاتر از نسخه اپلیکیشن باشد و متد fallbackToDestructiveMigrationOnDowngrade() تعریف نشده باشد، اپلیکیشن بلافاصله کرش می کند.

پرسش های متداول در خصوص دیتابیس Room در اندروید

آیا دیتابیس Room قابلیت رمزنگاری داده های محلی را دارد؟

بله؛ با ادغام کتابخانه رسمی SQLCipher با دیتابیس Room از طریق کلاس SupportOpenHelperFactory، می توانید کل فایل دیتابیس SQLite را با کلید رمزنگاری ۲۵۶ بیتی به صورت سخت افزاری رمزگذاری کنید تا حتی در دستگاه های روت شده نیز اطلاعات قابل استخراج نباشد.

تفاوت اصلی بین LiveData و Flow در خروجی متدهای DAO چیست؟

کتابخانه LiveData بخشی از فریم ورک اندروید است و وابسته به چرخه حیات لایف سایکل می باشد؛ در حالی که کاتلین Flow عضوی از کاتلین خالص است، از اپراتورهای غنی تبدیل داده پشتیبانی می کند و در تمام لایه های معماری تمیز بدون وابستگی به کلاس های UI قابل استفاده است.

چگونه می توان روابط چند به چند (Many-to-Many) را در Room پیاده سازی کرد؟

با تعریف یک جدول واسط (Junction Table) که شامل کلیدهای اصلی هر دو جدول باشد و بهره گیری از انوتیشن @Junction در کلاس های رابطه ای، می توان به راحتی روابط چند به چند نظیر رابطه بین دانشجویان و دوره های آموزشی را مدیریت کرد.

فایل exportSchema چیست و چرا باید در گیت ذخیره شود؟

هنگامی که گزینه exportSchema = true فعال باشد، کامپایلر Room در هر بار بیلد یک فایل JSON شامل ساختار کامل جداول و ایندکس ها در پوشه schemas ذخیره می کند. این فایل برای اعتبارسنجی مایگریشن های خودکار و پیگیری تغییرات ساختار دیتابیس در طول زمان حیاتی است.

چگونه می توان داده های صفحه بندی شده را مستقیماً از Room دریافت کرد؟

با تعریف خروجی متد DAO به صورت PagingSource<Int, UserEntity> و اتصال آن به کتابخانه Jetpack Paging ۳، می توانید هزاران رکورد را در بسته های کوچک و بهینه مستقیماً در رابط کاربری اسکرول کنید بدون اینکه رم اشغال شود.

آیا می توان دیتابیس از پیش پر شده (Pre-populated Database) را با Room باز کرد؟

بله؛ با فراخوانی متد createFromAsset("database/initial_data.db") در زمان ساخت دیتابیس با databaseBuilder، می توانید پایگاه داده ای که قبلاً با داده های اولیه پر شده است را درون پوشه assets پروژه قرار داده و در اولین اجرای برنامه لود نمایید.

بهترین روش برای تزریق دیتابیس Room با فریم ورک Hilt چیست؟

با تعریف یک ماژول Hilt شامل متدهای @Provides @Singleton برای فراهم کردن نمونه کلاس AppDatabase و متدهای فراهم کننده DAOها، می توانید وابستگی های دیتابیس را به راحتی در کلاس های مخازن لایه دیتا تزریق کنید.

چرا در متدهای ویرایش و حذف DAO نیازی به نوشتن کوئری SQL نیست؟

انوتیشن های @Update و @Delete دیتابیس Room به طور خودکار بر اساس کلید اصلی (@PrimaryKey) رکورد ارسال شده، کوئری متناظر را تولید و اجرا می کنند و فقط در صورتی که شرط خاصی خارج از کلید اصلی مدنظر باشد از @Query استفاده می شود.

امتیاز و نظرات

۰.۰ (۰ نظر)
برای ثبت دیدگاه باید وارد حساب کاربری شوید. ورود

هنوز نظری ثبت نشده است. اولین نفر باشید.