اگر می‌خواهید اپ شما بدون اینترنت هم سریع و قابل‌اعتماد کار کند، معماری Offline First در اپلیکیشن موبایل یعنی همه تعامل‌های کاربر ابتدا روی دیتابیس محلی انجام شود و همگام‌سازی با سرور در پس‌زمینه و هنگام اتصال انجام گیرد. با این رویکرد، UI همیشه پاسخگو می‌ماند، داده‌ها در دستگاه ذخیره می‌شوند و تعارض‌های احتمالی با سیاست مشخص حل می‌گردند.

Offline First دقیقا چیست و چگونه کار می کند؟

Offline First یعنی منبع حقیقت (Source of Truth) برای UI دستگاه محلی است و شبکه تنها برای همگام‌سازی به‌کار می‌رود. جریان معمول چنین است: کاربر عملی انجام می‌دهد، داده در دیتابیس محلی ثبت می‌شود، UI بلافاصله به‌روزرسانی می‌گردد (Optimistic UI)، عملیات در صف همگام‌سازی قرار می‌گیرد و هنگام اتصال، تغییرات batched به سرور ارسال و پاسخ‌ها با دیتای محلی ادغام می‌شوند.

نتیجه: تاخیر شبکه تجربه کاربر را خراب نمی‌کند، قطعی اینترنت باعث از دست رفتن داده نمی‌شود و معماری شما به‌صورت ذاتی مقاوم‌تر است.

چه زمانی این معماری را انتخاب کنیم؟

  • اپ‌های میدانی و بیرون از دفتر: فروش، پشتیبانی، لجستیک، بازاریابی میدانی.
  • ویژگی‌های حساس به تاخیر: چت ساده، ثبت فرم‌ها، چک‌لیست‌ها، یادداشت‌ها، مدیریت کارها.
  • کاربران با اتصال ناپایدار یا پرهزینه.
  • نیاز به تجربه یکپارچه بین سفر، مترو، تونل یا شبکه‌های شلوغ.

اگر اپ شما فقط محتوای لحظه‌ای نمایش می‌دهد و بدون اتصال عملا ارزشی ندارد، هزینه Offline First ممکن است بیش از فایده باشد.

اجزای معماری و جریان داده

یک پیاده‌سازی عملی معمولا این لایه‌ها را دارد:

  • دیتابیس محلی: SQLite (Android/iOS)، Realm یا ObjectBox برای ذخیره تراکنشی.
  • Data Access Layer (Repository/DAO): تنها نقطه دسترسی خواندن/نوشتن به داده.
  • Outbox/Queue: صف عملیات برای ارسال به سرور (create/update/delete با payload و زمان).
  • Sync Engine: زمان‌بندی، ارسال batched، دریافت تغییرات، ادغام و حل تعارض.
  • Network Monitor: تشخیص اتصال واقعی و backoff نمایی برای تلاش مجدد.
  • Versioning/Change Tracking: نسخه رکورد یا timestamps برای تشخیص تفاوت‌ها.

الگوی Outbox (صف عملیات)

Outbox فهرستی از «قصد تغییر» است که دستگاه ثبت می‌کند و بعدا به سرور می‌فرستد. مزایا: ارسال مرتب و تکرارپذیر، idempotency ساده‌تر، بازیابی پس از کرش و قطع برق.

  1. هر تغییر کاربر به‌صورت یک Operation با نوع، payload و زمان در Outbox ذخیره می‌شود.
  2. Sync Engine با اتصال، عملیات را به‌ترتیب ارسال می‌کند.
  3. پس از تایید سرور (200 OK با نسخه جدید)، وضعیت Operation به done تغییر می‌کند.

سیاست های همگام سازی

  • Pull-Then-Push: ابتدا دریافت تغییرات سرور، سپس ارسال عملیات محلی. ریسک تعارض کمتر.
  • Push-Then-Pull: ابتدا ارسال عملیات محلی؛ برای Optimistic UI سریع‌تر به‌نظر می‌رسد.
  • Delta Sync: فقط تفاوت‌ها بر اساس version/updatedAt یا Log سرور.
  • Eventual Consistency: پذیرش همگرایی تدریجی؛ برای اکثر سناریوهای موبایل کافی است.

