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

قواعد البيانات المحلية مع SQLite

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

قواعد البيانات المحلية مع SQLite

يأتي كل جهاز Android مزوّدًا بمحرك SQLite كامل مدمج في نظام التشغيل. عندما يحتاج تطبيقك إلى تخزين بيانات منظّمة وعلائقية — كقائمة مهام بفئات، أو سجل رسائل بطوابع زمنية، أو كتالوج منتجات بأسعار — فإن قاعدة بيانات SQLite المحلية هي الأداة المناسبة. SharedPreferences مخصصة لإعدادات المفتاح والقيمة؛ أما SQLite فهي لكل ما يشبه الجداول.

يعلّمك هذا الدرس كيفية إدارة قاعدة بيانات SQLite في Android باستخدام فئة SQLiteOpenHelper وواجهة برمجة التطبيقات الخام android.database.sqlite. في الدرس التالي ستضع طبقة Room فوق ذلك؛ وفهم ما تخفيه Room هو ما يمكّنك من تشخيص أخطائها حين تتصرف بشكل غير متوقع.

كيف تدير Android ملف قاعدة البيانات

تخزّن Android قاعدة بيانات SQLite لكل تطبيق كملف ضمن دليل البيانات الخاصة بالتطبيق: /data/data/<package>/databases/<name>.db. لا يمكن لأي تطبيق آخر قراءته. يُنشأ الملف في أول مرة تفتح فيها قاعدة البيانات — لا تنشئه يدويًا أبدًا.

نقطة الدخول إلى دورة الحياة هي SQLiteOpenHelper. تُنشئ منها فئة فرعية، وتحدد رقم إصدار المخطط، وتُعيد تعريف طريقتين:

  • onCreate(SQLiteDatabase db) — تُستدعى مرة واحدة فقط، في المرة الأولى التي يُنشأ فيها ملف قاعدة البيانات. ضع جمل CREATE TABLE هنا.
  • onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) — تُستدعى في كل مرة ترفع فيها رقم الإصدار في المُنشئ. نفّذ هجرة المخطط هنا؛ لا تحذف جداول دون توفير طريقة للحفاظ على البيانات.
أرقام الإصدار مهمة. العدد الصحيح الذي تمرره إلى مُنشئ super() يُخزَّن داخل ملف قاعدة البيانات. إن نشرت APK جديدًا برقم إصدار أعلى، تستدعي Android تلقائيًا onUpgrade على جهاز المستخدم. إن غيّرت المخطط دون رفع رقم الإصدار، لن يرى المستخدمون الحاليون التغيير أبدًا.

إنشاء فئة المساعد

أدناه مساعد كامل لقاعدة بيانات إدارة مهام بسيطة. فيها جدول واحد tasks مع مفتاح أساسي يزداد تلقائيًا، وعنوان، ومستوى أولوية، وراية منطقية للإنجاز.

