Tüm Yazılar
KategoriArchitecture
Okuma Süresi
22 dk okuma
Yayın Tarihi
...
Kelime Sayısı
1.957kelime

Kahveni hazırla - bu içerikli bir makale!

Offline-first mimari ile internet bağlantısı olmadan da tam fonksiyonel çalışan iOS uygulamaları geliştirin. Sync stratejileri, conflict resolution ve local-first veri yönetimi.

iOS Offline-First Mimari: Çevrimdışı Çalışan Uygulamalar

# iOS Offline-First Mimari: Çevrimdışı Çalışan Uygulamalar

Kullanıcılarınız metroda, uçakta veya kötü internet bağlantısı olan bir bölgedeyken uygulamanız ne yapıyor? "İnternet bağlantısı yok" mesajı göstermek 2026'da kabul edilemez. Offline-first mimari, uygulamanızın önce local veriyle çalışmasını, ağ bağlantısı olduğunda senkronize olmasını sağlar. Bu rehberde, sıfırdan production-ready bir offline-first mimari kuracağız.

Felsefe: "Offline, bir hata durumu değil — varsayılan durumdur." Bu zihniyetle tasarlanan uygulamalar, online olduğunda da daha hızlı çalışır.

İçindekiler


1. Offline-First Nedir?

Offline-first, verinin öncelikle local'de depolanıp işlenmesi ve ağ bağlantısı mevcut olduğunda sunucuyla senkronize edilmesi yaklaşımıdır.

Online-First vs Offline-First

Özellik
Online-First
Offline-First
Cache-First
**Veri kaynağı**
Sunucu
Local DB
Cache (geçici)
**Offline deneyim**
Hata mesajı
Tam fonksiyonel
Kısıtlı (stale data)
**İlk açılış**
Yavaş (network)
Hızlı (local)
Orta (cache hit/miss)
**Veri tutarlılığı**
Garantili
Eventual consistency
Garantisiz
**Complexity**
Düşük
Yüksek
Orta
**UX kalitesi**
Bağlantıya bağlı
Her zaman iyi
Değişken

2. Mimari Katmanlar

swift
1// MARK: - Offline-First Architecture Layers
2 
3// 1. PRESENTATION LAYER
4// SwiftUI View'lar sadece local veriyi gosterir
5// Sync durumu UI'da gosterilir (synced, pending, conflict)
6 
7// 2. DOMAIN LAYER
8// Use case'ler her zaman local repository'yi kullanir
9protocol NoteRepository {
10 func getAll() async throws -> [Note]
11 func getById(_ id: UUID) async throws -> Note?
12 func save(_ note: Note) async throws
13 func delete(_ id: UUID) async throws
14}
15 
16// 3. DATA LAYER
17// Local + Remote senkronizasyonu yonetir
18final class OfflineFirstNoteRepository: NoteRepository {
19 private let localStore: LocalNoteStore
20 private let remoteAPI: NoteAPI
21 private let syncEngine: SyncEngine
22 private let operationQueue: OperationQueue
23 
24 init(
25 localStore: LocalNoteStore,
26 remoteAPI: NoteAPI,
27 syncEngine: SyncEngine,
28 operationQueue: OperationQueue
29 ) {
30 self.localStore = localStore
31 self.remoteAPI = remoteAPI
32 self.syncEngine = syncEngine
33 self.operationQueue = operationQueue
34 }
35 
36 func getAll() async throws -> [Note] {
37 // Her zaman local'den oku (hizli!)
38 return try await localStore.fetchAll()
39 }
40 
41 func save(_ note: Note) async throws {
42 // 1. Once local'e kaydet (aninda)
43 var localNote = note
44 localNote.syncStatus = .pending
45 localNote.updatedAt = Date()
46 try await localStore.save(localNote)
47 
48 // 2. Sync queue'ya ekle (arkaplanda gonderilecek)
49 let operation = SyncOperation(
50 type: .upsert,
51 entityType: "note",
52 entityId: note.id.uuidString,
53 payload: try JSONEncoder().encode(note),
54 createdAt: Date()
55 )
56 try await operationQueue.enqueue(operation)
57 
58 // 3. Sync'i tetikle (best effort)
59 await syncEngine.triggerSync()
60 }
61 
62 func delete(_ id: UUID) async throws {
63 // Soft delete - sync sonrasi gercek silme
64 try await localStore.markDeleted(id)
65 
66 let operation = SyncOperation(
67 type: .delete,
68 entityType: "note",
69 entityId: id.uuidString,
70 payload: nil,
71 createdAt: Date()
72 )
73 try await operationQueue.enqueue(operation)
74 await syncEngine.triggerSync()
75 }
76 
77 func getById(_ id: UUID) async throws -> Note? {
78 return try await localStore.fetchById(id)
79 }
80}

