• 27.08.2026 21:32:06
  • Admin Admin

Flutter mobil uygulama geliştirme projelerinde çevrimdışı yazmaları kaybetmeden sıraya alan outbox mimarisini, idempotency anahtarlarını, sürüm çakışmalarını ve test edilebilir senkronizasyon akışını inceliyoruz.

Flutter Mobil Uygulama Geliştirmede Offline-First Outbox Tasarımı

Flutter mobil uygulama geliştirmede outbox veri sözleşmesi

Bir flutter eğitimi veya flutter kursu içeriğinde offline-first akış yalnızca yerel cache olarak anlatılırsa kritik bir boşluk kalır: kullanıcı değişikliği yaptıktan sonra uygulama kapanabilir ve HTTP isteği hiç gönderilemeyebilir. Çözüm, domain kaydını ve gönderilecek mutasyonu aynı SQLite transaction içinde yazmaktır. Drift ile her mutasyona değişmez bir mutationId, sunucudaki son bilinen kaydı temsil eden baseVersion ve yeniden deneme zamanı ekleyin. Aynı transaction kullanılmazsa, yerel görev güncellenip outbox satırı yazılmadan uygulama öldürülebilir; bu durumda kullanıcı arayüzü yeni değeri gösterirken sunucu sonsuza kadar eski değerde kalır.

class OutboxMutations extends Table {
  TextColumn get mutationId => text()();
  TextColumn get aggregateType => text()();
  TextColumn get aggregateId => text()();
  TextColumn get operation => text()();
  TextColumn get payloadJson => text()();
  IntColumn get baseVersion => integer()();
  IntColumn get attempt => integer().withDefault(const Constant(0))();
  DateTimeColumn get nextAttemptAt => dateTime()();
  TextColumn get leaseOwner => text().nullable()();
  DateTimeColumn get leasedUntil => dateTime().nullable()();
  DateTimeColumn get createdAt => dateTime()();

  @override
  Set<Column> get primaryKey => {mutationId};
}

Future<void> renameTask(Task task, String title) {
  final now = DateTime.now().toUtc();
  final changed = task.copyWith(title: title, isDirty: true);
  final mutation = OutboxMutationsCompanion.insert(
    mutationId: const Uuid().v7(),
    aggregateType: 'task',
    aggregateId: task.id,
    operation: 'patch',
    payloadJson: jsonEncode({'title': title}),
    baseVersion: task.serverVersion,
    nextAttemptAt: now,
    createdAt: now,
  );

  return transaction(() async {
    await into(tasks).insertOnConflictUpdate(changed);
    await into(outboxMutations).insert(mutation);
  });
}

Payload içine tüm Task nesnesini kopyalamak yerine değişen alanları içeren patch saklayın. Örneğin başlık değişiminde {"title":"Yeni başlık"} yazmak, başka cihazın değiştirdiği dueDate alanını istemeden geri alma riskini azaltır. dart programlama eğitimi kapsamında burada öğrenilmesi gereken incelik, JSON serileştirmesinin domain modelinden ayrı bir ağ sözleşmesi olmasıdır: nullable alanı kaldırmak ile null göndererek alanı temizlemek farklı işlemlerdir. Bu nedenle patch DTO'sunda alanın varlığını containsKey ile, değerini ise ayrı olarak değerlendirin.

Outbox tüketicisi, lease ve idempotency ile güvenli senkronizasyon

Senkronizasyon tetikleyicisi sadece connectivity_plus olamaz. Paket ağ türünün değiştiğini bildirir, fakat bu ağın internete veya API'nize erişebildiğini kanıtlamaz. Uygulama açılışında, kullanıcı manuel yenilediğinde ve bağlantı olayı geldiğinde aynı drainOutbox() işini çağırın; gerçek erişilebilirliği Dio isteğinin sonucu belirlesin. Android tarafında WorkManager, iOS tarafında BGTaskScheduler ile çalışan bir görev de aynı işlevi tetikleyebilir, ancak her iki platformun arka plan çalışma süresi sınırlı olduğundan kuyruk işleyicisi kısa, durdurulabilir ve tekrar başlatılabilir olmalıdır.

Aynı kuyruk satırını foreground senkronizasyonu ve arka plan görevi eşzamanlı çekebilir. Bunu önlemek için satırı göndermeden önce compare-and-set ile lease alın. Aşağıdaki sorgunun rowsAffected == 1 sonucu, bu çalıştırıcının gönderim hakkını aldığını gösterir. Lease süresi, en kötü beklenen HTTP timeout değerinden uzun olmalıdır; 20 saniye timeout kullanılıyorsa 5 saniyelik lease, yavaş isteğin ortasında ikinci bir gönderime izin verir.