package com.example.taskapp.db; import android.content.Context; import android.database.sqlite.SQLiteDatabase; import android.database.sqlite.SQLiteOpenHelper; public class TaskDbHelper extends SQLiteOpenHelper { private static final String DATABASE_NAME = "tasks.db"; private static final int DATABASE_VERSION = 1; // ثوابت أسماء الأعمدة — عرّفها مرة واحدة واستخدمها في كل مكان public static final String TABLE_TASKS = "tasks"; public static final String COL_ID = "_id"; public static final String COL_TITLE = "title"; public static final String COL_PRIORITY = "priority"; public static final String COL_DONE = "done"; private static final String SQL_CREATE = "CREATE TABLE " + TABLE_TASKS + " (" + COL_ID + " INTEGER PRIMARY KEY AUTOINCREMENT, " + COL_TITLE + " TEXT NOT NULL, " + COL_PRIORITY + " INTEGER DEFAULT 1, " + COL_DONE + " INTEGER DEFAULT 0" + // لا يوجد نوع BOOLEAN في SQLite؛ 0 = false، 1 = true ")"; private static final String SQL_DROP = "DROP TABLE IF EXISTS " + TABLE_TASKS; public TaskDbHelper(Context context) { super(context, DATABASE_NAME, null, DATABASE_VERSION); } @Override public void onCreate(SQLiteDatabase db) { db.execSQL(SQL_CREATE); } @Override public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) { // ترقية تدميرية بسيطة — مقبولة أثناء التطوير فقط. // في الإنتاج: استخدم ALTER TABLE أو نمط النسخ وإعادة التسمية. db.execSQL(SQL_DROP); onCreate(db); } }
عرّف أسماء الأعمدة كثوابت عامة. استخدام TaskDbHelper.COL_TITLE في كل الكود يعني أن أي خطأ إملائي يتحوّل إلى خطأ في وقت الترجمة وليس تعطّلًا في وقت التشغيل. لا تكتب أسماء الأعمدة كسلاسل نصية حرفية في أماكن متعددة أبدًا.

إدراج البيانات

لكتابة صف تحصل على مقبض قاعدة بيانات قابل للكتابة، تملأ خريطة ContentValues، ثم تستدعي insert(). القيمة المُعادة هي معرّف _id للصف الجديد، أو -1 عند الفشل.

import android.content.ContentValues; import android.database.sqlite.SQLiteDatabase; public class TaskRepository { private final TaskDbHelper helper; public TaskRepository(Context context) { this.helper = new TaskDbHelper(context); } public long addTask(String title, int priority) { // getWritableDatabase() ينشئ الملف أو يفتحه؛ آمن استدعاؤه عدة مرات SQLiteDatabase db = helper.getWritableDatabase(); ContentValues values = new ContentValues(); values.put(TaskDbHelper.COL_TITLE, title); values.put(TaskDbHelper.COL_PRIORITY, priority); values.put(TaskDbHelper.COL_DONE, 0); long newId = db.insert(TaskDbHelper.TABLE_TASKS, null, values); // لا تستدعِ db.close() هنا — راجع الملاحظة أدناه return newId; } }
لا تستدعِ db.close() بعد كل عملية. تخزّن SQLiteOpenHelper مقبض قاعدة البيانات داخليًا. إغلاقه بعد كل استدعاء يُجبر على إعادة فتح مكلفة في المرة التالية. بدلًا من ذلك، دع المساعد يعيش كـ singleton طويل الأمد (نطاق Application أو مستودع مُحقَن بالاعتماديات) وأغلقه فقط في Application.onTerminate() — الذي نادرًا ما يُستدعى على الأجهزة الحقيقية. يُستعيد نظام التشغيل مقبض الملف عند موت العملية.

الاستعلام عن البيانات

يُعدّ db.query() طريقة مساعدة منظّمة تبني جملة SELECT نيابةً عنك. تُعيد كائن Cursor — مؤشر على مجموعة النتائج تتكرر عليه صفًا بصف. أغلق المؤشر دائمًا في كتلة finally أو باستخدام try-with-resources.

import android.database.Cursor; import java.util.ArrayList; import java.util.List; public List<String> getPendingTaskTitles() { SQLiteDatabase db = helper.getReadableDatabase(); // query(table, columns, selection, selectionArgs, groupBy, having, orderBy) Cursor cursor = db.query( TaskDbHelper.TABLE_TASKS, new String[]{ TaskDbHelper.COL_ID, TaskDbHelper.COL_TITLE }, TaskDbHelper.COL_DONE + " = ?", // WHERE done = ? new String[]{ "0" }, // ? = 0 (مرّرها دائمًا كـ String) null, // GROUP BY null, // HAVING TaskDbHelper.COL_PRIORITY + " DESC" // ORDER BY priority DESC ); List<String> titles = new ArrayList<>(); try { while (cursor.moveToNext()) { int titleIndex = cursor.getColumnIndexOrThrow(TaskDbHelper.COL_TITLE); titles.add(cursor.getString(titleIndex)); } } finally { cursor.close(); } return titles; }
لا تبنِ جمل WHERE بتسلسل السلاسل النصية أبدًا. استخدم دائمًا المحدد ? ومرّر القيم في selectionArgs. تسلسل مدخلات المستخدم مباشرةً في سلسلة الاختيار يُعرّض تطبيقك لحقن SQL — حتى في قاعدة بيانات محلية، يمكن للمدخلات المشوّهة أن تُفسد البيانات أو تحذفها.

تحديث الصفوف وحذفها

كلتا العمليتين تتبعان نفس نمط الإدراج: احصل على مقبض قابل للكتابة، وصف ما تريد تغييره، ومرّر معايير الاختيار عبر المحددات ?.

// تمييز مهمة كمنجزة public int markDone(long taskId) { SQLiteDatabase db = helper.getWritableDatabase(); ContentValues values = new ContentValues(); values.put(TaskDbHelper.COL_DONE, 1); return db.update( TaskDbHelper.TABLE_TASKS, values, TaskDbHelper.COL_ID + " = ?", new String[]{ String.valueOf(taskId) } ); // تُعيد عدد الصفوف المتأثرة } // حذف مهمة public int deleteTask(long taskId) { SQLiteDatabase db = helper.getWritableDatabase(); return db.delete( TaskDbHelper.TABLE_TASKS, TaskDbHelper.COL_ID + " = ?", new String[]{ String.valueOf(taskId) } ); }

تنفيذ SQL خام

للاستعلامات المعقدة — الانضمامات متعددة الجداول، والاستعلامات الفرعية، والتجميعات — استخدم db.rawQuery(). ما زالت تقبل المحددات ? للسلامة وتُعيد Cursor مثل query().

Cursor cursor = db.rawQuery( "SELECT priority, COUNT(*) AS cnt FROM tasks WHERE done = ? GROUP BY priority", new String[]{ "0" } );

هجرات المخطط في الممارسة

لنفترض أن الإصدار 2 من تطبيقك يضيف عمود due_date. ترفع رقم الإصدار إلى 2 وتتعامل مع كلا مسارَي الترقية:

@Override public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) { if (oldVersion < 2) { // ALTER TABLE آمنة؛ تضيف عمودًا قابلًا للإهمال إلى الصفوف الموجودة db.execSQL("ALTER TABLE " + TaskDbHelper.TABLE_TASKS + " ADD COLUMN due_date INTEGER DEFAULT 0"); } // مستقبلًا: if (oldVersion < 3) { ... } }
استخدم نمط if (oldVersion < N) المتسلسل، وليس switch. مستخدم يرقّي من الإصدار 1 إلى 3 يجب أن يمرّ بجميع الهجرات الوسيطة. كل كتلة if تراكمية، فتُنفَّذ كلها بالتسلسل لذلك المستخدم.

اعتبارات الخيوط

تعدّ getWritableDatabase() وgetReadableDatabase() آمنتَين للخيوط في حد ذاتهما، لكن كائن SQLiteDatabase المُعاد ليس آمنًا للخيوط إن شاركته عبر خيوط متعددة دون تزامن. القاعدة الأسلم للـ SQLite الخام: نفّذ جميع عمليات قاعدة البيانات خارج الخيط الرئيسي. استخدم Executor أو أنماط LiveData التي ستتعلمها مع Room. إعاقة الخيط الرئيسي بعملية قاعدة بيانات تُسبّب أخطاء ANR (التطبيق لا يستجيب) تحت الحمل.

الخلاصة

تمنحك SQLite على Android قاعدة بيانات علائقية كاملة في ملف واحد. أنشئ فئة فرعية من SQLiteOpenHelper، وعرّف مخططك في onCreate، وهاجره في onUpgrade، ونفّذ عمليات CRUD عبر واجهة ContentValues وCursor. استخدم المحددات ? دائمًا، وأغلق المؤشرات دائمًا، وانقل عمل قاعدة البيانات دائمًا خارج الخيط الرئيسي. في الدرس التالي سترى كيف تلفّ Room هذا المحرك نفسه بأمان الأنواع، والتحقق من الاستعلامات في وقت الترجمة، وتكامل LiveData.