Andromida/SWIFTDATA_CLOUDKIT_SYNC_TEMPLATE.swift.md

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:

  1. Single habit toggle on Device A appears on Device B with both open.
  2. Rapid 4-6 toggles on Device A all appear on Device B without pull-to-refresh.
  3. Background Device B, then foreground, data remains consistent.
  4. Device logs show CloudKitSync remote-change events.
  5. 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-environment mismatch or signing/provisioning drift.
  • Partial batch updates shown: render invalidation not happening for every remote merge.