بيانات أندرويد والشبكات والواجهات

مكتبة Room للتخزين الدائم

18 دقيقة الدرس 3 من 12

مكتبة Room للتخزين الدائم

في الدرس السابق رأيت كيف تعمل مع SQLite مباشرةً باستخدام SQLiteOpenHelper. هذا الأسلوب يؤدي الغرض، لكنه يجبرك على كتابة كود نمطي متكرر: إنشاء الجداول بـ SQL خام، وكتابة كود حزم وفك حزم ContentValues، وتحويل الأعمدة يدويًا. Room هو طبقة التجريد الرسمية من Google فوق SQLite. فهو يُزيل معظم هذا الكود النمطي ويمنحك التحقق من صحة SQL وقت الترجمة، مع تكامل سلس مع بقية نظام Jetpack.

Room ليس بديلًا عن SQLite. إنه طبقة تعيين تولّد كود SQLite نيابةً عنك. في باطنه تظل بياناتك في ملف .db معتاد على الجهاز؛ Room يوفر عليك فحسب الكتابة اليدوية للأجزاء المتكررة.

الركائز الثلاث لـ Room

يتضمن كل إعداد لـ Room ثلاثة أنواع بالضبط من الفئات:

  1. الكيان (Entity) — فئة Java مزيّنة بـ @Entity. كل نسخة منها تُعيَّن إلى صف واحد في جدول قاعدة البيانات.
  2. كائن الوصول للبيانات (DAO) — واجهة أو فئة مجردة مزيّنة بـ @Dao. تُصرّح فيها بالعمليات وتولّد Room تنفيذ SQL لها.
  3. قاعدة البيانات (Database) — فئة مجردة مزيّنة بـ @Database تمتد من RoomDatabase. وهي نقطة الدخول الرئيسية التي تجمع كل شيء معًا.

إضافة Room إلى مشروعك

افتح ملف build.gradle الخاص بالوحدة وأضف التبعيات. يأتي Room عبر ثلاثة مكوّنات منفصلة:

// build.gradle (Module: app) dependencies { def room_version = "2.6.1" implementation "androidx.room:room-runtime:$room_version" annotationProcessor "androidx.room:room-compiler:$room_version" // Java (ليس Kotlin) // اختياري: مساعدات اختبار Room بشكل معزول testImplementation "androidx.room:room-testing:$room_version" }
استخدم annotationProcessor وليس kapt. kapt هو معالج التعليقات التوضيحية الخاص بـ Kotlin. في مشروع Android خالص بـ Java يجب استخدام annotationProcessor؛ وإلا لن يعمل مولّد كود Room وستحصل على أخطاء "cannot find symbol" غامضة وقت البناء.

تعريف الكيان

الكيان هو كائن Java عادي (POJO) مزيّن بتعليقات Room التوضيحية. تقرأ Room هذه التعليقات وقت الترجمة وتولّد عبارة CREATE TABLE نيابةً عنك.