3. Local Storage Stratejisi

swift
1// MARK: - SwiftData ile Local Store
2import SwiftData
3 
4@Model
5final class NoteEntity {
6 @Attribute(.unique) var id: UUID
7 var title: String
8 var content: String
9 var createdAt: Date
10 var updatedAt: Date
11 var syncStatus: String // "synced", "pending", "conflict"
12 var isDeleted: Bool
13 var serverVersion: Int
14 var localVersion: Int
15 
16 init(
17 id: UUID = UUID(),
18 title: String,
19 content: String,
20 createdAt: Date = Date(),
21 updatedAt: Date = Date(),
22 syncStatus: String = "pending",
23 isDeleted: Bool = false,
24 serverVersion: Int = 0,
25 localVersion: Int = 1
26 ) {
27 self.id = id
28 self.title = title
29 self.content = content
30 self.createdAt = createdAt
31 self.updatedAt = updatedAt
32 self.syncStatus = syncStatus
33 self.isDeleted = isDeleted
34 self.serverVersion = serverVersion
35 self.localVersion = localVersion
36 }
37}
38 
39// MARK: - Local Store Implementation
40final class SwiftDataNoteStore: LocalNoteStore {
41 private let container: ModelContainer
42 
43 init() throws {
44 let schema = Schema([NoteEntity.self])
45 let config = ModelConfiguration(isStoredInMemoryOnly: false)
46 self.container = try ModelContainer(for: schema, configurations: [config])
47 }
48 
49 func fetchAll() async throws -> [Note] {
50 let context = ModelContext(container)
51 let descriptor = FetchDescriptor<NoteEntity>(
52 predicate: #Predicate { !$0.isDeleted },
53 sortBy: [SortDescriptor(\.updatedAt, order: .reverse)]
54 )
55 let entities = try context.fetch(descriptor)
56 return entities.map { $0.toDomain() }
57 }
58 
59 func save(_ note: Note) async throws {
60 let context = ModelContext(container)
61 let entity = NoteEntity(from: note)
62 context.insert(entity)
63 try context.save()
64 }
65 
66 func markDeleted(_ id: UUID) async throws {
67 let context = ModelContext(container)
68 let descriptor = FetchDescriptor<NoteEntity>(
69 predicate: #Predicate { entity in entity.id == id }
70 )
71 if let entity = try context.fetch(descriptor).first {
72 entity.isDeleted = true
73 entity.syncStatus = "pending"
74 try context.save()
75 }
76 }
77 
78 func fetchById(_ id: UUID) async throws -> Note? {
79 let context = ModelContext(container)
80 let descriptor = FetchDescriptor<NoteEntity>(
81 predicate: #Predicate { entity in entity.id == id }
82 )
83 return try context.fetch(descriptor).first?.toDomain()
84 }
85}

Easter Egg

Gizli bir bilgi buldun!

Bu bölümde gizli bir bilgi var. Keşfetmek ister misin?


4. Sync Engine Tasarımı

