بگذارید با یک سناریو شروع کنم که احتمالاً برای خودتان هم پیش آمده: کاربرتان وارد آسانسور میشود، یا سوار مترو، یا شاید همان مسافری است که در روستایی دور از دکل مخابراتی نشسته. اپ شما باید همچنان کار کند. صادقانه بگویم، در سال 2026 دیگر کسی حوصلهی پیام «اتصال به اینترنت برقرار نیست» را ندارد. کاربر میخواهد یادداشت بنویسد، فرم پر کند، سفارش ثبت کند — و اپلیکیشن، هر وقت که اینترنت برگشت، سر و ته همه چیز را با سرور هماهنگ کند. اسم این رویکرد را گذاشتهایم Offline-First.
این مقاله ادامهی مستقیم سری «ساخت اپلیکیشن واقعی در .NET MAUI» است. در قسمتهای قبل سراغ ذخیرهسازی محلی با SQLite، اتصال به REST API و احراز هویت JWT رفتیم. حالا قطعهی گمشده را اضافه میکنیم؛ همان لایهای که این سه را به یک سیستم پایدار، پاسخگو و قابلاعتماد در شرایط اتصال ضعیف تبدیل میکند.
چرا Offline-First اهمیت دارد؟
در معماری سنتی، اپلیکیشن مستقیم با API صحبت میکند و دیتابیس محلی صرفاً یک کش است. مشکل کجاست؟ هر تأخیر شبکه به تأخیر UI ترجمه میشود و هر قطعی، میشود یک ارور قرمز وسط صفحه. در رویکرد Offline-First منطق برعکس میشود:
- اپ همیشه از دیتابیس محلی میخواند و در آن مینویسد.
- یک موتور همگامسازی در پسزمینه، تغییرات محلی را به سرور میفرستد و تغییرات سرور را پایین میآورد.
- کاربر هرگز منتظر شبکه نمیماند؛ شبکه از یک «پیشنیاز کارکرد» تبدیل میشود به یک «جزئیات پیادهسازی».
نتیجه؟ زمان پاسخ UI زیر ۱۶ میلیثانیه (یعنی همان ۶۰ فریم در ثانیه)، فارغ از اینکه کاربر آنتن دارد یا نه. در اپهای میدانی، فروشگاهی، پزشکی و حتی همان اپ یادداشتبرداری روزمره، این میشود یک مزیت رقابتی واقعی — نه ادعای بازاریابی.
معماری Offline-First در یک نگاه
قبل از اینکه سراغ کد برویم، بیایید تصویر بزرگ را ببینیم. سه لایهی اصلی داریم:
- لایهی دادهی محلی (SQLite) — منبع حقیقت برای UI. شامل جداول دامنه (مثلاً
Notes) و جداول متادیتای همگامسازی. - لایهی موتور همگامسازی (Sync Engine) — صف Outbox، منطق Push/Pull، تشخیص شبکه و حل تعارض، همه اینجا.
- لایهی API — همان REST endpointهایی که از مهر زمانی یا شمارهی نسخه برای همگامسازی تدریجی استفاده میکنند.
جریان داده ساده است: ViewModel ← Repository ← SQLite، و بهطور موازی Sync Engine ↔ SQLite ↔ API. نکتهی مهم اینجاست: ViewModel هرگز و تحت هیچ شرایطی مستقیم با API حرف نمیزند.
مدلسازی داده برای همگامسازی
هر موجودیتی که میخواهید همگامسازی شود، به سه فیلد متادیتا نیاز دارد. به همین سادگی:
using SQLite;
public class Note
{
[PrimaryKey]
public Guid Id { get; set; } // GUID تا کلید روی کلاینت تولید شود
public string Title { get; set; } = "";
public string Body { get; set; } = "";
// متادیتای همگامسازی
public DateTime UpdatedAtUtc { get; set; } // زمان آخرین تغییر روی هر طرف
public long RowVersion { get; set; } // شمارهی نسخهی سرور (برای حل تعارض)
public SyncState SyncState { get; set; } // وضعیت محلی
public bool IsDeleted { get; set; } // حذف نرم برای انتشار حذف به سرور
}
public enum SyncState
{
Synced = 0, // با سرور هماهنگ است
PendingPush = 1, // تغییر محلی، هنوز ارسال نشده
PendingDelete = 2 // محلی حذف شده، هنوز اعلام نشده
}
چند نکتهی کلیدی که نباید از آنها رد شد:
- کلید اصلی GUID است، نه auto-increment. چرا؟ چون میخواهیم کلاینت بتواند آفلاین رکورد بسازد، بدون اینکه نگران تداخل با IDهای سرور باشد.
- حذف نرم. اگر فقط رکورد را پاک کنید، سرور هرگز نمیفهمد که باید روی دستگاههای دیگر هم پاک شود. بهجایش
IsDeleted=trueبگذارید و فقط بعد از تأیید سرور، رکورد را بهطور فیزیکی از روی دستگاه پاک کنید. - RowVersion از سرور میآید (در SQL Server همان
rowversion، در PostgreSQL همxminیا یک ستونbigserial). با این فیلد، تشخیص تعارض دیگر حدس و گمان نیست.
الگوی Outbox: ضمانت تحویل داده
سادهترین کار این است که هنگام نوشتن، هم در دیتابیس بنویسید و هم به API بفرستید. ولی این الگو، خب، شکننده است. اگر API پاسخ بدهد ولی پاسخ به کلاینت نرسد، یا اگر اپ وسط کار کرش کند، داده گم میشود. راه حل؟ Outbox Pattern.
هر تغییر را در یک جدول OutboxEntries ذخیره میکنید. موتور همگامسازی این جدول را بهترتیب پردازش میکند و فقط بعد از پاسخ موفق سرور، رکورد را از Outbox حذف میکند. تمام.
public class OutboxEntry
{
[PrimaryKey, AutoIncrement]
public int Id { get; set; }
public string EntityType { get; set; } = ""; // مثلاً "Note"
public Guid EntityId { get; set; }
public OutboxOperation Operation { get; set; } // Create / Update / Delete
public string Payload { get; set; } = ""; // JSON serialized
public DateTime CreatedAtUtc { get; set; }
public int RetryCount { get; set; }
public DateTime? NextAttemptAtUtc { get; set; }
}
public enum OutboxOperation { Create, Update, Delete }
حالا Repository بهجای فراخوانی API، در یک تراکنش هم رکورد دامنه و هم Outbox را مینویسد:
public class NoteRepository : INoteRepository
{
private readonly SQLiteAsyncConnection _db;
public NoteRepository(SQLiteAsyncConnection db) => _db = db;
public async Task SaveAsync(Note note)
{
note.UpdatedAtUtc = DateTime.UtcNow;
note.SyncState = SyncState.PendingPush;
await _db.RunInTransactionAsync(tran =>
{
tran.InsertOrReplace(note);
tran.Insert(new OutboxEntry
{
EntityType = nameof(Note),
EntityId = note.Id,
Operation = OutboxOperation.Update,
Payload = JsonSerializer.Serialize(note, JsonOpts.Default),
CreatedAtUtc = DateTime.UtcNow,
NextAttemptAtUtc = DateTime.UtcNow
});
});
}
}
تراکنش تضمین میکند که هرگز رکوردی در دامنه باشد ولی در Outbox نباشد، یا برعکس. این، صادقانه بگویم، هستهی Offline-First است. هر چه روی این پایه بسازید، روی پایهی محکم ایستاده.
تشخیص وضعیت شبکه با IConnectivity
.NET MAUI یک API داخلی به نام IConnectivity دارد و خبر خوب اینکه دیگر نیازی به پکیجهای قدیمی مثل Plugin.Connectivity نیست. ثبتنام آن در MauiProgram.cs:
builder.Services.AddSingleton(Connectivity.Current);
و در سرویس همگامسازی:
public class ConnectivityWatcher
{
private readonly IConnectivity _connectivity;
private readonly ISyncEngine _sync;
public ConnectivityWatcher(IConnectivity connectivity, ISyncEngine sync)
{
_connectivity = connectivity;
_sync = sync;
_connectivity.ConnectivityChanged += OnConnectivityChanged;
}
private async void OnConnectivityChanged(object? sender, ConnectivityChangedEventArgs e)
{
if (e.NetworkAccess == NetworkAccess.Internet)
await _sync.SyncAsync(CancellationToken.None);
}
}
این کلاس را در App.xaml.cs یا یک Hosted Service بهمحض راهاندازی اپ نمونهسازی کنید تا گوشدادن به رویداد شروع شود. اگر این مرحله را فراموش کنید — که خب، خودم چند بار فراموش کردهام — کلاس ساخته نمیشود و رویداد هیچوقت گرفته نمیشود.
پیادهسازی موتور همگامسازی
موتور همگامسازی دو مرحله دارد: Push (ارسال تغییرات محلی) و Pull (دریافت تغییرات سرور). همیشه Push را اول اجرا کنید. چرا؟ چون اگر سرور هم تغییری روی همان رکورد داشته باشد، با Push اول میتوانید تعارض را زود تشخیص دهید.
گام Push
public async Task PushAsync(CancellationToken ct)
{
var pending = await _db.Table<OutboxEntry>()
.Where(e => e.NextAttemptAtUtc <= DateTime.UtcNow)
.OrderBy(e => e.Id)
.Take(50)
.ToListAsync();
foreach (var entry in pending)
{
ct.ThrowIfCancellationRequested();
try
{
var response = entry.Operation switch
{
OutboxOperation.Create or OutboxOperation.Update
=> await _api.UpsertNoteAsync(JsonSerializer.Deserialize<Note>(entry.Payload)!, ct),
OutboxOperation.Delete
=> await _api.DeleteNoteAsync(entry.EntityId, ct),
_ => throw new InvalidOperationException()
};
await ApplyServerResponseAsync(entry, response);
await _db.DeleteAsync(entry);
}
catch (ApiConflictException conflict)
{
await ResolveConflictAsync(entry, conflict.ServerVersion);
await _db.DeleteAsync(entry);
}
catch (HttpRequestException)
{
entry.RetryCount++;
entry.NextAttemptAtUtc = DateTime.UtcNow.Add(BackoffDelay(entry.RetryCount));
await _db.UpdateAsync(entry);
}
}
}
private static TimeSpan BackoffDelay(int retry) =>
TimeSpan.FromSeconds(Math.Min(300, Math.Pow(2, retry))); // exponential backoff تا 5 دقیقه
گام Pull (همگامسازی تدریجی)
بهجای دریافت کل جدول هر دفعه (که هم ترافیک میخورد و هم باتری)، فقط رکوردهایی را بگیرید که بعد از آخرین همگامسازی موفق تغییر کردهاند. این کار با ذخیرهی LastSyncCursor انجام میشود:
public async Task PullAsync(CancellationToken ct)
{
var cursor = await _state.GetCursorAsync("Note");
var page = await _api.GetNotesChangedSinceAsync(cursor, ct);
await _db.RunInTransactionAsync(tran =>
{
foreach (var serverNote in page.Items)
{
var local = tran.Find<Note>(serverNote.Id);
// اگر تغییر محلی در انتظار push وجود دارد، با تعارض روبهرو هستیم
if (local is { SyncState: SyncState.PendingPush })
{
_conflicts.Enqueue(local, serverNote);
continue;
}
if (serverNote.IsDeleted)
tran.Delete<Note>(serverNote.Id);
else
tran.InsertOrReplace(serverNote with { SyncState = SyncState.Synced });
}
});
await _state.SetCursorAsync("Note", page.NextCursor);
}
سرور در این الگو باید endpointی مثل GET /api/notes?changedSince={cursor}&limit=200 داشته باشد که نتایج را بهترتیب RowVersion برمیگرداند و یک NextCursor در پاسخ میگذارد. اسم این الگو در ادبیات صنعت delta sync است و در پروژههای واقعی، تجربهی شخصی من این بوده که ترافیک شبکه را بین ۸۰ تا ۹۰ درصد کاهش میدهد — بسته به اینکه چقدر دادهتان داغ است.
حل تعارض: LWW، Version Vectors و رویکرد ترکیبی
تعارض زمانی پیش میآید که هم کاربر روی دستگاه و هم سرور (یا کاربر دستگاه دیگرش) یک رکورد را همزمان تغییر دادهاند. سه استراتژی رایج داریم:
| استراتژی | پیچیدگی | ریسک | مناسب برای |
|---|---|---|---|
| Last-Write-Wins (LWW) | کم | از دست رفتن داده | یادداشت شخصی، تنظیمات |
| Version Vectors | متوسط | کم | چندکاربره، چنددستگاهه |
| Manual / Hybrid | زیاد | صفر در سطح داده | پزشکی، مالی، حقوقی |
برای اکثر اپها، LWW با مقایسهی UpdatedAtUtc یا RowVersion کافی است:
private async Task ResolveConflictAsync(OutboxEntry entry, Note serverVersion)
{
var localVersion = JsonSerializer.Deserialize<Note>(entry.Payload)!;
// Last-Write-Wins بر اساس UpdatedAtUtc
var winner = localVersion.UpdatedAtUtc > serverVersion.UpdatedAtUtc
? localVersion
: serverVersion;
winner.RowVersion = serverVersion.RowVersion; // اجبار به نسخهی فعلی سرور برای retry
winner.SyncState = winner == localVersion
? SyncState.PendingPush
: SyncState.Synced;
await _db.InsertOrReplaceAsync(winner);
}
اگر دادهی شما حساستر است (مثلاً پروندهی پزشکی یا قرارداد)، نسخهی محلی و سرور را در یک صفحهی «حل تعارض» به کاربر نشان دهید و اجازه دهید خودش انتخاب کند. در اپلیکیشنهای پزشکی و قراردادی، این کار نه یک گزینه، بلکه یک الزام است.
همگامسازی پسزمینه در Android و iOS
تا اینجا همگامسازی فقط وقتی اتفاق میافتد که اپ باز است یا در پیشزمینه. اما اگر کاربر اپ را ببندد و فردا بازش کند چه؟ هر پلتفرم سازوکار متفاوت خودش را دارد.
Android: WorkManager
روی اندروید، بهترین گزینه WorkManager است که Doze Mode و App Standby را محترم میشمارد. در .NET MAUI 9 و بالاتر میتوانید مستقیماً از Bindingهای آن استفاده کنید:
// Platforms/Android/SyncWorker.cs
[Service(Name = "com.example.SyncWorker")]
public class SyncWorker : Worker
{
public SyncWorker(Context context, WorkerParameters parameters)
: base(context, parameters) { }
public override Result DoWork()
{
var sync = IPlatformApplication.Current!
.Services.GetRequiredService<ISyncEngine>();
sync.SyncAsync(CancellationToken.None).GetAwaiter().GetResult();
return Result.InvokeSuccess();
}
}
// زمانبندی هنگام راهاندازی اپ
var request = PeriodicWorkRequest
.Builder.From<SyncWorker>(TimeSpan.FromMinutes(15))
.SetConstraints(new Constraints.Builder()
.SetRequiredNetworkType(NetworkType.Connected)
.Build())
.Build();
WorkManager.GetInstance(Platform.AppContext)
.EnqueueUniquePeriodicWork(
"background-sync",
ExistingPeriodicWorkPolicy.Keep,
request);
iOS: BGTaskScheduler
روی iOS سراغ BGTaskScheduler بروید. اول در Info.plist نوع تسک را اعلام کنید:
<key>BGTaskSchedulerPermittedIdentifiers</key>
<array>
<string>com.example.app.sync</string>
</array>
سپس در AppDelegate ثبتش کنید:
// Platforms/iOS/AppDelegate.cs
public override bool FinishedLaunching(UIApplication app, NSDictionary opts)
{
BGTaskScheduler.Shared.Register(
"com.example.app.sync",
null,
async task => await HandleSync((BGAppRefreshTask)task));
return base.FinishedLaunching(app, opts);
}
private async Task HandleSync(BGAppRefreshTask task)
{
ScheduleNext();
var sync = IPlatformApplication.Current!.Services.GetRequiredService<ISyncEngine>();
using var cts = new CancellationTokenSource();
task.ExpirationHandler = () => cts.Cancel();
try { await sync.SyncAsync(cts.Token); task.SetTaskCompleted(true); }
catch { task.SetTaskCompleted(false); }
}
یک نکتهی مهم: iOS هیچ تضمینی برای زمان دقیق اجرای تسک پسزمینه نمیدهد. سیستم بر اساس الگوی استفادهی کاربر تصمیم میگیرد چه وقت اجرا کند. به همین خاطر، همگامسازی هنگام بازگشت اپ به foreground را هم نباید فراموش کنید — این میشود تور ایمنی شما.
تجربهی کاربری: نشان دادن وضعیت همگامسازی
کاربر باید بداند دادهاش امن است. حتی اگر همه چیز پشت پرده عالی کار کند، اگر کاربر این را نبیند، از اپ بیاعتماد میشود. سه نشانگر ساده اما مؤثر:
- آیکون کنار هر آیتم: ابر سبز برای Synced، فلش زرد برای PendingPush، علامت تعجب قرمز برای Conflict.
- نوار وضعیت در بالای صفحه: «همگامسازی شد ۲ دقیقه پیش» یا «۳ تغییر در انتظار».
- صفحهی Sync Center: لیست رکوردهای در صف Outbox، با گزینهی «تلاش مجدد».
یک ViewModel ساده برای نوار وضعیت:
public partial class SyncStatusViewModel : ObservableObject
{
[ObservableProperty] private int pendingCount;
[ObservableProperty] private DateTime? lastSyncAt;
[ObservableProperty] private bool isSyncing;
public SyncStatusViewModel(ISyncEngine sync, INoteRepository repo)
{
sync.SyncStarted += (_, _) => IsSyncing = true;
sync.SyncCompleted += async (_, _) =>
{
IsSyncing = false;
LastSyncAt = DateTime.Now;
PendingCount = await repo.CountPendingAsync();
};
}
}
تست استراتژی همگامسازی
سه سناریو که حتماً، بدون استثنا، باید تست شوند:
- Happy path: ایجاد آفلاین → اتصال → push → pull موفق.
- تعارض: ویرایش روی دو دستگاه بهطور همزمان → بررسی استراتژی LWW یا UI حل تعارض.
- قطعی در میانهی Push: push روی سرور موفق ولی پاسخ به کلاینت نرسد → در retry بعدی نباید رکورد تکراری بسازد (idempotency با کلید GUID رعایت شده).
برای تستهای خودکار از یک FakeApiClient استفاده کنید که میتواند به دستور تست شما، تأخیر، خطای 5xx یا تعارض شبیهسازی کند. تستهای end-to-end را هم با Airplane Mode دستی روی دستگاه واقعی اجرا کنید. صادقانه میگویم: هیچ شبیهسازی جای آن لحظهای را نمیگیرد که گوشی واقعی در دستتان است و دارید هواپیماییاش میکنید.
اشتباهات رایجی که باید از آنها دوری کنید
- کلید auto-increment روی کلاینت: دو دستگاه میتوانند ID یکسان تولید کنند. همیشه و همیشه از GUID استفاده کنید.
- full sync روی هر اتصال: ترافیک را منفجر میکند. delta cursor الزامی است، نه پیشنهادی.
- نادیده گرفتن idempotency: اگر سرور دو بار درخواست Create بگیرد، نباید دو رکورد بسازد. کلید client-generated GUID + endpoint upsert این مشکل را تمیز حل میکند.
- اعتماد به ساعت دستگاه: ساعت دستگاهها میتواند نادرست باشد (و معمولاً هست). برای تشخیص تعارض، ترجیحاً از
RowVersionسرور استفاده کنید. - ذخیرهی توکن JWT در Outbox: Payload نباید شامل اطلاعات حساس باشد. توکن را همیشه از SecureStorage هنگام ارسال بخوانید.
سوالات متداول
تفاوت Offline-First با Caching معمولی چیست؟
در caching، دادهی محلی صرفاً یک کپی موقت از سرور است و writeها مستقیم به API میروند. در Offline-First، منبع حقیقت برای UI همان دیتابیس محلی است؛ writeها اول محلی انجام میشوند و بعد موتور همگامسازی آنها را به سرور میبرد. تفاوت کجا آشکار میشود؟ هنگام قطع شبکه: caching فقط خواندن آفلاین میدهد، Offline-First هم خواندن و هم نوشتن آفلاین.
آیا باید از Azure Mobile Apps DataSync یا فریمورکهای آماده استفاده کنم؟
بستگی دارد. اگر backend شما ASP.NET Core است و انعطافپذیری در طراحی API برایتان مهم نیست، DataSync سرعت توسعه را بهطور محسوس بالا میبرد. اما اگر API شما از قبل وجود دارد، REST قراردادی غیرقابل تغییر دارد، یا میخواهید کنترل کامل روی منطق conflict resolution داشته باشید — همان چیزی که در این مقاله ساختیم منعطفتر و کموابستهتر است.
برای تشخیص تعارض از UpdatedAt استفاده کنم یا RowVersion؟
RowVersion (یا معادلش در دیتابیس سرور) قابل اعتمادتر است، چون مستقل از ساعت سیستم است و در هر تغییر روی سرور بهطور اتمیک افزایش مییابد. UpdatedAt برای نمایش به کاربر و LWW در سطح کلاینت مفید است، ولی منبع تشخیص تعارض را روی سرور نگه دارید. به ساعت موبایل اعتماد نکنید — همین.
چگونه از Offline-First با لیستهای بسیار بزرگ (مثلاً ۱۰۰هزار رکورد) استفاده کنم؟
کل دیتاست را روی دستگاه دانلود نکنید (سهم باتری و فضای کاربر را به هدر نمیدهیم). از partial replica استفاده کنید: فقط دادههایی که کاربر اخیراً دیده یا متعلق به اوست را sync کنید. در سمت UI، CollectionView با RemainingItemsThreshold برای incremental loading از خود SQLite عالی کار میکند. ترکیب delta sync + partial replica + on-demand load حتی روی دیتاستهای میلیونی تجربهی روان میدهد.
آیا SignalR جایگزین این معماری میشود؟
خیر. SignalR برای دریافت تغییرات real-time عالی است، اما فقط وقتی اپ در پیشزمینه باز باشد کار میکند. میتوانید از SignalR بهعنوان trigger همگامسازی استفاده کنید (وقتی پیام رسید، Pull را اجرا کن)، اما لایهی Outbox + SQLite همچنان لازم است تا اپ شما هنگام بسته بودن یا قطع اتصال داده گم نکند. SignalR و Offline-First مکملاند، نه جایگزین.
گام بعدی
حالا که اپ شما در شرایط اتصال ضعیف هم پایدار است، گام منطقی بعدی بهبود کارایی UI برای نمایش این دادهها است: virtualization در CollectionView، compiled bindings و تمپلیتهای بهینه. در مقالهی بعدی این سری به سراغ بهینهسازی CollectionView برای دیتاستهای بزرگ میرویم — موضوعی که در اپهای Offline-First با هزاران رکورد محلی، اهمیتش دو چندان است. تا آن موقع، تستِ Airplane Mode را فراموش نکنید.