5.5 KiB
5.5 KiB
SwiftData to CloudKit Sync Template (Copy/Paste)
Use this as a starter for new apps that need near-real-time SwiftData sync across Apple devices.
1) Build Config (xcconfig)
// Debug.xcconfig
#include "Base.xcconfig"
APS_ENVIRONMENT = development
// Release.xcconfig
#include "Base.xcconfig"
APS_ENVIRONMENT = production
Entitlements should include:
<key>aps-environment</key>
<string>$(APS_ENVIRONMENT)</string>
2) App Container Setup
import SwiftData
enum AppIdentifiers {
static let appGroupIdentifier = "group.com.example.app"
static let cloudKitContainerIdentifier = "iCloud.com.example.app"
}
func makeModelContainer(isRunningTests: Bool) throws -> ModelContainer {
let schema = Schema([
Ritual.self,
RitualArc.self,
ArcHabit.self
])
let storeURL = FileManager.default
.containerURL(forSecurityApplicationGroupIdentifier: AppIdentifiers.appGroupIdentifier)?
.appendingPathComponent("App.sqlite")
?? URL.documentsDirectory.appendingPathComponent("App.sqlite")
let config = ModelConfiguration(
schema: schema,
url: storeURL,
cloudKitDatabase: isRunningTests ? .none : .private(AppIdentifiers.cloudKitContainerIdentifier)
)
return try ModelContainer(for: schema, configurations: [config])
}
3) Store Pattern (Remote Change + Context Refresh)
import Foundation
import Observation
import SwiftData
import CoreData
import os
@MainActor
@Observable
final class RitualStore {
@ObservationIgnored private let modelContainer: ModelContainer
@ObservationIgnored private var modelContext: ModelContext
@ObservationIgnored private var remoteChangeObserver: NSObjectProtocol?
@ObservationIgnored private let syncLogger = Logger(
subsystem: Bundle.main.bundleIdentifier ?? "App",
category: "CloudKitSync"
)
private(set) var rituals: [Ritual] = []
private(set) var dataRefreshVersion: Int = 0
private(set) var lastRemoteChangeDate: Date?
private(set) var remoteChangeEventCount: Int = 0
init(modelContext: ModelContext) {
self.modelContainer = modelContext.container
self.modelContext = modelContext
reloadRituals()
observeRemoteChanges()
}
deinit {
if let observer = remoteChangeObserver {
NotificationCenter.default.removeObserver(observer)
}
}
private func observeRemoteChanges() {
syncLogger.info("Starting CloudKit remote change observation")
remoteChangeObserver = NotificationCenter.default.addObserver(
forName: .NSPersistentStoreRemoteChange,
object: nil,
queue: .main
) { [weak self] _ in
Task { @MainActor [weak self] in
self?.handleRemoteStoreChange()
}
}
}
private func handleRemoteStoreChange() {
remoteChangeEventCount += 1
lastRemoteChangeDate = Date()
// Important when long-lived contexts become stale after remote merges.
modelContext = ModelContext(modelContainer)
syncLogger.info(
"Received remote store change #\(self.remoteChangeEventCount, privacy: .public); reloading"
)
reloadRituals()
}
func refresh() {
reloadRituals()
}
private func reloadRituals() {
do {
rituals = try modelContext.fetch(FetchDescriptor<Ritual>())
dataRefreshVersion &+= 1
} catch {
syncLogger.error("Reload failed: \(error.localizedDescription, privacy: .public)")
}
}
}
4) Root View Pattern (Safety-Net Refresh)
import SwiftUI
struct RootView: View {
@Bindable var store: RitualStore
@Environment(\.scenePhase) private var scenePhase
@State private var fallbackRefreshTask: Task<Void, Never>?
var body: some View {
// Ensure body observes remote reload increments.
let _ = store.dataRefreshVersion
ContentView(store: store)
.onChange(of: scenePhase) { _, newPhase in
if newPhase == .active {
scheduleCloudKitFallbackRefresh()
} else if newPhase == .background {
fallbackRefreshTask?.cancel()
}
}
}
private func scheduleCloudKitFallbackRefresh() {
let activationDate = Date()
fallbackRefreshTask?.cancel()
fallbackRefreshTask = Task { @MainActor in
try? await Task.sleep(for: .seconds(8))
guard !Task.isCancelled else { return }
let gotRemoteSinceActive = store.lastRemoteChangeDate.map { $0 >= activationDate } ?? false
guard !gotRemoteSinceActive else { return }
store.refresh()
}
}
}
5) Verification Script (Manual)
Run this release gate on two real devices:
- Single habit toggle on Device A appears on Device B with both open.
- Rapid 4-6 toggles on Device A all appear on Device B without pull-to-refresh.
- Background Device B, then foreground, data remains consistent.
- Device logs show
CloudKitSyncremote-change events. - CloudKit Console private DB shows matching record updates.
6) Common Failure Modes
- Push works but UI stale: context/view invalidation issue.
- Only updates after relaunch: missing remote observer or stale long-lived context.
- Works in one build flavor only:
aps-environmentmismatch or signing/provisioning drift. - Partial batch updates shown: render invalidation not happening for every remote merge.