swift
1// MARK: - Sync Engine
2actor SyncEngine {
3 private let operationQueue: OperationQueue
4 private let remoteAPI: RemoteAPI
5 private let localStore: LocalNoteStore
6 private var isSyncing = false
7 private let reachability: NetworkReachability
8 
9 init(
10 operationQueue: OperationQueue,
11 remoteAPI: RemoteAPI,
12 localStore: LocalNoteStore,
13 reachability: NetworkReachability
14 ) {
15 self.operationQueue = operationQueue
16 self.remoteAPI = remoteAPI
17 self.localStore = localStore
18 self.reachability = reachability
19 }
20 
21 func triggerSync() async {
22 guard !isSyncing else { return }
23 guard reachability.isConnected else { return }
24 
25 isSyncing = true
26 defer { isSyncing = false }
27 
28 do {
29 // 1. Bekleyen local degisiklikleri gonder (Push)
30 try await pushLocalChanges()
31 
32 // 2. Sunucudan yeni degisiklikleri cek (Pull)
33 try await pullRemoteChanges()
34 
35 // 3. Conflict'leri coz
36 try await resolveConflicts()
37 
38 } catch {
39 LogService.error("Sync hatasi: \(error)")
40 }
41 }
42 
43 private func pushLocalChanges() async throws {
44 let pendingOps = try await operationQueue.getPending()
45 
46 for operation in pendingOps {
47 do {
48 try await remoteAPI.execute(operation)
49 try await operationQueue.markCompleted(operation.id)
50 } catch let error as APIError where error.isConflict {
51 try await operationQueue.markConflict(operation.id)
52 } catch let error as APIError where error.isRetryable {
53 // Yeniden denenecek, birakalim
54 continue
55 } catch {
56 try await operationQueue.markFailed(operation.id, error: error)
57 }
58 }
59 }
60 
61 private func pullRemoteChanges() async throws {
62 let lastSyncTimestamp = UserDefaults.standard.double(forKey: "last_sync")
63 let remoteChanges = try await remoteAPI.getChanges(since: lastSyncTimestamp)
64 
65 for change in remoteChanges {
66 try await localStore.applyRemoteChange(change)
67 }
68 
69 UserDefaults.standard.set(Date().timeIntervalSince1970, forKey: "last_sync")
70 }
71 
72 private func resolveConflicts() async throws {
73 let conflicts = try await localStore.getConflicts()
74 for conflict in conflicts {
75 let resolution = ConflictResolver.resolve(
76 local: conflict.localVersion,
77 remote: conflict.remoteVersion,
78 strategy: .lastWriterWins
79 )
80 try await localStore.applyResolution(conflict.id, resolution: resolution)
81 }
82 }
83}

5. Conflict Resolution

Conflict, aynı verinin hem local hem remote'da değiştirilmesi durumunda ortaya çıkar.

Conflict Resolution Stratejileri

Strateji
Karmaşıklık
Veri Kaybı
Kullanım
**Last Writer Wins**
Düşük
Olabilir
Basit uygulamalar
**Server Wins**
Düşük
Local kayıp
Otoriter sunucu
**Client Wins**
Düşük
Remote kayıp
Offline-öncelikli
**Manual Merge**
Yüksek
Yok (kullanıcı karar)
Doküman editörleri
**Field-Level Merge**
Orta
Minimal
Yapılandırılmış veri
**CRDT**
Çok yüksek
Yok
Gerçek zamanlı collab
swift
1// MARK: - Conflict Resolver
2enum ConflictStrategy {
3 case lastWriterWins
4 case serverWins
5 case clientWins
6 case fieldLevelMerge
7}
8 
9struct ConflictResolver {
10 static func resolve(
11 local: VersionedEntity,
12 remote: VersionedEntity,
13 strategy: ConflictStrategy
14 ) -> VersionedEntity {
15 switch strategy {
16 case .lastWriterWins:
17 return local.updatedAt > remote.updatedAt ? local : remote
18 
19 case .serverWins:
20 return remote
21 
22 case .clientWins:
23 return local
24 
25 case .fieldLevelMerge:
26 return mergeFields(local: local, remote: remote)
27 }
28 }
29 
30 private static func mergeFields(
31 local: VersionedEntity,
32 remote: VersionedEntity
33 ) -> VersionedEntity {
34 // Alan bazli birlestirme
35 var merged = remote
36 // Local'de degisen alanlari koru
37 for field in local.changedFields {
38 if !remote.changedFields.contains(field) {
39 merged.setValue(local.value(for: field), for: field)
40 }
41 }
42 return merged
43 }
44}

6. Queue-Based Operations

Offline işlemler bir kuyrukta birikir ve bağlantı geldiğinde sırayla işlenir.