Future<bool> claimMutation(String id, String workerId) async {
  final now = DateTime.now().toUtc();
  final leaseUntil = now.add(const Duration(seconds: 45));

  final changed = await customUpdate(
    '''UPDATE outbox_mutations
       SET lease_owner = ?, leased_until = ?
       WHERE mutation_id = ?
         AND (leased_until IS NULL OR leased_until < ?)''',
    variables: [
      Variable.withString(workerId),
      Variable.withDateTime(leaseUntil),
      Variable.withString(id),
      Variable.withDateTime(now),
    ],
    updates: {outboxMutations},
  );
  return changed == 1;
}

Future<void> send(OutboxMutation row) async {
  final response = await dio.patch(
    '/tasks/${row.aggregateId}',
    data: jsonDecode(row.payloadJson),
    options: Options(
      validateStatus: (_) => true,
      headers: {
        'Idempotency-Key': row.mutationId,
        'If-Match': '"${row.baseVersion}"',
      },
    ),
  );

  if (response.statusCode == 200 || response.statusCode == 204) {
    await delete(outboxMutations).delete(row);
  } else if (response.statusCode == 409 || response.statusCode == 412) {
    await markAsConflict(row.mutationId, response.data);
  } else {
    await scheduleRetry(row);
  }
}

Sunucu, Idempotency-Key değerini kullanıcı kimliği ve endpoint ile birlikte benzersiz saklamalı, ilk başarılı yanıtı aynı anahtar için tekrar döndürmelidir. Sadece istemcide benzersiz UUID üretmek yeterli değildir: istemci yanıtı alamadan bağlantı koparsa aynı PATCH yeniden gönderilir. API ilk çağrıyı uygulayıp ikinciyi tekrar uygularsa sayaç artırma, kredi düşme veya sıralı olay yazma gibi işlemler iki kez gerçekleşir. Idempotency kaydını en az istemcinin maksimum retry penceresi boyunca tutun ve yanıt gövdesini de saklayın.

Flutter state management ile optimistic UI ve kuyruk durumu

flutter state management katmanında repository'nin iki ayrı çıktısını modelleyin: kullanıcının gördüğü yerel Task verisi ve senkronizasyon durumu. Task satırına isDirty, hasConflict ve son hata kodunu eklemek, arayüzün ağ isteğinin sonucunu beklemeden güncellenmesini sağlar. Ancak bir hata banner'ı için tüm görev listesini yeniden sorgulamak yerine, Drift'in watch() akışından türetilmiş ayrı bir outbox özet provider'ı kullanın.

final pendingMutationCountProvider = StreamProvider<int>((ref) {
  final db = ref.watch(appDatabaseProvider);
  final query = db.selectOnly(db.outboxMutations)
    ..addColumns([db.outboxMutations.mutationId.count()]);
  return query.watchSingle().map(
    (row) => row.read(db.outboxMutations.mutationId.count()) ?? 0,
  );
});

final taskSyncBadgeProvider = Provider<String>((ref) {
  final pending = ref.watch(pendingMutationCountProvider).valueOrNull ?? 0;
  return pending == 0 ? 'Senkron' : '$pending değişiklik bekliyor';
});

Bu ayrım özellikle cross platform mobil uygulama geliştirme projelerinde önemlidir; mobil ağdaki geçici hata ile masaüstü istemcideki VPN kesintisi aynı kuyruk semantiğiyle ele alınabilir. Edge case olarak, kullanıcı bir Task başlığını çevrimdışıyken art arda beş kez değiştirirse beş PATCH göndermeyin. Henüz lease alınmamış, aynı aggregateId ve operation=patch satırlarını transaction içinde coalesce edin; son başlık değerini taşıyan tek mutation bırakın. Buna karşılık ödeme, yorum ekleme veya denetim kaydı gibi sıralı ve kaybolmaması gereken command'leri coalesce etmeyin.

Sürüm çakışması, tombstone ve alan bazlı birleştirme

If-Match başlığındaki sürüm sunucudaki sürümle eşleşmezse API'nin 412 döndürmesi, sessiz son-yazan-kazan davranışından daha güvenlidir. İstemci 412 aldığında kuyruk satırını silmek yerine conflict durumuna taşımalı, sunucunun güncel kaydını ve yerel patch'i saklamalıdır. Başlık gibi bağımsız bir alan için istemci, güncel sunucu kaydına yerel patch'i tekrar uygulayıp yeni baseVersion ile yeni mutation üretebilir. Aynı alan iki tarafta değişmişse otomatik birleştirme yerine kullanıcıya karşılaştırma ekranı gösterin.

Silme işlemi için doğrudan yerel satırı kaldırmak hatalıdır. Kullanıcı çevrimdışıyken silinen kaydı, deletedAt ve serverVersion taşıyan tombstone olarak saklayın ve outbox'a delete command ekleyin. Aksi halde başka cihazdan gelen eski bir güncelleme, yerelde silinmiş kaydı yeniden oluşturabilir. Sunucu tombstone'u fiziksel olarak temizlemeden önce tüm aktif istemcilerin geride kalma sınırını dikkate almalıdır; pratikte sunucu tarafında belirlenmiş bir saklama süresi ve tam yeniden eşitleme endpoint'i gerekir.

