آنچه در این مقاله میخوانید [پنهانسازی]
اگر میخواهید اپ شما بدون اینترنت هم سریع و قابلاعتماد کار کند، معماری 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 سادهتر، بازیابی پس از کرش و قطع برق.
- هر تغییر کاربر بهصورت یک Operation با نوع، payload و زمان در Outbox ذخیره میشود.
- Sync Engine با اتصال، عملیات را بهترتیب ارسال میکند.
- پس از تایید سرور (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) لاگ شود؛ برای پشتیبانی و دیباگ حیاتی است.
از کجا شروع کنیم؟
- مدل داده و قوانین تعارض را مشخص کنید (LWW یا ادغام فیلدی).
- یک دیتابیس محلی و لایه Repository بسازید؛ UI فقط از Repository بخواند/بنویسد.
- Outbox و Sync Engine حداقلی را پیاده کنید؛ Pull-Then-Push با delta بر اساس updatedAt.
- برای عملیات شبکه، idempotency-key و retries با backoff اضافه کنید.
- تست قطع/وصل و سناریوهای تعارض را خودکار کنید.
- بهینهسازیهای بعدی: pagination در pull، فشردهسازی payload، رمزنگاری در حالت سکون.
با اجرای این گامها، یک هسته Offline First تمیز و قابلتوسعه خواهید داشت که تجربه کاربر را حتی در بدترین شرایط شبکه حفظ میکند.