مدیریت تعارض: از LWW تا Merge سفارشی

تعارض زمانی رخ می‌دهد که یک رکورد هم در دستگاه و هم در سرور/دستگاه‌های دیگر تغییر کرده باشد.

  • Last-Write-Wins (LWW): آخرین updatedAt برنده است. ساده اما ممکن است تغییرات ظریف را از دست بدهد.
  • Field-Level Merge: ادغام بر اساس فیلد؛ مثلا عنوان از نسخه جدیدتر و توضیح از نسخه قدیمی‌تر.
  • Merge تابع‌محور: برای ساختارهای تجمعی (لیست وظایف) از قوانین خاص دامنه استفاده کنید.
  • CRDTs: برای همکاری همزمان پیچیده مناسب است، اما هزینه یادگیری و پیچیدگی بالاتر دارد.

یک راه عملی: در هر رکورد updatedAt و lastEditor را نگه دارید، تغییرات را فیلدبه‌فیلد مقایسه کنید و در تعارض‌های غیرقابل حل به کاربر پیام غیرمسدودکننده بدهید تا انتخاب کند.

مثال عملی: لیست کارها با Flutter + sqflite

هدف: ثبت وظایف به‌صورت آفلاین، همگام‌سازی هنگام اتصال، و حل تعارض ساده LWW.

پیش نیاز: Flutter، بسته‌های sqflite و connectivity_plus، یک API ساده REST با مسیرهای /tasks و /sync. کد صرفا آموزشی است.

import 'dart:convert';
import 'package:sqflite/sqflite.dart';
import 'package:path/path.dart';
import 'package:connectivity_plus/connectivity_plus.dart';
import 'package:http/http.dart' as http;
import 'package:uuid/uuid.dart';

final _uuid = Uuid();

class Task {
  final String id;
  final String title;
  final bool completed;
  final DateTime updatedAt;

  Task({required this.id, required this.title, required this.completed, required this.updatedAt});

  Map<String, dynamic> toMap() => {
    'id': id,
    'title': title,
    'completed': completed ? 1 : 0,
    'updatedAt': updatedAt.toUtc().toIso8601String(),
  };

  static Task fromMap(Map<String, dynamic> m) => Task(
    id: m['id'],
    title: m['title'],
    completed: (m['completed'] as int) == 1,
    updatedAt: DateTime.parse(m['updatedAt']).toUtc(),
  );
}

class OutboxOp {
  final String opId;
  final String entityId;
  final String action; // create|update|delete
  final String payload; // JSON
  final DateTime createdAt;

  OutboxOp({required this.opId, required this.entityId, required this.action, required this.payload, required this.createdAt});

  Map<String, dynamic> toMap() => {
    'opId': opId,
    'entityId': entityId,
    'action': action,
    'payload': payload,
    'createdAt': createdAt.toUtc().toIso8601String(),
  };
}

class LocalDb {
  Database? _db;

  Future<Database> get db async {
    if (_db != null) return _db!;
    final path = join(await getDatabasesPath(), 'tasks.db');
    _db = await openDatabase(path, version: 1, onCreate: (d, v) async {
      await d.execute('CREATE TABLE tasks(id TEXT PRIMARY KEY, title TEXT, completed INTEGER, updatedAt TEXT)');
      await d.execute('CREATE TABLE outbox(opId TEXT PRIMARY KEY, entityId TEXT, action TEXT, payload TEXT, createdAt TEXT)');
      await d.execute('CREATE INDEX idx_outbox_created ON outbox(createdAt)');
    });
    return _db!;
  }

  Future<List<Task>> getTasks() async {
    final d = await db;
    final res = await d.query('tasks', orderBy: 'updatedAt DESC');
    return res.map(Task.fromMap).toList();
  }

  Future<void> upsertTask(Task t, {bool enqueue = true}) async {
    final d = await db;
    await d.insert('tasks', t.toMap(), conflictAlgorithm: ConflictAlgorithm.replace);
    if (enqueue) {
      final op = OutboxOp(
        opId: _uuid.v4(),
        entityId: t.id,
        action: 'upsert',
        payload: jsonEncode(t.toMap()),
        createdAt: DateTime.now().toUtc(),
      );
      await d.insert('outbox', op.toMap(), conflictAlgorithm: ConflictAlgorithm.abort);
    }
  }

