التخزين المحلي للبيانات

استراتيجيات ترحيل قاعدة البيانات عبر جميع حلول التخزين

16 دقيقة الدرس 12 من 12

استراتيجيات ترحيل قاعدة البيانات عبر جميع حلول التخزين

كل تطبيق Flutter في الإنتاج سيحتاج في نهاية المطاف إلى تطوير مخطط بياناته — أعمدة جديدة، مفاتيح مُعادة التسمية، أنواع بيانات متغيرة، أو جداول جديدة كليًا. بدون استراتيجية ترحيل منضبطة، قد يُفسد تحديث التطبيق بيانات المستخدم أو يتعطل عند الإطلاق. يغطي هذا الدرس كيفية تعامل كل حل تخزين رئيسي مع تطور المخطط: sqflite مع ترقيات SQL الخام، وDrift مع الترحيل خطوة بخطوة، وHive مع إصدارات TypeAdapter، وSharedPreferences لإعادة تسمية المفاتيح.

المبدأ الأساسي: الترحيل هو تحويل أحادي الاتجاه ومُعنوَن ينقل البيانات من إصدار المخطط N إلى الإصدار N+1. يجب أن يكون كل ترحيل متحمّلًا بما يكفي ليبقى سليمًا بعد تشغيل منقطع أو متكرر، ويجب ألا يدمر أبدًا البيانات التي يتوقع المستخدم رؤيتها بعد الترقية.

sqflite: onUpgrade مع ALTER TABLE

يكشف sqflite ثلاثة استدعاءات على openDatabase: onCreate، وonUpgrade، وonDowngrade. عندما يكون version في استدعائك أعلى من الإصدار المخزن في ملف قاعدة البيانات، يُطلق sqflite onUpgrade مع أرقام الإصدارات القديمة والجديدة. يستخدم معالج الترقية المتين حلقة while (أو switch مع fall-through) حتى يطبّق التطبيق الذي يقفز عبر إصدارات متعددة كل ترحيل وسيط:

Future<Database> openAppDatabase() async {
  return openDatabase(
    'app.db',
    version: 3,
    onCreate: (db, version) async {
      // إنشاء المخطط الأحدث دائمًا
      await db.execute('''
        CREATE TABLE notes (
          id      INTEGER PRIMARY KEY AUTOINCREMENT,
          title   TEXT    NOT NULL,
          body    TEXT    NOT NULL,
          pinned  INTEGER NOT NULL DEFAULT 0,
          color   TEXT    NOT NULL DEFAULT '#FFFFFF'
        )
      ''');
    },
    onUpgrade: (db, oldVersion, newVersion) async {
      // تطبيق كل خطوة ترحيل بالتتابع
      if (oldVersion < 2) {
        // v1 -> v2: إضافة علامة التثبيت
        await db.execute(
          'ALTER TABLE notes ADD COLUMN pinned INTEGER NOT NULL DEFAULT 0',
        );
      }
      if (oldVersion < 3) {
        // v2 -> v3: إضافة عمود اللون
        await db.execute(
          "ALTER TABLE notes ADD COLUMN color TEXT NOT NULL DEFAULT '#FFFFFF'",
        );
      }
    },
  );
}
قيود ALTER TABLE في SQLite: يدعم SQLite فقط ADD COLUMN وRENAME COLUMN (الأخيرة منذ SQLite 3.25). لحذف عمود أو تغيير نوعه يجب إنشاء جدول جديد، ونسخ البيانات، وحذف الجدول القديم، وإعادة التسمية — وهو نمط إعادة بناء الجدول الكلاسيكي.

Drift: MigrationStrategy مع الترحيلات المتدرجة

يُغلّف Drift (المعروف سابقًا بـ Moor) مكتبة sqflite ويوفر نهجًا مكتوبًا ومُولَّدًا كوديًا للترحيلات. تُعلن schemaVersion على فئة قاعدة البيانات وتُعيد MigrationStrategy من getter الخاص بـ migration. يفهم مساعد Migrator في Drift كائنات الجدول المُولَّدة فلا تحتاج نادرًا لكتابة SQL خام:

@DriftDatabase(tables: [Notes, Tags])
class AppDatabase extends _$AppDatabase {
  AppDatabase(QueryExecutor e) : super(e);

  @override
  int get schemaVersion => 3;

  @override
  MigrationStrategy get migration => MigrationStrategy(
    onCreate: (Migrator m) async {
      await m.createAll(); // إنشاء كل الجداول بأحدث مخطط
    },
    onUpgrade: (Migrator m, int from, int to) async {
      if (from < 2) {
        // إضافة جدول Tags المُقدَّم في v2
        await m.createTable(tags);
      }
      if (from < 3) {
        // إضافة عمود archivedAt قابل للنلية في Notes في v3
        await m.addColumn(notes, notes.archivedAt);
      }
    },
    beforeOpen: (OpenedDatabase details) async {
      // تفعيل المفاتيح الأجنبية عند كل فتح (SQLite تعطّلها افتراضيًا)
      await customStatement('PRAGMA foreign_keys = ON');
    },
  );
}
اختبارات مخطط Drift: شغّل verifyDatabase() في اختبار وحدة بعد كل ترحيل لمقارنة المخطط الحي بتوقعات Drift المُولَّدة. هذا يكتشف الأعمدة الناقصة قبل أن يراها المستخدمون.

