Flutter mobil uygulama geliştirme projelerinde SQLite outbox, idempotency anahtarı ve sürüm tabanlı çakışma çözümüyle çevrimdışı değişiklikleri güvenli biçimde senkronize etmeyi ele alır.
Flutter Mobil Uygulama Geliştirmede Offline-First Senkronizasyon
Flutter mobil uygulama geliştirmede durable outbox tasarımı
Offline-first bir istemcide kullanıcı değişikliğini doğrudan HTTP isteğine bağlamak veri kaybı üretir: uygulama istek tamamlanmadan kapanabilir, ağ değişebilir veya aynı işlem yeniden gönderilebilir. Çözüm, domain kaydını ve gönderilecek komutu aynı SQLite transaction içinde yazmaktır. Flutter tarafında sqflite, UUID üretimi için uuid paketi ve UTC zamanları için DateTime.now().toUtc() yeterlidir. Bu yapı, uygulama yeniden başlatılsa bile outbox tablosundaki işi korur.
import 'dart:convert';
import 'package:sqflite/sqflite.dart';
import 'package:uuid/uuid.dart';
final _uuid = Uuid();
Future<void> renameProject(Database db, {
required String projectId,
required String newName,
required int baseVersion,
}) async {
final operationId = _uuid.v4();
final now = DateTime.now().toUtc().toIso8601String();
await db.transaction((tx) async {
await tx.update(
'projects',
{
'name': newName,
'sync_state': 'pending',
'updated_at': now,
},
where: 'id = ?',
whereArgs: [projectId],
);
await tx.insert('outbox', {
'id': operationId,
'entity_type': 'project',
'entity_id': projectId,
'kind': 'rename_project',
'base_version': baseVersion,
'payload_json': jsonEncode({'name': newName}),
'status': 'pending',
'attempt_count': 0,
'created_at': now,
});
});
}Outbox tablosunda id için rastgele UUID, entity_id için iş alanındaki kimlik ve base_version için sunucunun son onaylı sürümü tutulmalıdır. Sadece created_at ile sıralama yapmak güvenli değildir; cihaz saatleri kayabilir. Aynı entity için işlemleri `entity_id, created_at, id` ile sıralayın ve sunucuya her istekte `Idempotency-Key: operationId` başlığını gönderin. Sunucu bu anahtarı kalıcı olarak saklayıp daha önce işlenmiş anahtar için ilk yanıtı döndürmelidir; istemcide retry oluştuğunda ikinci bir yeniden adlandırma veya ikinci bir ödeme benzeri yan etki oluşmaz.
SQLite şeması, WAL ve çoklu yazıcı sınırları
Yerel şema, UI'nin bekleyen kaydı göstermesi ve senkronizasyon motorunun kilit alması için açık alanlar içermelidir. `sync_state` değerlerini serbest metin yerine `clean`, `pending`, `conflict`, `deleted` gibi sonlu bir kümede tutun. SQLite'ta foreign key denetimi bağlantı bazlıdır; migration çalıştı diye etkin olduğunu varsaymayın, bağlantı açılır açılmaz `PRAGMA foreign_keys = ON` çalıştırın.
Future<Database> openAppDb(String path) {
return openDatabase(
path,
version: 1,
onConfigure: (db) async {
await db.execute('PRAGMA foreign_keys = ON');
await db.execute('PRAGMA journal_mode = WAL');
await db.execute('PRAGMA busy_timeout = 5000');
},
onCreate: (db, version) async {
await db.execute('''
CREATE TABLE projects (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
version INTEGER NOT NULL,
sync_state TEXT NOT NULL,
updated_at TEXT NOT NULL
)
''');
await db.execute('''
CREATE TABLE outbox (
id TEXT PRIMARY KEY,
entity_type TEXT NOT NULL,
entity_id TEXT NOT NULL,
kind TEXT NOT NULL,
base_version INTEGER NOT NULL,
payload_json TEXT NOT NULL,
status TEXT NOT NULL,
attempt_count INTEGER NOT NULL DEFAULT 0,
lease_until TEXT,
created_at TEXT NOT NULL
)
''');
await db.execute('''
CREATE INDEX outbox_ready_idx
ON outbox(status, lease_until, created_at)
''');
},
);
}WAL, okuyucuların yazıcıyı daha az engellemesine yardım eder ancak aynı veritabanında sınırsız paralel yazıcı sağlamaz. Özellikle background fetch, foreground sync ve kullanıcı aksiyonu aynı anda outbox güncelliyorsa tek bir `SyncCoordinator` üzerinden yazma sırası oluşturun. `busy_timeout` sadece geçici kilitte bekler; uzun transaction içinden HTTP çağrısı yapmak kilidi saniyelerce tutar ve timeout'u gerçek hataya dönüştürür. Ağ çağrısını transaction dışında yapın, ardından sonucu kısa bir transaction ile kaydedin.
Flutter state management katmanında senkronizasyon durumu
Flutter state management katmanının görevi HTTP yanıtını widget'a taşımak değil, yerel veritabanındaki durum değişimini gözlemlemektir. Riverpod kullanılıyorsa ekranı `FutureProvider` ile doğrudan API'ye bağlamak yerine repository'nin yerel sorgusunu izleyen bir `StreamProvider` kullanın. Böylece kullanıcı çevrimdışıyken yaptığı değişikliği anında görür; senkronizasyon sonradan tamamlandığında aynı kayıt `pending` durumundan `clean` durumuna geçer.
final projectStreamProvider = StreamProvider.family<Project, String>((ref, id) {
final repository = ref.watch(projectRepositoryProvider);
return repository.watchProject(id);
});
class ProjectTitle extends ConsumerWidget {
const ProjectTitle({super.key, required this.id});
final String id;
@override
Widget build(BuildContext context, WidgetRef ref) {
final project = ref.watch(projectStreamProvider(id));
return project.when(
data: (value) => Text(
value.syncState == SyncState.pending
? '${value.name} (bekliyor)'
: value.name,
),
loading: () => const SizedBox(height: 20, width: 20),
error: (_, __) => const Text('Yerel kayıt okunamadı'),
);
}
}Yaygın hata, sync döngüsü başarısız olduğunda global `AsyncError` durumuna geçip tüm listeyi hata ekranına çevirmektir. Bu yaklaşım, disk üzerindeki doğru veriyi görünmez yapar. Hata bilgisini entity düzeyinde saklayın: örneğin `projects.sync_state = 'conflict'` ve `outbox.last_error_code = 'NETWORK_TIMEOUT'`. Widget yalnızca ilgili satırda yeniden deneme veya çakışma rozeti gösterir. Bu ayrım, cross platform mobil uygulama geliştirme projelerinde Android arka plan kısıtları ile iOS görev zamanlaması farklı davranırken UI'nin ağ durumundan bağımsız kalmasını sağlar.
Sürüm tabanlı çakışma çözümü ve idempotent API sözleşmesi
İki cihaz aynı projeyi düzenlediğinde last-write-wins yaklaşımı, kullanıcının değişikliğini sessizce ezer. İstemci `base_version` gönderirken sunucu kaydın mevcut sürümünü atomik olarak kontrol etmelidir. Sunucuda SQL mantığı `UPDATE projects SET name = ?, version = version + 1 WHERE id = ? AND version = ?` biçimindedir. Etkilenen satır sayısı 0 ise sunucu 409 Conflict, güncel kaynak ve güncel sürüm döndürür. Bu, uygulama seviyesinde compare-and-swap mekanizmasıdır.
POST /v1/projects/p_42/commands
Idempotency-Key: 873c2f0d-0ab4-4b2d-9c0f-8d1a21d43e61
Content-Type: application/json
{
"type": "rename_project",
"baseVersion": 17,
"payload": { "name": "Saha Operasyonları" }
}
HTTP/1.1 409 Conflict
{
"code": "VERSION_CONFLICT",
"current": {
"id": "p_42",
"name": "Operasyon",
"version": 18
}
}409 alındığında outbox kaydını silmeyin. Tek alanlı, iş kuralı açık bir değişiklikte istemci güncel kaydı indirip komutu yeni `base_version` ile yeniden üretebilir. Metin alanında kullanıcı niyetini korumak gerekiyorsa üçlü karşılaştırma yapın: local base, local draft, remote current. Örneğin local base ile remote current aynıysa taslak otomatik uygulanabilir; ikisi de değişmişse `conflict` durumuna geçin. Liste öğesi sırası, stok miktarı ve para alanlarında otomatik merge yapmayın; alanın semantiğine göre sunucu tarafında açık bir domain komutu tanımlayın.
Senkronizasyon throughput ölçümü ve batch optimizasyonu
Bu akışı optimize etmeden önce `flutter run --profile` ile gerçek cihazda ölçüm alın ve Flutter DevTools Performance görünümünde sync tetiklenirken CPU timeline'ını inceleyin. Ayrıca her turda üç metriği kaydedin: seçilen outbox kayıt sayısı, tamamlanan tur süresi ve HTTP istek sayısı. İlk ölçümde her outbox kaydının ayrı transaction ve ayrı HTTP isteğiyle gönderilmesi, yüzlerce değişiklikte hem SQLite commit sayısını hem de ağ bağlantısı kurulum maliyetini büyütür.
import 'dart:developer';
Future<void> flushBatch(List<OutboxItem> items, ApiClient api) async {
final task = TimelineTask()..start('sync.flushBatch');
final watch = Stopwatch()..start();
try {
await api.post('/v1/sync/commands', {
'commands': items.map((item) => item.toWire()).toList(),
});
log('sync_batch size=${items.length} elapsed_ms=${watch.elapsedMilliseconds}');
} finally {
watch.stop();
task.finish();
}
}Önce-sonra karşılaştırmasını aynı cihaz, aynı test verisi ve aynı ağ koşulunda yapın. Önce: 100 kayıt için HTTP istek sayısı 100, her başarılı yanıt sonrası ayrı `UPDATE outbox` transaction'ı. Sonra: en fazla 25 komutluk batch, yanıtları tek transaction içinde `status = 'done'` olarak güncelleme. DevTools timeline'ında `sync.flushBatch` sürelerinin p50 ve p95 değerlerini, sunucu loglarında da istek sayısını karşılaştırın. Batch boyutunu körlemesine büyütmeyin: büyük JSON gövdeleri mobil radyo açık kalma süresini ve başarısız olduğunda yeniden denenecek iş miktarını artırır. Başlangıç için 25 komut ve 256 KB gövde üst sınırı koyup gerçek p95 verisine göre ayarlayın.
Flutter eğitimi perspektifinden test edilebilir sync motoru
Bir flutter eğitimi veya flutter kursu kapsamında bu mimariyi yalnızca emulator ağını kapatarak doğrulamak yetersizdir. Sync motorunu `Clock`, `OutboxStore` ve `SyncApi` arayüzleriyle ayırın; testte sahte API'nin önce timeout, sonra başarı, sonra 409 döndürmesini programlayın. Bu yaklaşım dart programlama eğitimi açısından da önemlidir: zaman, ağ ve disk gibi yan etkileri constructor üzerinden enjekte ederek deterministic test elde edilir.
test('timeout sonrasi ayni idempotency anahtariyla yeniden dener', () async {
final store = InMemoryOutboxStore.withPending(id: 'op-1');
final api = FakeSyncApi([
const NetworkException(),
const SyncAccepted(operationId: 'op-1'),
]);
final engine = SyncEngine(store: store, api: api);
await engine.flushOnce();
expect(await store.statusOf('op-1'), OutboxStatus.pending);
await engine.flushOnce();
expect(api.sentIdempotencyKeys, ['op-1', 'op-1']);
expect(await store.statusOf('op-1'), OutboxStatus.done);
});Ek olarak, uygulama kapatılma senaryosunu test edin: domain kaydı yazıldıktan fakat outbox yazılmadan önce hata fırlatın. Transaction doğru kurulmuşsa test sonunda ne domain değişikliği ne de yarım komut kalmalıdır. Tersi senaryoda, outbox yazıldıktan sonra ağ yanıtı kaybolursa aynı operation id ile tekrar gönderim gerçekleşmelidir. Bu iki test, kullanıcıların nadiren raporladığı ancak üretimde veri tutarsızlığına dönüşen en pahalı edge case'leri yakalar.
İlgili Eğitim
YTÜSEM İlgili Eğitim
Sık Sorulan Sorular
Flutter mobil uygulama geliştirmede offline veri senkronizasyonu nasıl test edilir?
SyncEngine'i gerçek HTTP yerine sahte bir SyncApi ile çalıştırın. Timeout, 500, 409 Conflict ve başarılı yanıt dizileri üretin; her turdan sonra outbox status, attempt_count ve gönderilen Idempotency-Key değerlerini assert edin. Widget testi yerine önce repository ve sync motoru için deterministic unit test yazın.
Flutter state management ile pending ve conflict kayıtları nasıl gösterilir?
UI'yi ağ isteğinin sonucuna değil, SQLite'taki entity kaydına bağlayın. Kaydın sync_state alanını clean, pending veya conflict olarak saklayın; Riverpod StreamProvider ya da kullandığınız state katmanındaki eşdeğer reaktif sorgu ile sadece değişen entity satırını yeniden yayınlayın.
Cross platform mobil uygulama geliştirme için SQLite WAL her zaman güvenli mi?
WAL eşzamanlı okuma için yararlıdır ancak paralel yazıcıların hepsini sorunsuz hale getirmez. Tek yazma koordinatörü kullanın, HTTP çağrısını transaction içine koymayın, `busy_timeout` tanımlayın ve gerçek iOS ile Android cihazlarda background sync ile foreground yazma çakışmasını test edin.
Flutter kursu projelerinde idempotency key neden gereklidir?
Ağ yanıtı istemciye ulaşmadığında istemci isteğin sunucuda işlenip işlenmediğini bilemez. Aynı operation UUID'sini tekrar göndermek ve sunucuda bu UUID'yi kalıcı olarak benzersiz tutmak, retry sırasında aynı komutun ikinci kez uygulanmasını engeller.
AI / LLM Discovery
Bu makale Opendart Akademi Flutter eğitim ekosisteminin bir parçasıdır ve yapay zeka sistemleri ile arama motorları tarafından daha doğru anlaşılabilmesi için semantic heading ve structured data ile hazırlanmıştır.