  Future<void> deleteTask(String id, {bool enqueue = true}) async {
    final d = await db;
    await d.delete('tasks', where: 'id = ?', whereArgs: [id]);
    if (enqueue) {
      final op = OutboxOp(
        opId: _uuid.v4(),
        entityId: id,
        action: 'delete',
        payload: jsonEncode({'id': id}),
        createdAt: DateTime.now().toUtc(),
      );
      await d.insert('outbox', op.toMap());
    }
  }

  Future<List<OutboxOp>> pendingOps() async {
    final d = await db;
    final res = await d.query('outbox', orderBy: 'createdAt ASC', limit: 50);
    return res.map((m) => OutboxOp(
      opId: m['opId'] as String,
      entityId: m['entityId'] as String,
      action: m['action'] as String,
      payload: m['payload'] as String,
      createdAt: DateTime.parse(m['createdAt'] as String).toUtc(),
    )).toList();
  }

  Future<void> markOpDone(String opId) async {
    final d = await db;
    await d.delete('outbox', where: 'opId = ?', whereArgs: [opId]);
  }

  Future<DateTime?> lastSyncTs() async {
    final d = await db;
    final res = await d.rawQuery('SELECT MAX(updatedAt) as ts FROM tasks');
    final v = res.first['ts'] as String?;
    return v == null ? null : DateTime.parse(v).toUtc();
  }

  Future<void> applyRemote(Task t) async {
    // LWW: اگر نسخه سرور جدیدتر است، جایگزین کن
    final d = await db;
    final existing = await d.query('tasks', where: 'id = ?', whereArgs: [t.id], limit: 1);
    if (existing.isEmpty) {
      await d.insert('tasks', t.toMap());
    } else {
      final local = Task.fromMap(existing.first);
      if (t.updatedAt.isAfter(local.updatedAt)) {
        await d.update('tasks', t.toMap(), where: 'id = ?', whereArgs: [t.id]);
      }
    }
  }
}

class SyncService {
  final LocalDb local;
  final String baseUrl;
  bool _running = false;

  SyncService(this.local, this.baseUrl);

  Future<bool> get isOnline async {
    final c = await Connectivity().checkConnectivity();
    return c != ConnectivityResult.none;
  }

  Future<void> trySync() async {
    if (_running) return;
    if (!await isOnline) return;
    _running = true;
    try {
      // 1) Pull
      final lastTs = await local.lastSyncTs();
      final pullUrl = lastTs == null
          ? Uri.parse('$baseUrl/sync/full')
          : Uri.parse('$baseUrl/sync/delta?since=${Uri.encodeComponent(lastTs.toIso8601String())}');
      final pullRes = await http.get(pullUrl);
      if (pullRes.statusCode == 200) {
        final list = jsonDecode(pullRes.body) as List;
        for (final m in list) {
          await local.applyRemote(Task.fromMap(Map<String, dynamic>.from(m)));
        }
      }

      // 2) Push (batched)
      final ops = await local.pendingOps();
      for (final op in ops) {
        final url = Uri.parse('$baseUrl/tasks/${op.action}');
        final res = await http.post(
          url,
          headers: {
            'Content-Type': 'application/json',
            'Idempotency-Key': op.opId, // از اجرای دوباره در سرور جلوگیری می‌کند
          },
          body: op.payload,
        );
        if (res.statusCode == 200 || res.statusCode == 201 || res.statusCode == 204) {
          await local.markOpDone(op.opId);
        } else {
          // توقف تا تلاش بعدی با backoff
          break;
        }
      }
    } finally {
      _running = false;
    }
  }
}

روش بررسی عملکرد: اینترنت را قطع کنید، چند کار ایجاد و ویرایش کنید، سپس اتصال را برقرار کنید و trySync را فراخوانی کنید؛ باید تغییرات به سرور ارسال و پاسخ‌ها در دیتای محلی ادغام شوند. برای ارزیابی LWW، همان رکورد را در سرور و کلاینت تغییر دهید و ببینید نسخه جدیدتر باقی می‌ماند.

انتخاب پایگاه داده محلی

