تطوير الجوّال

التخزين المحلي بـ Room 3 وJetpack Compose خطوة بخطوة

4 min de lecture

🔝 الدليل الرئيسي: Kotlin وJetpack Compose في 2026

تطبيق محمول يعتمد حصريًا على الشبكة يُحبط المستخدم سريعًا. اقطع الاتصال، تبقى الشاشة فارغة. حفظ البيانات محليًا في قاعدة SQLite يُغيّر هذه التجربة: فتح فوري، قراءة البيانات بدون اتصال، ومزامنة شفافة عند عودة الشبكة. Room 3.0، الصادر في مارس 2026، هو طبقة التجريد الرسمية لـ SQLite على Android. هذا الإصدار الرئيسي يجلب تغييرين هيكليين: حصرية KSP (KAPT لم يعد مدعومًا) وتوافق Kotlin Multiplatform عبر حزمة androidx.room3. يستعرض هذا الدرس الإعداد الكامل: التبعيات، الكيانات، DAOs، قاعدة البيانات، الترحيلات، والتكامل مع Repository.

المتطلبات

  • مشروع Kotlin بـ ViewModel + StateFlow وRetrofit مُعدَّان
  • KSP مُفعَّل في البناء (راجع تثبيت Android Studio الخطوة 5)
  • فهم أساسي لـ SQLite (جدول، صف، عمود، مفتاح أساسي)
  • الوقت المُقدَّر: 90 دقيقة

الخطوة 1 — إضافة تبعيات Room 3.0

Room 3.0 يستخدم إحداثيات Maven الجديدة androidx.room3، متمايزة عن androidx.room القديم. لمشروع جديد، ننطلق على Room 3.0 مباشرة.

[versions]
room = "3.0.0"

[libraries]
androidx-room3-runtime = { group = "androidx.room3", name = "room3-runtime", version.ref = "room" }
androidx-room3-ktx = { group = "androidx.room3", name = "room3-ktx", version.ref = "room" }
androidx-room3-compiler = { group = "androidx.room3", name = "room3-compiler", version.ref = "room" }
dependencies {
    implementation(libs.androidx.room3.runtime)
    implementation(libs.androidx.room3.ktx)
    ksp(libs.androidx.room3.compiler)
}

زامن. مهمة Gradle kspDebugKotlin يجب أن تظهر. عند أول بناء، Room يُولّد تلقائيًا أصناف تنفيذ DAOs والقاعدة.

الخطوة 2 — إعلان كيان Room

import androidx.room3.ColumnInfo
import androidx.room3.Entity
import androidx.room3.PrimaryKey

@Entity(tableName = "articles")
data class ArticleEntity(
    @PrimaryKey val id: Int,
    val titre: String,
    val contenu: String,
    @ColumnInfo(name = "auteur_id") val auteurId: Int,
    @ColumnInfo(name = "publie_le") val publieLe: Long
)

اختيارات تقنية. id: Int للمفتاح الأساسي — إذا قدّمت API البعيدة معرّفات مستقرة، نأخذها؛ وإلا @PrimaryKey(autoGenerate = true) val id: Long = 0. التواريخ تُخزَّن كـ Long (timestamp Unix بالميلي ثانية). للقيم المُعدَّدة، نستخدم TypeConverter.

الخطوة 3 — تعريف DAO بـ suspend وFlow

import androidx.room3.Dao
import androidx.room3.Insert
import androidx.room3.OnConflictStrategy
import androidx.room3.Query
import androidx.room3.Update
import kotlinx.coroutines.flow.Flow

@Dao
interface ArticlesDao {

    @Query("SELECT * FROM articles ORDER BY publie_le DESC")
    fun observerArticles(): Flow<List<ArticleEntity>>

    @Query("SELECT * FROM articles WHERE id = :id")
    suspend fun obtenirParId(id: Int): ArticleEntity?

    @Insert(onConflict = OnConflictStrategy.REPLACE)
    suspend fun upsert(articles: List<ArticleEntity>)