Hive: إصدارات TypeAdapter

Hive هو مخزن مفتاح-قيمة يُسلسل الكائنات باستخدام TypeAdapter. عند إضافة حقول أو إزالتها، يجب معالجة التغيير داخل طريقة read للمحول بتوفير قيم افتراضية للحقول المفقودة. معرّفات الحقول المُعيَّنة بـ @HiveField(n) هي مفاتيح القرص — لا تُعيد استخدام أو تغيير معرّف حقل، أضف فقط معرّفات جديدة:

// الإصدار 1 من النموذج — حقلان
@HiveType(typeId: 0)
class UserProfile extends HiveObject {
  @HiveField(0)
  String name;

  @HiveField(1)
  String email;

  UserProfile({required this.name, required this.email});
}

// الإصدار 2 — إضافة bio؛ الحقل(2) آمن للإضافة،
// السجلات الموجودة تقرأ null وتحصل على القيمة الافتراضية
@HiveType(typeId: 0)
class UserProfile extends HiveObject {
  @HiveField(0)
  String name;

  @HiveField(1)
  String email;

  @HiveField(2)
  String bio; // حقل جديد — القيمة الافتراضية تُعيَّن في read() للمحول

  UserProfile({required this.name, required this.email, this.bio = ''});
}

// إعادة توليد المحول مع build_runner:
// flutter pub run build_runner build --delete-conflicting-outputs
// طريقة read() للمحول المُولَّد تضع null كقيمة افتراضية للحقول المفقودة تلقائيًا.
لا تُعد استخدام معرّف حقل Hive أبدًا. إذا كان الحقل 2 في السابق age وحذفته، فإن الحقل 2 متقاعد إلى الأبد. إعادة استخدامه لحقل جديد ستُفسد أي سجل لا يزال يخزن نوع البيانات القديم على القرص.

ترحيل مفاتيح SharedPreferences

لا يوجد إصدار مخطط في SharedPreferences، لذا تتطلب إعادة تسمية المفاتيح حارسًا لمرة واحدة مخزنًا في التفضيلات نفسها. النمط هو: اقرأ المفتاح القديم، اكتبه تحت المفتاح الجديد، ثم احذف المفتاح القديم، وسجّل علامة إصدار الترحيل حتى يعمل الترحيل مرة واحدة فقط:

Future<void> migratePreferences() async {
  final prefs = await SharedPreferences.getInstance();

  const migrationKey = 'prefs_migration_version';
  final currentMigration = prefs.getInt(migrationKey) ?? 0;

  if (currentMigration < 1) {
    // v0 -> v1: إعادة تسمية 'user_name' إلى 'display_name'
    final oldValue = prefs.getString('user_name');
    if (oldValue != null) {
      await prefs.setString('display_name', oldValue);
      await prefs.remove('user_name');
    }
    await prefs.setInt(migrationKey, 1);
  }

  if (currentMigration < 2) {
    // v1 -> v2: ترحيل boolean 'dark_mode' إلى string 'theme_mode'
    final darkMode = prefs.getBool('dark_mode');
    if (darkMode != null) {
      await prefs.setString('theme_mode', darkMode ? 'dark' : 'light');
      await prefs.remove('dark_mode');
    }
    await prefs.setInt(migrationKey, 2);
  }
}

مكان استدعاء الترحيلات في تطبيقك

يجب تشغيل جميع الترحيلات قبل أي وصول للبيانات. المكان الأأمن هو داخل تسلسل تهيئة تطبيقك، قبل runApp()، أو داخل شاشة splash/تحميل تُبوّب التنقل:

  • استدعاء openAppDatabase() (sqflite/Drift) عند الإطلاق — تعمل الترحيلات تلقائيًا داخل onUpgrade.
  • استدعاء migratePreferences() صراحةً مرة واحدة قبل قراءة أي مفتاح تفضيلات.
  • تسجيل محولات Hive المُحدَّثة قبل فتح أي صندوق — يتعامل Hive مع إضافة الحقول الافتراضية بشفافية عند القراءة التالية.

الملخص

لكل طبقة تخزين عقدها الترحيلي الخاص: يستخدم sqflite استدعاءات onUpgrade المُعنوَنة مع SQL الشرطي؛ يضيف Drift MigrationStrategy مكتوبة مع مساعدات مُولَّدة؛ يعتمد Hive على معرّفات الحقول الثابتة وتعيين القيم الافتراضية في المحول؛ يحتاج SharedPreferences علامة إصدار ترحيل يدوية. مطبّقة باتساق، تضمن هذه الاستراتيجيات نجاة بيانات كل مستخدم من تحديثات التطبيق دون فقدان أو تلف.

النقطة الرئيسية: زد دائمًا إصدار المخطط، ولا تتخطَّ خطوات الترحيل، ولا تُعد استخدام المعرّفات المتقاعدة، وشغّل الترحيلات قبل أي وصول للبيانات. هذه القواعد الأربع تحافظ على بيانات المستخدم في أمان عبر كل إصدار.