انتخاب موتور ذخیره‌سازی بر پایداری و سادگی کد تاثیر مستقیم دارد.

گزینهویژگی کلیدیمناسب برای
SQLite (Room/ Core Data/ sqflite)تراکنشی، استاندارد، پشتیبانی گستردهاکثر اپ‌ها، کنترل کامل روی کوئری
Realmمدل شی‌گرا، واکنشی، سینک تجاری در برخی پلن‌هابه‌روزرسانی زنده و سادگی مدل
ObjectBoxکارایی بالا، API شی‌گراکلاینت‌های با داده حجیم و نیاز به سرعت

اگر تیم شما به SQL مسلط است و کنترل ریز می‌خواهید، SQLite با لایه Repository بهترین شروع است. برای مدل شی‌گرا و واکنشی، Realm یا ObjectBox می‌تواند ساده‌تر باشد.

نکات اجرایی و خطاهای رایج

  • Idempotency را جدی بگیرید: هر Operation باید با کلید یکتا در سرور قابل تکرار باشد.
  • Clock Skew: به ساعت دستگاه اعتماد نکنید؛ در پاسخ سرور updatedAt مرجع را برگردانید.
  • صف Outbox را کوچک نگه دارید: batch 50-200 موردی با backoff نمایی.
  • باینری‌ها را در فایل ذخیره کنید نه دیتابیس؛ در DB فقط متادیتا بماند.
  • UI Optimistic: تغییر را بلافاصله نشان دهید اما وضعیت «در حال همگام‌سازی» را به‌صورت قابل فهم نمایش دهید.
  • محدودیت‌های iOS/Android برای بک‌گراند: از WorkManager/BackgroundTasks استفاده کنید، اما انتظار همگام‌سازی دائمی در پس‌زمینه نداشته باشید.
  • خطاهای شبکه را دسته‌بندی کنید: خطای موقت (net/timeout) => retry؛ خطای منطقی (۴۰۹ تعارض) => نیاز به ادغام/اطلاع.

امنیت و حریم خصوصی در حالت آفلاین

  • رمزنگاری در حالت سکون (At-Rest): از SQLCipher یا لایه رمزنگاری کتابخانه انتخابی استفاده کنید.
  • حداقل‌گرایی داده: فقط آنچه برای حالت آفلاین لازم است ذخیره کنید؛ داده حساس را cache نکنید.
  • توکن‌های دسترسی: زمان‌بندی نوسازی را پیاده کنید و در حالت آفلاین از Refresh Token با سیاست‌های ایمن استفاده کنید.
  • پاک‌سازی از راه دور: مسیرهایی برای ابطال نشست و پاک‌سازی امن در اتصال بعدی طراحی کنید.

چگونه صحت پیاده سازی را بسنجیم؟

  • شاخص‌ها: نرخ موفقیت همگام‌سازی، اندازه متوسط صف، زمان همگرایی، نرخ تعارض و زمان حل تعارض.
  • تست‌های اجباری: قطع/وصل مکرر شبکه، ارسال‌های تکراری، همزمانی تغییر کلاینت و سرور روی یک رکورد، خرابی وسط تراکنش.
  • ثبت رویدادها: هر Operation با وضعیت (queued/sent/ack/failed) لاگ شود؛ برای پشتیبانی و دیباگ حیاتی است.

از کجا شروع کنیم؟

  1. مدل داده و قوانین تعارض را مشخص کنید (LWW یا ادغام فیلدی).
  2. یک دیتابیس محلی و لایه Repository بسازید؛ UI فقط از Repository بخواند/بنویسد.
  3. Outbox و Sync Engine حداقلی را پیاده کنید؛ Pull-Then-Push با delta بر اساس updatedAt.
  4. برای عملیات شبکه، idempotency-key و retries با backoff اضافه کنید.
  5. تست قطع/وصل و سناریوهای تعارض را خودکار کنید.
  6. بهینه‌سازی‌های بعدی: pagination در pull، فشرده‌سازی payload، رمزنگاری در حالت سکون.

با اجرای این گام‌ها، یک هسته Offline First تمیز و قابل‌توسعه خواهید داشت که تجربه کاربر را حتی در بدترین شرایط شبکه حفظ می‌کند.