Lab 6.2 — CloudKit Sync App
Goal
Build a recipe-sharing app that uses two CloudKit databases simultaneously: the private database holds your personal recipes; the public database hosts community recipes anyone can browse. Wire CKSubscription silent pushes so changes from other devices appear in real time without polling. By the end you’ll have shipped a non-trivial CloudKit-direct app and feel comfortable with the raw CKContainer/CKDatabase/CKRecord APIs that Chapter 6.3 introduced.
Time
~3 hours. Stretch goals push this to a full day.
Prerequisites
- Xcode 16+
- Paid Apple Developer account (required to use CloudKit on device; free tier is simulator-only)
- An iCloud-signed-in simulator or device
- Read Chapter 6.3
Setup
- New iOS app project. Name
CookCloud. SwiftUI. No SwiftData/Core Data. - Project → Signing & Capabilities → + Capability → iCloud → check CloudKit. Add container
iCloud.com.yourname.CookCloud. -
- Capability → Background Modes → check Remote notifications.
-
- Capability → Push Notifications.
- Make sure your simulator/device is signed into iCloud (Settings → Sign in).
Build
Step 1 — model types & DTO
Recipe.swift:
import CloudKit
struct Recipe: Identifiable, Hashable {
let id: CKRecord.ID
var title: String
var ingredients: String
var instructions: String
var modifiedAt: Date
init(record: CKRecord) {
self.id = record.recordID
self.title = record["title"] as? String ?? ""
self.ingredients = record["ingredients"] as? String ?? ""
self.instructions = record["instructions"] as? String ?? ""
self.modifiedAt = record.modificationDate ?? .now
}
func toRecord(in zoneID: CKRecordZone.ID) -> CKRecord {
let record = CKRecord(recordType: "Recipe", recordID: id)
apply(to: record)
return record
}
func apply(to record: CKRecord) {
record["title"] = title as CKRecordValue
record["ingredients"] = ingredients as CKRecordValue
record["instructions"] = instructions as CKRecordValue
}
}
Step 2 — repository actor
CloudKitRepository.swift:
import CloudKit
import Foundation
actor CloudKitRepository {
enum Scope { case privateDB, publicDB }
private let container: CKContainer
private let zoneID = CKRecordZone.ID(zoneName: "Recipes", ownerName: CKCurrentUserDefaultName)
private var didCreateZone = false
init() {
self.container = CKContainer(identifier: "iCloud.com.yourname.CookCloud")
}
private func database(for scope: Scope) -> CKDatabase {
switch scope {
case .privateDB: return container.privateCloudDatabase
case .publicDB: return container.publicCloudDatabase
}
}
func ensurePrivateZone() async throws {
guard !didCreateZone else { return }
let zone = CKRecordZone(zoneID: zoneID)
_ = try await container.privateCloudDatabase.save(zone)
didCreateZone = true
}
func save(_ recipe: Recipe, in scope: Scope) async throws -> Recipe {
if scope == .privateDB { try await ensurePrivateZone() }
let recordID = CKRecord.ID(recordName: recipe.id.recordName,
zoneID: scope == .privateDB ? zoneID : .default)
let existing = try? await database(for: scope).record(for: recordID)
let record = existing ?? CKRecord(recordType: "Recipe", recordID: recordID)
recipe.apply(to: record)
let saved = try await database(for: scope).save(record)
return Recipe(record: saved)
}
func fetchAll(in scope: Scope) async throws -> [Recipe] {
let predicate = NSPredicate(value: true)
let query = CKQuery(recordType: "Recipe", predicate: predicate)
query.sortDescriptors = [NSSortDescriptor(key: "modificationDate", ascending: false)]
let (matches, _) = try await database(for: scope).records(matching: query,
inZoneWith: scope == .privateDB ? zoneID : nil,
resultsLimit: 200)
return matches.compactMap { try? Recipe(record: $0.1.get()) }
}
func delete(_ recipe: Recipe, in scope: Scope) async throws {
_ = try await database(for: scope).deleteRecord(withID: recipe.id)
}
// MARK: subscriptions
func subscribeToChanges() async throws {
try await subscribe(to: .privateDB, subscriptionID: "private-recipes")
try await subscribe(to: .publicDB, subscriptionID: "public-recipes")
}
private func subscribe(to scope: Scope, subscriptionID: String) async throws {
let subscription = CKQuerySubscription(
recordType: "Recipe",
predicate: NSPredicate(value: true),
subscriptionID: subscriptionID,
options: [.firesOnRecordCreation, .firesOnRecordUpdate, .firesOnRecordDeletion]
)
let info = CKSubscription.NotificationInfo()
info.shouldSendContentAvailable = true
subscription.notificationInfo = info
do {
_ = try await database(for: scope).save(subscription)
} catch let error as CKError where error.code == .serverRejectedRequest {
// already exists - that's fine
}
}
}
Step 3 — @Observable store
import Observation
@Observable
@MainActor
final class RecipeStore {
private let repo = CloudKitRepository()
var privateRecipes: [Recipe] = []
var publicRecipes: [Recipe] = []
var error: String?
func bootstrap() async {
do {
try await repo.subscribeToChanges()
await refresh()
} catch {
self.error = "Bootstrap: \(error.localizedDescription)"
}
}
func refresh() async {
async let privates = try? await repo.fetchAll(in: .privateDB) ?? []
async let publics = try? await repo.fetchAll(in: .publicDB) ?? []
privateRecipes = await privates ?? []
publicRecipes = await publics ?? []
}
func save(_ recipe: Recipe, public isPublic: Bool) async {
do {
let saved = try await repo.save(recipe, in: isPublic ? .publicDB : .privateDB)
if isPublic { upsert(saved, into: &publicRecipes) }
else { upsert(saved, into: &privateRecipes) }
} catch {
self.error = error.localizedDescription
}
}
private func upsert(_ recipe: Recipe, into list: inout [Recipe]) {
if let i = list.firstIndex(where: { $0.id == recipe.id }) {
list[i] = recipe
} else {
list.insert(recipe, at: 0)
}
}
func delete(_ recipe: Recipe, public isPublic: Bool) async {
do {
try await repo.delete(recipe, in: isPublic ? .publicDB : .privateDB)
if isPublic { publicRecipes.removeAll { $0.id == recipe.id } }
else { privateRecipes.removeAll { $0.id == recipe.id } }
} catch {
self.error = error.localizedDescription
}
}
}
Step 4 — handle silent pushes (AppDelegate)
import UIKit
import CloudKit
final class AppDelegate: NSObject, UIApplicationDelegate {
static var refreshHandler: (() async -> Void)?
func application(_ application: UIApplication,
didFinishLaunchingWithOptions opts: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
application.registerForRemoteNotifications()
return true
}
func application(_ application: UIApplication,
didReceiveRemoteNotification userInfo: [AnyHashable: Any],
fetchCompletionHandler completion: @escaping (UIBackgroundFetchResult) -> Void) {
guard CKNotification(fromRemoteNotificationDictionary: userInfo) != nil else {
completion(.noData); return
}
Task {
await Self.refreshHandler?()
completion(.newData)
}
}
}
@main
struct CookCloudApp: App {
@UIApplicationDelegateAdaptor(AppDelegate.self) private var delegate
@State private var store = RecipeStore()
var body: some Scene {
WindowGroup {
RootView()
.environment(store)
.task {
await store.bootstrap()
AppDelegate.refreshHandler = { @MainActor in await store.refresh() }
}
}
}
}
Step 5 — UI
struct RootView: View {
@Environment(RecipeStore.self) private var store
var body: some View {
TabView {
RecipeListView(scopeIsPublic: false)
.tabItem { Label("My Recipes", systemImage: "person.crop.circle") }
RecipeListView(scopeIsPublic: true)
.tabItem { Label("Community", systemImage: "globe") }
}
}
}
struct RecipeListView: View {
let scopeIsPublic: Bool
@Environment(RecipeStore.self) private var store
@State private var showingNew = false
private var recipes: [Recipe] {
scopeIsPublic ? store.publicRecipes : store.privateRecipes
}
var body: some View {
NavigationStack {
List {
ForEach(recipes) { recipe in
NavigationLink(value: recipe) {
VStack(alignment: .leading) {
Text(recipe.title).font(.headline)
Text(recipe.modifiedAt, style: .relative)
.font(.caption).foregroundStyle(.secondary)
}
}
}
.onDelete { offsets in
Task {
for i in offsets { await store.delete(recipes[i], public: scopeIsPublic) }
}
}
}
.navigationTitle(scopeIsPublic ? "Community" : "My Recipes")
.toolbar {
Button("New", systemImage: "plus") { showingNew = true }
}
.sheet(isPresented: $showingNew) {
RecipeEditorSheet(scopeIsPublic: scopeIsPublic)
}
.navigationDestination(for: Recipe.self) { RecipeDetailView(recipe: $0) }
.refreshable { await store.refresh() }
}
}
}
struct RecipeEditorSheet: View {
let scopeIsPublic: Bool
@Environment(RecipeStore.self) private var store
@Environment(\.dismiss) private var dismiss
@State private var title = ""
@State private var ingredients = ""
@State private var instructions = ""
var body: some View {
NavigationStack {
Form {
TextField("Title", text: $title)
Section("Ingredients") { TextEditor(text: $ingredients).frame(minHeight: 120) }
Section("Instructions") { TextEditor(text: $instructions).frame(minHeight: 120) }
}
.navigationTitle("New Recipe")
.toolbar {
ToolbarItem(placement: .topBarLeading) { Button("Cancel") { dismiss() } }
ToolbarItem(placement: .topBarTrailing) {
Button("Save") {
let recipe = Recipe(
record: {
let r = CKRecord(recordType: "Recipe")
r["title"] = title as CKRecordValue
r["ingredients"] = ingredients as CKRecordValue
r["instructions"] = instructions as CKRecordValue
return r
}())
Task { await store.save(recipe, public: scopeIsPublic); dismiss() }
}.disabled(title.isEmpty)
}
}
}
}
}
struct RecipeDetailView: View {
let recipe: Recipe
var body: some View {
Form {
Section("Ingredients") { Text(recipe.ingredients) }
Section("Instructions") { Text(recipe.instructions) }
}
.navigationTitle(recipe.title)
}
}
Step 6 — verify
- Run on a simulator signed into iCloud. Create a recipe. Confirm it appears.
- Open the CloudKit Dashboard → your container → Records → choose the appropriate database. You should see the record.
- Run a second simulator (different scheme target or different model) signed into the same iCloud account for the private DB test, or any iCloud account for the public DB test. Confirm changes propagate (give the silent push a few seconds).
- Delete a recipe on Device A. Confirm it disappears on Device B.
Stretch
- Stretch 1 — delta sync: switch from full
fetchAlltoCKFetchRecordZoneChangesOperationwith persistedCKServerChangeToken. Massive speedup once the dataset grows. - Stretch 2 — assets: add a photo to each recipe via
CKAsset. Store local cache of fetched assets; don’t re-download on every refresh. - Stretch 3 — sharing: enable
CKSharefor collaborative editing of a private recipe with another iCloud user. Implement theapplication(_:userDidAcceptCloudKitShareWith:)flow. - Stretch 4 — account changes: handle
CKAccountChangednotification by clearing caches and re-bootstrapping when the user signs out/in.
Notes & gotchas
- Public database queries need indexes in Production. Sort and filter fields must be marked queryable in the dashboard. Development environment auto-indexes; Production does not.
- Silent pushes don’t deliver to apps that were force-quit. The user must launch the app at least once after install for push registration to take effect.
- The default zone in the private database doesn’t support
fetchRecordZoneChanges— you must use a custom zone (we created “Recipes”). The public database uses the default zone and a different sync strategy (last-modified queries). - Schema must be deployed to Production before App Store builds can use new record types or fields. Test on TestFlight with Production environment, not Development.
- Container identifier must match the bundle ID convention Apple expects:
iCloud.+ your bundle ID. Use a different one and you’ll burn an afternoon.