import androidx.room.ColumnInfo; import androidx.room.Entity; import androidx.room.PrimaryKey; @Entity(tableName = "tasks") public class Task { @PrimaryKey(autoGenerate = true) public int id; @ColumnInfo(name = "title") public String title; @ColumnInfo(name = "is_done") public boolean isDone; @ColumnInfo(name = "created_at") public long createdAt; // ميلي ثانية منذ epoch // Room تحتاج إلى منشئ بلا معاملات (أو منشئ مُعلَّم بـ @Ignore) public Task() {} public Task(String title) { this.title = title; this.isDone = false; this.createdAt = System.currentTimeMillis(); } }

تفاصيل التعليقات الرئيسية:

  • @Entity(tableName = "tasks") — اسم الجدول المولَّد. إن حذفته استخدمت Room اسم الفئة بأحرف صغيرة.
  • @PrimaryKey(autoGenerate = true) — يخبر Room باستخدام ما يعادل AUTOINCREMENT في SQLite. يجب أن يحتوي كل كيان على مفتاح أساسي واحد بالضبط.
  • @ColumnInfo(name = "...") — يعيّن حقل Java إلى اسم عمود محدد. اختياري لكن موصى به بشدة حتى لا يؤدي تغيير اسم الحقل إلى كسر استعلاماتك بصمت.
لا تدعم Room تخزين الكائنات المعقدة مباشرةً. يجب أن تكون الحقول أنواعًا بدائية أو أنواعًا معبّأة أو String أو أنواعًا لها @TypeConverter مسجّل. إن حاولت تخزين List أو فئة مخصصة مباشرةً سترفض Room الترجمة برسالة مثل: "Cannot figure out how to save this field into database."

تعريف كائن DAO

في الـ DAO تُصرّح بالعمليات التي يحتاجها تطبيقك، وتولّد Room التنفيذ. تكتب توقيعات الدوال وSQL (أو تدع Room تستنتج SQL لعمليات CRUD الشائعة).

import androidx.room.Dao; import androidx.room.Delete; import androidx.room.Insert; import androidx.room.OnConflictStrategy; import androidx.room.Query; import androidx.room.Update; import java.util.List; @Dao public interface TaskDao { // INSERT — تولّد Room جملة INSERT OR IGNORE (أو REPLACE) @Insert(onConflict = OnConflictStrategy.REPLACE) void insert(Task task); // UPDATE — تطابق الصفوف بالمفتاح الأساسي @Update void update(Task task); // DELETE — تطابق الصفوف بالمفتاح الأساسي @Delete void delete(Task task); // SELECT مخصص — تكتب SQL وتتحقق Room منها وقت الترجمة @Query("SELECT * FROM tasks ORDER BY created_at ASC") List<Task> getAllTasks(); @Query("SELECT * FROM tasks WHERE is_done = 0 ORDER BY created_at ASC") List<Task> getPendingTasks(); @Query("SELECT * FROM tasks WHERE id = :taskId") Task getById(int taskId); @Query("DELETE FROM tasks WHERE is_done = 1") void deleteCompletedTasks(); }

لاحظ كيف يأخذ التعليق @Query سلسلة SQL عادية. تُحلّل Room هذه الجملة وقت الترجمة وتُبلّغ عن الأخطاء — فاسم عمود أو جدول مكتوب بشكل خاطئ سيتسبب في فشل البناء لا في تعطّل وقت التشغيل. هذا أحد أقيم ضمانات السلامة في Room.

إنشاء فئة قاعدة البيانات

فئة قاعدة البيانات هي الغراء الذي يربط كل شيء. تُصرّح فيها بالكيانات التي ينتمي إليها المخطط والإصدار، وتعرض دوال مصنع للحصول على نسخ الـ DAO.

import android.content.Context; import androidx.room.Database; import androidx.room.Room; import androidx.room.RoomDatabase; @Database(entities = {Task.class}, version = 1, exportSchema = false) public abstract class AppDatabase extends RoomDatabase { // دالة مجردة — تولّد Room التنفيذ public abstract TaskDao taskDao(); // Singleton آمن للخيوط private static volatile AppDatabase INSTANCE; public static AppDatabase getInstance(Context context) { if (INSTANCE == null) { synchronized (AppDatabase.class) { if (INSTANCE == null) { INSTANCE = Room.databaseBuilder( context.getApplicationContext(), AppDatabase.class, "task_database" // اسم ملف .db على القرص ).build(); } } } return INSTANCE; } }

قراران مهمان اتُّخذا هنا:

  • نمط Singleton مع القفل المزدوج الفحص — إنشاء RoomDatabase عملية مكلفة (تفتح الملف وتُجمّع عمليات الترحيل). تريد نسخة واحدة بالضبط لكل عملية.
  • context.getApplicationContext() — تمرير سياق Activity سيُسرّب هذا الـ Activity. يعيش سياق التطبيق طوال عمر العملية وهو آمن للاحتفاظ به.

استخدام Room على خيط الخلفية

تفرض Room قاعدة صارمة: لا يجوز تشغيل عمليات قاعدة البيانات على الخيط الرئيسي (UI). فعل ذلك سيحجب واجهة المستخدم ويتسبب في أخطاء ANR (التطبيق لا يستجيب). أبسط طريقة للامتثال هي استخدام خيط خلفية صريح:

// داخل Activity أو Fragment AppDatabase db = AppDatabase.getInstance(this); TaskDao dao = db.taskDao(); // عملية كتابة على خيط خلفية new Thread(() -> { Task t = new Task("شراء البقالة"); dao.insert(t); }).start(); // عملية قراءة على خيط خلفية، ثم تحديث واجهة المستخدم على الخيط الرئيسي new Thread(() -> { List<Task> tasks = dao.getAllTasks(); runOnUiThread(() -> { // تحديث محوّل RecyclerView وما شابه adapter.submitList(tasks); }); }).start();
Room مع LiveData أو Kotlin Coroutines؟ في المشاريع الاحترافية ستجد كثيرًا أن دوال الـ DAO تُعيد LiveData<List<Task>> بدلًا من List<Task> العادية. حينئذٍ تُشغّل Room الاستعلام على خيط خلفية تلقائيًا وتُرسل التحديثات إلى المراقبين على الخيط الرئيسي كلما تغيّرت البيانات. هذا النمط الموصى به في الإنتاج. في الوقت الحالي استخدام خيوط صريحة يُوضّح ما تفعله Room فعليًا تحت الغطاء.

ترحيل المخطط

عند إضافة عمود أو جدول يجب زيادة version في @Database وتوفير كائن Migration. بدون ترحيل ستطرح Room IllegalStateException حين تكتشف عدم تطابق إصدار المخطط.

import androidx.room.migration.Migration; import androidx.sqlite.db.SupportSQLiteDatabase; // ترحيل من الإصدار 1 إلى الإصدار 2 static final Migration MIGRATION_1_2 = new Migration(1, 2) { @Override public void migrate(SupportSQLiteDatabase database) { database.execSQL( "ALTER TABLE tasks ADD COLUMN priority INTEGER NOT NULL DEFAULT 0" ); } }; // تسجيل الترحيل عند بناء قاعدة البيانات INSTANCE = Room.databaseBuilder(context.getApplicationContext(), AppDatabase.class, "task_database") .addMigrations(MIGRATION_1_2) .build();

أثناء التطوير يمكنك استدعاء .fallbackToDestructiveMigration() بدلًا من ذلك لحذف قاعدة البيانات وإعادة إنشائها عند عدم تطابق الإصدار. لا تستخدم هذا في الإنتاج أبدًا — فهو يُدمّر بيانات المستخدم بصفة دائمة.

الخلاصة

تُقلّص Room عمل قواعد بيانات Android إلى ثلاث فئات مزيّنة بتعليقات: @Entity يصف صف الجدول، و@Dao الذي تُصرّح فيه بالاستعلامات (مع التحقق منها وقت الترجمة)، و@Database كـ Singleton يربط كل شيء. شغّل Room دائمًا على خيط خلفية، واستخدم getApplicationContext() للـ Singleton، وعرّف كائنات Migration صريحة في كل مرة يتغير فيها المخطط. في الدرس القادم ستتعمق في الخيوط وستتعلم الخيارات الحديثة — ExecutorService وWorkManager — التي تتزاوج مع Room بشكل طبيعي في تطبيقات الإنتاج.