Come funziona la condivisione familiare senza fidarsi di un server
Due account iCloud, un budget condiviso, nessun backend sotto il mio controllo. NSPersistentCloudKitContainer non ci riusciva, così ho scritto a mano il motore di sincronizzazione. Ecco cosa serve davvero.
The Smart Budget permette a una famiglia di condividere un unico budget. Due persone, due account iCloud separati, entrambe a modificare le stesse transazioni e gli stessi budget in tempo reale. Il vincolo che mi sono dato: nessun server sotto il mio controllo nel mezzo. I dati sui tuoi soldi si sincronizzano direttamente tramite CloudKit di Apple, da account ad account. Io non li vedo mai, e non c'è un backend che io possa farmi sfuggire, che possa ricevere un'ingiunzione o che possa essere violato.
Apple ha un framework che sembra fare esattamente questo: NSPersistentCloudKitContainer con CKShare. Ho passato tre giorni a cercare di farlo funzionare. Poi l'ho eliminato e ho scritto da solo il motore di sincronizzazione. Questa è la storia del perché, e di com'è fatto il sostituto, con il codice vero.
L'approccio ovvio, e il crash
La strada benedetta è questa: appoggi Core Data a NSPersistentCloudKitContainer (NSPCKC), che replica automaticamente il tuo store su un database CloudKit privato, poi chiami share(...) per creare un CKShare per gli oggetti che vuoi condividere.
Va in crash. Non subito, ed è questa la parte crudele. Funziona nella demo e muore nel caso reale e disordinato, quando il tuo CKShare personalizzato finisce nello stesso database privato che il framework sta replicando. Dal commento che ora sta dove prima c'era NSPCKC:
/// Apple's `NSPersistentCloudKitContainer` is gone. It crashed when our custom `CKShare`
/// landed in the same private DB it was mirroring (it tries to ingest the share as a
/// `cloudkit.share` Core Data entity that doesn't exist in the model). Replacing it with
/// our own bare-CloudKit pipeline removes the framework collision and gives us one path
/// for all sync, single-user and household.
Il framework vede un record di tipo cloudkit.share nel database che sta replicando e prova a importarlo come se fosse una delle tue entità Core Data. Nel tuo modello non c'è nessuna entità cloudkit.share, perché è un tipo di sistema di CloudKit. Boom.
E il problema più profondo non era questo singolo crash: era che non avevo modo di risolverlo. Dal documento con il piano di riscrittura:
Tre giorni di test di
NSPersistentCloudKitContainer+CKShareper la condivisione familiare hanno prodotto uno schema ricorrente: ogni correzione fa emergere una nuova modalità di errore (blocchi da zona non trovata, loop di «Can't find metadata», tempistiche a cascata che producono duplicati, reset CKKS a stato parziale impossibili da recuperare). La replica automatica del framework è opaca: quando va storta non abbiamo alcuna API per ispezionarla o correggerla; funziona solo «cancella tutto e ricomincia da capo».
È questa la vera accusa. Quando la cosa automatica funziona, è magia. Quando non funziona, non c'è nessuno spiraglio in cui mettere le mani. Per una funzione in cui «ricomincia da capo» significa «elimina il budget condiviso di una famiglia», è squalificante. La sintesi del compromesso che ho scelto, nel documento di progetto: «scambiare la complessità di combattere il framework con la complessità del controllo esplicito». Sceglierò il controllo esplicito ogni volta che ci sono di mezzo i soldi dei miei utenti.
Il sostituto: due modalità, un solo motore
Ho sostituito NSPCKC con una pipeline basata direttamente su CloudKit: il push, il pull, la risoluzione dei conflitti e la traduzione dei record sono miei. La sincronizzazione personale con un solo account e la condivisione familiare passano per lo stesso motore: cambiano solo la zona e il database CloudKit a cui puntano. Il cuore è un actor:
/// Custom CloudKit sync layer for ALL of the user's shareable data.
/// **Two modes, one engine:**
/// - **Personal mode** (default): syncs `Shared.sqlite` to a `personal-zone` in the
/// user's private CloudKit DB. No `CKShare`.
/// - **Household mode**: syncs to a `household-shared` zone in the owner's private DB,
/// with a `CKShare` attached.
/// **Conflict resolution:** last-writer-wins by `lastModified`.
actor HouseholdSync {
static let shared = HouseholdSync()
Tutto il trucco del «nessun server centrale» sta nel modo in cui un partecipante indirizza i dati. Ci sono quattro modalità, e ognuna sceglie la propria zona e il proprio database:
enum Mode { case off; case personal; case householdOwner; case householdParticipant }
private var currentZoneID: CKRecordZone.ID? {
switch currentMode {
case .personal:
return CKRecordZone.ID(zoneName: Self.personalZoneName, ownerName: CKCurrentUserDefaultName)
case .householdOwner:
return CKRecordZone.ID(zoneName: Self.householdZoneName, ownerName: CKCurrentUserDefaultName)
case .householdParticipant:
let ownerName = UserDefaults.standard.string(forKey: Self.householdOwnerCKUserIDKey) ?? CKCurrentUserDefaultName
return CKRecordZone.ID(zoneName: Self.householdZoneName, ownerName: ownerName)
default: return nil
}
}
private var currentDatabase: CKDatabase? {
switch currentMode {
case .personal, .householdOwner: return container.privateCloudDatabase
case .householdParticipant: return container.sharedCloudDatabase
default: return nil
}
}
Il proprietario scrive in una zona household-shared nel proprio database privato. Il partecipante legge e scrive quella stessa zona attraverso il proprio database condiviso, ricostruendo la coppia (zoneName, ownerName) del proprietario a partire dall'ID utente CloudKit del proprietario, salvato quando ha accettato l'invito. Due database privati, cuciti insieme da un'unica condivisione. La sincronizzazione la fanno i server di Apple; la logica non la fa il server di nessuno.
Quattro componenti alimentano quel motore.
LocalChangeWatcher: trasformare i salvataggi in operazioni di push
Un observer di NSManagedObjectContextDidSave. Ogni volta che lo store condiviso salva, costruisce le operazioni di push per gli oggetti inseriti, aggiornati ed eliminati e avvia un push. Il dettaglio sottile, quello che regge tutto, è ciò che salta:
private func handle(_ notification: Notification) {
guard SyncStatus.shared.isActive else { return }
guard let sharedStore = controller.sharedStore else { return }
// Skip saves emitted by HouseholdSync itself (pull's apply + push's metadata writeback)
if let savingContext = notification.object as? NSManagedObjectContext,
Self.skipContexts.contains(ObjectIdentifier(savingContext)) {
return
}
for object in inserted.union(updated) {
guard object.objectID.persistentStore == sharedStore else { continue }
guard RecordTranslator.shareableEntityNames.contains(object.entity.name ?? "") else { continue }
ops.append(.init(cloudKitRecordName: recordName, entityName: entityName,
kind: .upsert, objectIDURI: object.objectID.uriRepresentation().absoluteString))
}
Task {
await PushQueue.shared.enqueue(ops)
await HouseholdSync.shared.pushPendingChanges()
}
}
Quell'insieme skipContexts usa come chiave ObjectIdentifier, di proposito: leggere context.transactionAuthor da un'altra coda può mandare Core Data in deadlock. Senza questo salto, applicare una modifica scaricata provocherebbe a sua volta un salvataggio, che la rimanderebbe indietro con un push, che il partner scaricherebbe e salverebbe di nuovo… il che ci porta al bug più bello del progetto (più avanti).
PushQueue: una FIFO persistente che si compatta da sola
La rete cade. Le app vengono chiuse. Per questo la coda dei push in sospeso è un actor salvato su file, che scrive su disco in modo atomico a ogni modifica ed elimina i duplicati già in ingresso:
func enqueue(_ ops: [Operation]) async {
for op in ops {
// URI-based dedupe FIRST so a stale optimistic-UUID op for the same
// managed object gets dropped.
if let uri = op.objectIDURI {
queue.removeAll { $0.objectIDURI == uri || $0.cloudKitRecordName == op.cloudKitRecordName }
} else {
queue.removeAll { $0.cloudKitRecordName == op.cloudKitRecordName }
}
queue.append(op)
}
await persist()
}
Il contenuto non viene serializzato: il push rilegge lo stato attuale del managed object al momento del push. Così, se modifichi una transazione cinque volte in due secondi, le modifiche si riducono gratis a un solo push dello stato finale.
RecordTranslator: Core Data ⇄ CKRecord, in entrambe le direzioni
Funzioni pure, senza I/O, eseguite dentro il context.perform del chiamante. Ogni oggetto condivisibile porta con sé un cloudKitRecordName stabile, così entrambi i dispositivi concordano su un'unica identità per ogni riga logica. In uscita, codifica come riferimenti solo le relazioni a-uno, e salta quelle il cui destinatario non è ancora stato inviato:
for (name, rel) in object.entity.relationshipsByName where !rel.isToMany {
guard let target = object.value(forKey: name) as? NSManagedObject else { record[name] = nil; continue }
guard let targetName = target.value(forKey: "cloudKitRecordName") as? String, !targetName.isEmpty else {
continue // target not pushed yet — next cycle fixes it
}
let targetID = CKRecord.ID(recordName: targetName, zoneID: zoneID)
record[name] = CKRecord.Reference(recordID: targetID, action: .none)
}
In entrata, c'è un controllo che sembra una pignoleria ed è invece il cuore della partita:
let target = resolveReference(ref, destinationEntity: rel.destinationEntity!.name!, in: context)
if target != nil {
object.setValue(target, forKey: name)
}
// else: target not pulled to this context yet — leave as-is. Critically we do NOT
// overwrite to nil and create orphan rows that dedupBudgets would group under a
// single nil-category bucket, summing 9 different-category Budgets into one row.
Se una transazione arriva prima della categoria a cui punta, non devi annullare la relazione. Se lo fai, crei righe orfane, che il passaggio di deduplicazione (più avanti) unirà allegramente tra loro. Ed è esattamente così che una volta ho prodotto un'unica riga di budget da 6,800 AED a partire da nove categorie che non c'entravano nulla l'una con l'altra.
Le parti difficili
HouseholdDedup: convergere senza un coordinatore
Quando due telefoni sono offline ed entrambi creano una categoria «Spesa», una volta sincronizzati ti ritrovi dei duplicati. Non c'è un server a fare da arbitro. La soluzione è la convergenza: entrambi i dispositivi eseguono lo stesso passaggio deterministico sugli stessi dati e producono lo stesso insieme di eliminazioni, che poi si propaga attraverso la normale pipeline di push. Nessun coordinamento necessario.
/// **Determinism:** both devices run dedup independently. As long as both pick the same
/// canonical for each group (we use lexicographically-smallest `cloudKitRecordName`,
/// preferring `isSystem` rows for Categories), they produce the same delete set →
/// converges to the same final state under LWW.
Prima di eliminare un duplicato, ripunta ogni relazione sul superstite e aggiorna lastModified, così l'unione vince in last-writer-wins sul dispositivo del partner:
let canonical = pickCanonicalCategory(from: dupes)
for dupe in dupes where dupe.objectID != canonical.objectID {
if let txns = dupe.transactions as? Set<Transaction> {
for txn in txns { txn.category = canonical; txn.lastModified = now }
}
// budgets, splits, aliases, recurringSeries likewise…
context.delete(dupe)
}
L'incidente dei 6,800 AED mi ha insegnato a essere paranoico sulla chiave di raggruppamento. Una delle prime build raggruppava i budget senza categoria sotto la stringa letterale "nil" e univa nove categorie diverse in un'unica riga enorme. Ora salta del tutto i budget senza categoria, e il traduttore (sopra) si rifiuta di creare quei nil fin dall'inizio. Due protezioni indipendenti per un solo bug, perché quel bug si presenta come un numero sbagliato nel budget di qualcuno, e in un'app di finanza non c'è bug peggiore di un numero sbagliato mostrato con sicurezza.
HouseholdShareAcceptance: l'aggancio dell'invito che SwiftUI ti nasconde
Quando il tuo partner tocca il link di condivisione, iOS deve passare alla tua app il CKShare.Metadata. Questo avviene tramite una callback di UIWindowSceneDelegate, che un'app SwiftUI pura non ha. Quindi ne fornisci una tramite UIApplicationDelegateAdaptor:
func windowScene(
_ windowScene: UIWindowScene,
userDidAcceptCloudKitShareWith cloudKitShareMetadata: CKShare.Metadata
) {
let metadata = cloudKitShareMetadata
Task {
do { try await HouseholdSync.shared.acceptShare(metadata: metadata) }
catch { /* … */ }
}
}
Lo stesso delegate gestisce i risvegli da notifiche push silenziose: una CKNotification validata avvia un pull + push e, dettaglio che fa risparmiare ore, alla fine segnala sempre .newData. Se segnali .failed, iOS inizia a limitare i tuoi push in background, e all'improvviso la sincronizzazione «in tempo reale» arriva con dieci minuti di ritardo.
HouseholdIdentity: sapere chi sei, in modo sincrono
Metà della logica deve rispondere a «quali di questi record sono miei?» senza un giro di rete. Per questo l'ID utente CloudKit dell'utente corrente viene risolto una volta e messo in cache in UserDefaults:
func refresh() async {
do {
let recordID = try await CKContainer(identifier: PersistenceController.cloudKitContainerID).userRecordID()
currentUserID = recordID.recordName
await runClaimSweepIfNeeded()
} catch {
Self.logger.info("Could not resolve CloudKit user ID (likely no iCloud account)…")
}
}
Quell'ID in cache marca i record con ownerCloudKitUserID e loggedByMemberID: è così che «Lascia la famiglia» sa quali righe tiene ciascuno, e così che il report delle spese per membro attribuisce un caffè al partner giusto.
Creare e accettare la condivisione
Lato proprietario: crea la zona, poi crea un solo CKShare sull'intera zona (non uno per record), cambia il tuo ruolo, azzera il change token, rimetti tutto in coda perché confluisca nella nuova zona ed elimina la zona personale ormai superata:
_ = try await privateDB.save(CKRecordZone(zoneID: householdZoneID))
let share = CKShare(recordZoneID: householdZoneID)
share[CKShare.SystemFieldKey.title] = "The Smart Budget — Household" as CKRecordValue
share.publicPermission = .none
_ = try await privateDB.save(share)
setRole(.owner)
UserDefaults.standard.removeObject(forKey: Self.serverChangeTokenKey)
await PushQueue.shared.clear()
await reenqueueAllLocalRecords(controller: controller)
await deletePersonalZoneServerSide()
Quel CKShare nudo lo presenti con il UICloudSharingController nativo di Apple: non ti serve NSPCKC per gestire l'interfaccia di invito del sistema, che è proprio la cosa che tutti credono ti leghi al framework. Lato partecipante, accetti e salvi l'ID del proprietario:
_ = try await container.accept(metadata)
// Owner's CKUserID is encoded in the share's zoneID — persist it so currentZoneID
// can construct the right (zoneName, ownerName) tuple on subsequent pushes/pulls.
let ownerName = metadata.share.recordID.zoneID.ownerName
UserDefaults.standard.set(ownerName, forKey: Self.householdOwnerCKUserIDKey)
setRole(.participant)
La risoluzione dei conflitti in tutto il sistema è last-writer-wins su un timestamp lastModified: la versione locale vince solo se è strettamente più recente, altrimenti si applica quella remota. Semplice, e converge.
Due storie di guerra che il codice ricorda ancora
La tempesta incontrollata di push su iCloud (risolta il 29 giugno 2026). L'insieme skipContexts (di LocalChangeWatcher) veniva svuotato sulla coda sbagliata rispetto al blocco dell'observer. Lo svuotamento continuava a perdere la corsa, così ogni scrittura dei metadati sfuggiva all'esterno come un nuovo push, che il partner scaricava e rimandava indietro: un loop di sincronizzazione infinito che martellava iCloud. La correzione è stata una riga sul momento in cui l'insieme dei contesti da saltare viene svuotato. I loop di retroazione nel codice di sincronizzazione non si annunciano; si limitano a consumare in silenzio il tuo budget di richieste.
La protezione dai pull obsoleti. Prima di partire, un fetch fotografa la modalità e la zona correnti. Se acceptShare o disconnect cambiano la modalità mentre è in corso, i risultati del fetch vengono scartati invece che interpretati male, perché un errore «zona sparita» durante un fetch avviato nella vecchia modalità non equivale a «il tuo partner ha chiuso la condivisione», e trattarlo come tale una volta ha fatto finire i dati di un coniuge nella zona sbagliata.
Lo estrarrei come SDK?
Qui dentro si nasconde un pacchetto CloudKitHouseholdSync pulito: HouseholdSync, PushQueue, RecordTranslator, LocalChangeWatcher, la deduplicazione, l'aggancio alla scena. Me lo chiedono in continuazione. La mia risposta onesta: non ancora. Contiene ipotesi specifiche di Smart Budget (la chiave della deduplicazione basata sul nome in minuscolo, l'ordine delle dipendenze tra entità) che dovrei generalizzare con attenzione, e non voglio pubblicare una libreria di sincronizzazione finché non si è dimostrata solida con famiglie reali in produzione. Il piano di estrazione è scritto. Uscirà quando la cosa se lo sarà guadagnato.
La lezione non è «non usare mai NSPersistentCloudKitContainer»: per un'app a utente singolo che vuole solo un backup su iCloud è ottimo, e dovresti usarlo. È questa: nel momento in cui mescoli un CKShare personalizzato in un database privato replicato da Core Data, hai lasciato la strada asfaltata, e il framework non ti dà alcuno strumento per sopravvivere fuori. Possedere la pipeline significa più codice. Ma è anche l'unica versione in cui, quando qualcosa va storto con i soldi condivisi di una famiglia, ho uno spiraglio in cui mettere le mani e sistemarlo, invece di «cancella tutto e ricomincia da capo».
The Smart Budget è disponibile ora su iPhone. La condivisione familiare è una funzione Pro; il motore di sincronizzazione personale che c'è sotto è lo stesso codice, attivo per tutti. Niente passa da un server sotto il mio controllo.
— Omar