swift
1// MARK: - Operation Queue (Persistent)
2actor PersistentOperationQueue: OperationQueue {
3 private let store: UserDefaults
4 private let key = "pending_sync_operations"
5 
6 init(store: UserDefaults = .standard) {
7 self.store = store
8 }
9 
10 func enqueue(_ operation: SyncOperation) async throws {
11 var ops = getPendingSync()
12 ops.append(operation)
13 savePendingSync(ops)
14 }
15 
16 func getPending() async throws -> [SyncOperation] {
17 return getPendingSync()
18 }
19 
20 func markCompleted(_ id: UUID) async throws {
21 var ops = getPendingSync()
22 ops.removeAll { $0.id == id }
23 savePendingSync(ops)
24 }
25 
26 func markConflict(_ id: UUID) async throws {
27 var ops = getPendingSync()
28 if let index = ops.firstIndex(where: { $0.id == id }) {
29 ops[index].status = .conflict
30 }
31 savePendingSync(ops)
32 }
33 
34 func markFailed(_ id: UUID, error: Error) async throws {
35 var ops = getPendingSync()
36 if let index = ops.firstIndex(where: { $0.id == id }) {
37 ops[index].status = .failed
38 ops[index].retryCount += 1
39 }
40 savePendingSync(ops)
41 }
42 
43 private func getPendingSync() -> [SyncOperation] {
44 guard let data = store.data(forKey: key) else { return [] }
45 return (try? JSONDecoder().decode([SyncOperation].self, from: data)) ?? []
46 }
47 
48 private func savePendingSync(_ ops: [SyncOperation]) {
49 if let data = try? JSONEncoder().encode(ops) {
50 store.set(data, forKey: key)
51 }
52 }
53}

7. Network Reachability

swift
1import Network
2 
3// MARK: - Network Monitor
4final class NetworkReachability: ObservableObject {
5 @Published var isConnected = true
6 @Published var connectionType: NWInterface.InterfaceType?
7 
8 private let monitor = NWPathMonitor()
9 private let queue = DispatchQueue(label: "com.app.network-monitor")
10 
11 init() {
12 monitor.pathUpdateHandler = { [weak self] path in
13 DispatchQueue.main.async {
14 self?.isConnected = path.status == .satisfied
15 self?.connectionType = path.availableInterfaces.first?.type
16 }
17 }
18 monitor.start(queue: queue)
19 }
20 
21 deinit {
22 monitor.cancel()
23 }
24}

swift
1// MARK: - Optimistic UI Pattern
2enum SyncStatus: String, Codable {
3 case synced // Sunucuyla eslesik
4 case pending // Gonderilmeyi bekliyor
5 case conflict // Cakisma tespit edildi
6 case failed // Gonderilemedi
7 
8 var icon: String {
9 switch self {
10 case .synced: return "checkmark.circle.fill"
11 case .pending: return "arrow.triangle.2.circlepath"
12 case .conflict: return "exclamationmark.triangle.fill"
13 case .failed: return "xmark.circle.fill"
14 }
15 }
16}
17 
18// SwiftUI'da kullanim
19struct NoteRowView: View {
20 let note: Note
21 
22 var body: some View {
23 HStack {
24 VStack(alignment: .leading) {
25 Text(note.title).font(.headline)
26 Text(note.updatedAt, style: .relative).font(.caption).foregroundStyle(.secondary)
27 }
28 Spacer()
29 Image(systemName: note.syncStatus.icon)
30 .foregroundStyle(note.syncStatus == .synced ? .green : .orange)
31 .symbolEffect(.pulse, isActive: note.syncStatus == .pending)
32 }
33 }
34}

ALTIN İPUCU

Bu yazının en değerli bilgisi

Bu ipucu, yazının en önemli çıkarımını içeriyor.

Okuyucu Ödülü

Tebrikler! Bu yazıyı sonuna kadar okuduğun için sana özel bir hediyem var:

Sonuç ve Öneriler

Offline-first mimari, kullanıcı deneyimini dramatik şekilde iyileştirir. Local-first yaklaşımla uygulamanız her zaman hızlı açılır, her zaman çalışır ve bağlantı geldiğinde şeffaf şekilde senkronize olur. Karmaşıklığı kabullenin ama doğru soyutlamalarla yönetilebilir kılın. SyncEngine'i actor olarak tasarlayın, conflict resolution stratejinizi erken belirleyin ve her zaman offline senaryoları test edin.

Etiketler

#Offline-First#Architecture#Sync#CoreData#SwiftData#iOS
Muhittin Çamdalı

Muhittin Çamdalı

Senior iOS Developer

12+ yıllık deneyime sahip iOS Developer. Swift, SwiftUI ve modern iOS mimarileri konusunda uzman. Apple platformlarında performanslı ve kullanıcı dostu uygulamalar geliştiriyorum.

iOS Geliştirme Haberleri

Haftalık Swift tips, SwiftUI tricks ve iOS best practices. Spam yok, sadece değerli içerik.

Gizliliğinize saygı duyuyoruz. İstediğiniz zaman abonelikten çıkabilirsiniz.

Paylaş

Bunu da begenebilirsiniz