Çakışma oranını ölçülebilir hale getirin: her 412 yanıtında mutation_conflict_total, her başarılı drain sonunda outbox_oldest_age_seconds ve outbox_pending_count metriklerini kaydedin. OpenTelemetry Flutter SDK ile bu değerleri span attribute veya metric olarak dışarı aktarabilirsiniz. En yaşlı kayıt 15 dakikayı aşıyorsa sorun yalnızca ağ olmayabilir; lease'in hiç serbest bırakılmaması, yanlış filtrelenen nextAttemptAt veya sürekli 401 dönen bir oturum yenileme akışı da olası kök nedenlerdir.

Retry politikası ve offline-first akışın entegrasyon testi

Retry için sabit 5 saniye beklemek, binlerce istemci aynı anda bağlantıya döndüğünde API'ye burst üretir. Full jitter kullanın: delay = random(0, min(cap, base * 2^attempt)). Örneğin base=2 saniye, cap=5 dakika ile üçüncü denemede aralık 0-16 saniyedir. 400, 401, 403 ve doğrulama hatası olan 422 yanıtlarını otomatik retry etmeyin; bunları kullanıcı eylemi, token yenileme veya conflict çözümü gerektiren terminal durumlar olarak sınıflandırın.

Akışı sadece unit test ile doğrulamak yeterli değildir; gerçek SQLite transaction davranışını görmek için flutter test integration_test/offline_outbox_test.dart komutunu CI'da çalıştırın. Test senaryosunda Dio adapter ile ilk PATCH yanıtını sunucuda işlenmiş fakat istemciye ulaşmamış gibi simüle edin, uygulamayı yeniden başlatın ve aynı Idempotency-Key ile ikinci isteğin gönderildiğini doğrulayın. Sunucu fake'i ikinci istekte yeni Task üretmek yerine ilk yanıtı döndürmelidir. Bu test, en sık gözden kaçan 'istek başarısız göründü ama sunucu uyguladı' durumunu doğrudan kapsar.

Kuyruk büyümesini release öncesi ölçmek için testte 1000 yerel mutation üretin, ardından drain süresini ve SQLite dosya boyutunu kaydedin. Ölçümü Flutter DevTools Timeline yerine burada uygulama içi Stopwatch ve structured log ile alın: hedefiniz örneğin 1000 küçük patch'in tek batch içinde kaç saniye sürdüğünü aynı cihaz ve aynı test verisiyle önce-sonra karşılaştırmaktır. Batch boyutunu 20 ile 100 arasında deneyin; çok büyük batch, uygulama kapanırken işin yarıda kalma maliyetini ve tek seferdeki bellek kullanımını artırır.

İlgili Eğitim

Flutter Eğitimi

Sık Sorulan Sorular

Flutter mobil uygulama geliştirmede offline outbox için Hive mi Drift mi kullanmalıyım?

Domain kaydı ile mutation satırını atomik yazmanız, SQL ile lease almanız ve filtreli kuyruk sorguları çalıştırmanız gerekiyorsa Drift kullanın. Hive key-value erişiminde pratik olsa da compare-and-set lease, sıralı sorgu ve transaction sınırlarını uygulamak için ek tasarım gerektirir. Drift tarafında Task güncellemesi ile OutboxMutations insert işlemini aynı transaction içine koyun.

Flutter state management optimistic update sonrası hata nasıl gösterilir?

Repository yerel Task satırını hemen güncellesin ve isDirty işaretlesin. HTTP 422 gibi retry edilmeyecek hata geldiğinde ilgili mutation'ı failed durumuna, Task satırını da lastSyncError alanıyla güncelleyin. Riverpod veya başka bir state yönetiminde görev listesinden ayrı bir failed mutation sorgusu izleyerek hata rozetini üretin; ağ hatası nedeniyle kullanıcı değişikliğini hemen geri almayın.

Cross platform mobil uygulama geliştirme için idempotency key ne kadar saklanmalı?

Sunucu idempotency kaydını istemcinin maksimum yeniden deneme penceresinden daha uzun saklamalıdır. Örneğin retry tavanınız 5 dakika olsa bile uygulamanın günler sonra açılıp aynı mutation'ı gönderebileceğini hesaba katın. Anahtarı kullanıcı kimliği, HTTP methodu ve kaynakla birlikte indeksleyin; yalnızca anahtarı global benzersiz yapmak, farklı kullanıcıların aynı UUID ile çakışmasına karşı gereksiz bir risk oluşturur.

Dart programlama eğitimi kapsamında exponential backoff testi nasıl yazılır?

Random kaynağını doğrudan çağırmak yerine constructor ile enjekte edin ve testte sabit bir Random implementasyonu kullanın. attempt=3, base=2 saniye ve cap=5 dakika için üretilen gecikmenin 0 ile 16 saniye arasında olduğunu doğrulayın. Ayrıca 422 yanıtında nextAttemptAt değerinin değiştirilmediğini, 503 yanıtında ise attempt sayısının arttığını ayrı test edin.

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.

Opendart Akademi llms.txt