    @Update
    suspend fun mettreAJour(article: ArticleEntity)

    @Query("DELETE FROM articles WHERE id = :id")
    suspend fun supprimer(id: Int)

    @Query("DELETE FROM articles")
    suspend fun vider()
}

ثلاثة عناصر للفهم. OnConflictStrategy.REPLACE يستبدل صفًا موجودًا. observerArticles(): Flow<List<ArticleEntity>> يُصدر قيمة جديدة عند كل كتابة على الجدول. ومعاملات SQL تستخدم :nom الذي يتطابق مع معامل Kotlin.

الخطوة 4 — بناء قاعدة البيانات

import androidx.room3.Database
import androidx.room3.RoomDatabase

@Database(
    entities = [ArticleEntity::class],
    version = 1,
    exportSchema = true
)
abstract class AppDatabase : RoomDatabase() {
    abstract fun articlesDao(): ArticlesDao
}

object DatabaseProvider {
    @Volatile private var instance: AppDatabase? = null

    fun lire(context: Context): AppDatabase {
        return instance ?: synchronized(this) {
            instance ?: Room.databaseBuilder(
                context.applicationContext,
                AppDatabase::class.java,
                "app.db"
            )
                .fallbackToDestructiveMigration(dropAllTables = false)
                .build()
                .also { instance = it }
        }
    }
}

exportSchema = true يُصدّر الـ schema SQL في app/schemas/ عند كل زيادة إصدار — مفيد لحفظ الترحيلات في Git. fallbackToDestructiveMigration مقبول أثناء التطوير، يُحذف في الإنتاج.

الخطوة 5 — ربط Room بـ Repository

class ArticlesRepository(
    private val api: BlogApi,
    private val dao: ArticlesDao
) {
    fun observerArticles(): Flow<List<Article>> {
        return dao.observerArticles().map { liste ->
            liste.map { it.toArticle() }
        }
    }

    suspend fun rafraichir() {
        try {
            val articlesDistants = api.listerArticles()
            dao.upsert(articlesDistants.map { it.toEntity() })
        } catch (e: Exception) {
            // L'utilisateur conserve les données locales
        }
    }

    private fun ArticleEntity.toArticle() = Article(id, titre, contenu)
    private fun ArticleDto.toEntity() = ArticleEntity(
        id = id,
        titre = title,
        contenu = contenu,
        auteurId = auteurId,
        publieLe = System.currentTimeMillis()
    )
}

هذه الهندسة تُسمى Single Source of Truth: القاعدة المحلية هي الحقيقة، الشبكة تُغذّيها. ViewModel يُراقب observerArticles() ولا يعرف شيئًا عن الشبكة. عندما يسحب المستخدم للتحديث، يستدعي repository.rafraichir(). الكتابة الجديدة في Room تُطلق تلقائيًا إصدار قيمة جديدة على Flow، وUI يُحدَّث.

الخطوة 6 — إدارة ترحيلات الـ schema

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

Room.databaseBuilder(context, AppDatabase::class.java, "app.db")
    .addMigrations(MIGRATION_1_2)
    .build()

للترحيلات المعقدة، نستخدم auto-migrations لـ Room 3 التي تُولّد كود SQL من تعليقات @AutoMigration(from = 1, to = 2). كل الترحيلات يجب أن تُحفظ في Git وتُختبر قبل release — ترحيل معطوب يُدمّر بيانات المستخدم.

الخطوة 7 — اختبار استعلامات Room

@RunWith(AndroidJUnit4::class)
class ArticlesDaoTest {
    private lateinit var db: AppDatabase
    private lateinit var dao: ArticlesDao

    @Before
    fun setup() {
        val context = ApplicationProvider.getApplicationContext<Context>()
        db = Room.inMemoryDatabaseBuilder(context, AppDatabase::class.java)
            .allowMainThreadQueries()
            .build()
        dao = db.articlesDao()
    }

    @After fun teardown() = db.close()

