Offline-first bir Flutter istemcisinde değişiklik günlüğü, sürüm vektörü ve deterministik birleştirme kurallarıyla çakışmaları yönetin. Bu flutter mobil uygulama geliştirme rehberi, istemci ve API sözleşmesini birlikte ele alır.
Flutter State Management ile Offline-First Çakışma Çözümü
Flutter mobil uygulama geliştirme için değişiklik günlüğü tasarımı
Offline-first akışta ekrandaki nesneyi doğrudan sunucudaki kaynağın kopyası gibi ele almayın. Her kullanıcı yazmasını, yeniden gönderilebilen bir mutation kaydı olarak saklayın. drift ile `entityId`, istemci üretimli `operationId`, `baseVersion`, `payload`, `createdAt` ve `status` alanlarını içeren bir outbox tablosu oluşturun. `operationId` için UUID kullanılması, bağlantı kesildiği anda aynı HTTP isteğinin yeniden denenmesinde sunucunun işlemi iki kez uygulamasını engeller.
class PendingMutations extends Table {
TextColumn get operationId => text()();
TextColumn get entityId => text()();
IntColumn get baseVersion => integer()();
TextColumn get patchJson => text()();
IntColumn get createdAtMs => integer()();
TextColumn get status => text().withDefault(const Constant('pending'))();
@override
Set<Column> get primaryKey => {operationId};
}Bir kayıt düzenlenirken önce yerel projection'ı ve outbox satırını tek SQLite transaction içinde yazın. Ayrı transaction kullanılırsa uygulama yerel ekranı güncelledikten sonra mutation kaydını yazamadan kapanabilir; kullanıcı değişikliği görür, fakat sonraki açılışta gönderilecek işlem kalmaz. Bu nedenle UI state'i yalnızca bellekte tutmak yerine, projection'ı outbox'tan türetebilen kalıcı bir veri modeline bağlayın. Bir flutter eğitimi veya flutter kursu içinde bu ayrım çoğu zaman basit CRUD örneklerinde atlanır, ancak bağlantısız kullanımda veri kaybının temel kaynağı budur.
await database.transaction(() async {
await into(tasks).insertOnConflictUpdate(
TasksCompanion.insert(
id: taskId,
title: newTitle,
version: localVersion,
),
);
await into(pendingMutations).insert(
PendingMutationsCompanion.insert(
operationId: const Uuid().v7(),
entityId: taskId,
baseVersion: localVersion,
patchJson: jsonEncode({'title': newTitle}),
createdAtMs: DateTime.now().millisecondsSinceEpoch,
),
);
});Flutter state management katmanında optimistic write ve sıra garantisi
flutter state management katmanında aynı varlığa ait mutation'ları paralel göndermeyin. Örneğin `title=A` işlemi ağda gecikmişken `title=B` gönderilirse, sunucu yanıtları ters sırada dönebilir. Riverpod kullanıyorsanız outbox worker'ını tek bir provider içinde çalıştırın ve her `entityId` için FIFO sıra uygulayın. Farklı varlıklar paralel işlenebilir, fakat aynı varlık için maksimum eşzamanlılık 1 olmalıdır.
final syncWorkerProvider = Provider((ref) => SyncWorker(ref.read));
class SyncWorker {
SyncWorker(this.read);
final Reader read;
final _locks = <String, Future<void>>{};
Future<void> enqueue(PendingMutation mutation) {
final previous = _locks[mutation.entityId] ?? Future.value();
final next = previous
.catchError((_) {})
.then((_) => _send(mutation));
_locks[mutation.entityId] = next.whenComplete(() {
if (identical(_locks[mutation.entityId], next)) {
_locks.remove(mutation.entityId);
}
});
return next;
}
Future<void> _send(PendingMutation mutation) async {
await read(apiClientProvider).patchTask(mutation);
}
}Yaygın hata, `AsyncNotifier` içinde her buton tıklamasında bağımsız `Future` başlatıp son tamamlanan yanıtı state'e yazmaktır. Bunun yerine UI'a `pendingOperationCount` ve son bilinen projection'ı gösterin; HTTP yanıtı state'in tek doğruluk kaynağı olmamalıdır. İptal edilen bir widget'ın `ref` nesnesi üzerinden yanıt yazmamak için uzun yaşayan senkron işçisini ekran provider'ından ayırın. Bu tasarım, cross platform mobil uygulama geliştirme sırasında Android işlem öldürmesi veya iOS'ta uygulamanın yeniden açılması sonrasında outbox'ın tekrar taranabilmesini de sağlar.
ETag, If-Match ve idempotency ile API çakışma sözleşmesi
Sunucu yalnızca `updatedAt` alanına göre last-write-wins uygulamamalıdır. Cihaz saatleri güvenilir değildir ve iki cihazın aynı alanı değiştirdiğini ayırt edemezsiniz. Kaynağın her başarılı yazımında artırılan sayısal `version` veya bir ETag üretin. İstemci mutation içindeki `baseVersion` değerini `If-Match` başlığıyla gönderir. API sürüm uyuşmazlığında `409 Conflict` ya da HTTP önkoşul semantiğine uygun `412 Precondition Failed` döndürmeli; yanıtta güncel sunucu nesnesi ve sürüm bulunmalıdır.
curl -X PATCH 'https://api.example.com/tasks/t-42' -H 'Authorization: Bearer token' -H 'Idempotency-Key: 018f2f18-7dc2-7c24-a6bf-7c0f5e353d1d' -H 'If-Match: "17"' -H 'Content-Type: application/json' --data '{"title":"Teklif gönder"}'Dio istemcisinde `Idempotency-Key` değerini interceptor içinde her retry'da yeniden üretmeyin. Anahtar mutation oluşturulurken kalıcı olarak yazılmalı ve aynı outbox satırı silinene kadar korunmalıdır. Yeni anahtar ile yapılan retry, sunucunun ikinci isteği ilk isteğin kopyası olarak tanımasını engeller. Sunucu tarafında bu anahtarı kullanıcı kimliği ve endpoint ile birlikte saklayıp, ilk başarılı yanıtı tekrar döndürün. Böylece zaman aşımı yaşayan istemci, işlemin sunucuda gerçekleşip gerçekleşmediğini tahmin etmek zorunda kalmaz.
class IdempotencyInterceptor extends Interceptor {
@override
void onRequest(RequestOptions options, RequestInterceptorHandler handler) {
final operationId = options.extra['operationId'] as String?;
if (operationId != null) {
options.headers['Idempotency-Key'] = operationId;
}
handler.next(options);
}
}Alan bazlı merge kuralları ve silinmiş kayıt edge case'i
Çakışma geldiğinde tüm nesneyi otomatik olarak yerel veya uzak kopyayla değiştirmek veri kaybettirir. Patch'i alan bazında değerlendirin: örneğin kullanıcı bir cihazda `title`, diğer cihazda `dueDate` değiştirdiyse iki değişiklik birlikte korunabilir. Aynı alan iki tarafta da değişmişse ürün kuralını açıkça kodlayın: metin alanında kullanıcı seçimi, sayaçta toplama, etiketlerde OR-Set benzeri ekleme-silme işlemleri kullanılabilir. Basit görev uygulamalarında bile silme işlemini `deletedAt` tombstone olarak temsil edin; aksi halde eski bir cihazın gecikmiş PATCH isteği silinen kaydı yeniden oluşturabilir.
Task mergeTask({
required Task base,
required Task local,
required Task remote,
}) {
final localChangedTitle = local.title != base.title;
final remoteChangedTitle = remote.title != base.title;
if (localChangedTitle && remoteChangedTitle &&
local.title != remote.title) {
throw TitleConflict(local: local, remote: remote);
}
return remote.copyWith(
title: localChangedTitle ? local.title : remote.title,
dueDate: local.dueDate != base.dueDate ? local.dueDate : remote.dueDate,
deletedAt: remote.deletedAt ?? local.deletedAt,
);
}Bu resolver için üç yönlü merge gerekir: `base`, mutation oluşturulurken kullanıcının gördüğü sürüm; `local`, kullanıcının yaptığı değişiklik; `remote`, 409 veya 412 ile gelen güncel sürüm. Sadece local ve remote ile karşılaştırma yapmak, bir alanın aslında değişmediğini kanıtlayamaz. `flutter test` içinde özellik tabanlı test için `package:checks` veya `package:test` kullanarak 'farklı alanlardaki değişikliklerin birleşmesi' ve 'tombstone'un eski patch'e üstün gelmesi' senaryolarını çalıştırın. Bu, dart programlama eğitimi kapsamında öğrenilen immutable model yaklaşımının dağıtık veri yazımındaki pratik karşılığıdır.
test('remote tombstone eski yerel patch ile geri dönmez', () {
final result = mergeTask(
base: baseTask,
local: baseTask.copyWith(title: 'Yeni başlık'),
remote: baseTask.copyWith(deletedAt: DateTime.utc(2026, 1, 1)),
);
expect(result.deletedAt, isNotNull);
});DevTools ile outbox işleme maliyetini ölçme
Çakışma çözümü ekledikten sonra maliyeti ölçmeden her mutation sonrasında tüm yerel tabloyu yeniden okumayın. Flutter DevTools Performance görünümünde 100 bekleyen mutation içeren test hesabıyla senkronizasyonu kaydedin. Önceki yaklaşımda her başarılı PATCH sonrasında `SELECT * FROM tasks` yapılıyorsa, Timeline'da SQLite okuma ve JSON dönüştürme blokları tekrar eder. Sonraki yaklaşımda yalnızca etkilenen `entityId` için projection güncelleyin. Karşılaştırmayı aynı cihaz, aynı test verisi ve release/profile modunda P50/P95 'mutation tamamlanma süresi' ile yapın.
import 'dart:developer' as dev;
Future<void> applyServerAck(Ack ack) async {
final task = dev.TimelineTask()..start('applyServerAck');
try {
await dao.applyAckForEntity(ack.entityId, ack.version);
} finally {
task.finish(arguments: {'entityId': ack.entityId});
}
}Ölçümü otomatikleştirmek için integration_test senaryosunda 100 mutation üretin, ardından `flutter drive --profile` ile zaman çizelgesini alın. Kabul kriterini örneğin '100 kayıtlık outbox boşaltılırken UI isolate üzerinde 16 ms üstü frame sayısı 5'i geçmez' şeklinde yazın. Ağ çağrılarını gerçek sunucu yerine gecikme ve 412 üreten kontrollü bir stub ile besleyin; aksi halde mobil ağ değişkenliği merge algoritması yerine bağlantı kalitesini ölçmenize neden olur.
İlgili Eğitim
YTÜSEM İlgili Eğitim
Sık Sorulan Sorular
Flutter state management içinde offline çakışma nasıl yönetilir?
Her kullanıcı yazmasını kalıcı outbox'a `operationId` ve `baseVersion` ile kaydedin. Aynı `entityId` için FIFO gönderim uygulayın, API'den 409 veya 412 gelince `base-local-remote` üç yönlü merge çalıştırın. Riverpod, Bloc veya başka bir araçtan bağımsız olarak merge sonucu önce SQLite projection'a, sonra ekrana yansımalıdır.
Flutter mobil uygulama geliştirme sırasında ETag mi version alanı mı kullanılmalı?
API zaten HTTP cache ve koşullu istek altyapısı kullanıyorsa ETag ile `If-Match` uygundur. Mobil istemcinin kalıcı outbox'ında hata ayıklamayı kolaylaştırmak için gövdede artırılan sayısal `version` da saklanabilir. Her iki durumda da sürüm, cihazın `DateTime.now()` değeriyle üretilmemelidir; sunucu otoriter sürümü üretmelidir.
Cross platform mobil uygulama geliştirme için Idempotency-Key neden gereklidir?
Zaman aşımı, sunucunun işlemi uygulamadığı anlamına gelmez. İstemci aynı PATCH isteğini yeniden gönderdiğinde kalıcı `Idempotency-Key`, sunucunun önceki sonucu döndürmesini sağlar. Anahtarı retry anında değil, mutation oluşturulurken UUID olarak üretin ve outbox satırıyla birlikte saklayın.
Flutter kursu projelerinde SQLite outbox testi nasıl yapılır?
Drift için geçici test veritabanı açın, bir mutation ekleyin, uygulamayı yeniden başlatmayı taklit etmek için DAO'yu yeniden oluşturun ve `pending` satırının durduğunu doğrulayın. Ardından sahte API'nin ilk çağrıda 412, ikinci çağrıda 200 dönmesini sağlayıp merge sonrası `baseVersion` ve projection içeriğini `flutter test` ile assert 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.