    @Test
    fun upsertEtObservationRenvoieLaListe() = runTest {
        dao.upsert(listOf(ArticleEntity(1, "Test", "Contenu", 1, 0L)))
        val liste = dao.observerArticles().first()
        assertEquals(1, liste.size)
        assertEquals("Test", liste[0].titre)
    }
}

القاعدة inMemory تُنشأ وتُدمَّر لكل اختبار. DAOs تُختبَر في ميلي ثوانٍ لكل حالة. للـ Repository الذي يجمع Room وRetrofit، نحقن FakeBlogApi يُرجع DTOs مُتحكَّم بها.

الخطوة 8 — العلاقات بين الكيانات

@Entity(tableName = "auteurs")
data class AuteurEntity(
    @PrimaryKey val id: Int,
    val nom: String
)

data class AuteurAvecArticles(
    @Embedded val auteur: AuteurEntity,
    @Relation(
        parentColumn = "id",
        entityColumn = "auteur_id"
    )
    val articles: List<ArticleEntity>
)

@Dao
interface AuteursDao {
    @Transaction
    @Query("SELECT * FROM auteurs WHERE id = :id")
    suspend fun obtenirAvecArticles(id: Int): AuteurAvecArticles?
}

التعليق @Transaction يضمن أن الاستعلامين المُولَّدين (auteur + articles) يُنفَّذان في معاملة SQLite واحدة — وإلا، بين الاثنين، قد يكتب thread آخر ويجعل النتيجة غير متماسكة. للعلاقات many-to-many، نستخدم @Junction.

أخطاء شائعة

العَرَض السبب الحل
Cannot find implementation for AppDatabase KSP غير مُعدّ أو room3-compiler مفقود تحقق من plugin KSP وتبعية ksp(libs.room3.compiler)
A migration from N to M is required الإصدار زاد بدون Migration أضف addMigrations(…) أو fallbackToDestructiveMigration
تعطّل في main thread « Cannot access database » استعلام متزامن على UI thread استخدم suspend fun أو Flow
لا تحديث بعد الإدراج قراءة فورية بدل Flow استبدل بـ Flow على DAO
نوع غير مدعوم حقل Date، UUID، enum بدون converter أضف @TypeConverter

تكييف الـ cache مع الاستخدام الحقيقي

تخزين كل شيء ليس دائمًا الفكرة الصحيحة. ثلاث استراتيجيات عملية. TTL لكل كيان مع عمود cached_at: إن تجاوز 24 ساعة، نعتبره مهجورًا ونُحدّث. حد حجم: الاحتفاظ بآخر 500 مقال فقط (LRU). تنظيف مُخطَّط عبر WorkManager أسبوعيًا.

للصور، لا تُخزّن blobs في Room — غير فعّال. استخدم Coil 3 الذي يُدير cache قرص مُحسَّن. Room للبيانات الوصفية النصية، Coil للـ bitmaps.

الأسئلة الشائعة

Room 3 أم Room 2؟
Room 3 لأي مشروع جديد في 2026. Room 2.8.4 يبقى مُصانًا للمشاريع الموجودة.

هل نُشفّر القاعدة؟
لبيانات حساسة (صحة، مالية)، نعم عبر SQLCipher. وإلا، sandbox Android يكفي.

كم قاعدة لكل تطبيق؟
واحدة كقاعدة عامة. عدة فقط إذا كانت لديك بيانات مستقلة جدًا بدورات حياة مختلفة.

كيف نُرحّل من Realm أو Greendao؟
تصدير البيانات عبر ترحيل جماعي يدوي: قراءة من القديم، كتابة في Room، في job خلفي مرة واحدة.

هل Room يعمل على Wear OS؟
نعم، بدون تعديل. انتبه لحجم الملف.

مقالات ذات صلة

Sponsoriser ce contenu

Cet emplacement est à vous

Position premium en fin d'article — c'est l'instant où les lecteurs sont le plus engagés. Réservez cet espace pour votre marque, votre formation ou votre offre.

Recevoir nos tarifs
Publicité