# CloudKit avec SwiftUI en 2026 : synchronisation de données cross-device > Guide complet pour implémenter la synchronisation CloudKit avec SwiftUI : CKSyncEngine, intégration SwiftData, gestion des conflits et bonnes pratiques iOS 2026. - Published: 2026-03-14 - Updated: 2026-03-28 - Author: SharpSkill - Tags: cloudkit, swiftui, icloud, synchronisation, swift - Reading time: 18 min --- La synchronisation des données entre appareils Apple représente une fonctionnalité attendue par les utilisateurs modernes. CloudKit, le service cloud d'Apple, offre une solution gratuite et intégrée pour synchroniser les données via iCloud. Avec l'introduction de CKSyncEngine dans iOS 17 et les améliorations continues, la synchronisation cross-device devient plus accessible que jamais. > **Ce que couvre cet article** > > Ce guide explore l'implémentation complète de CloudKit avec SwiftUI : configuration initiale, CKSyncEngine pour un contrôle fin, intégration avec SwiftData, gestion des conflits et patterns avancés pour une synchronisation robuste. ## Comprendre l'architecture CloudKit CloudKit fonctionne avec trois types de bases de données distinctes, chacune servant un cas d'usage spécifique. Cette compréhension constitue le fondement de toute implémentation réussie. ```swift // CloudKitDatabases.swift // Les trois types de databases CloudKit import CloudKit /* TYPES DE DATABASES CLOUDKIT : 1. PUBLIC DATABASE - Accessible à tous les utilisateurs de l'app - Quota stockage compté sur le quota développeur - Idéal pour : contenu partagé, données de référence 2. PRIVATE DATABASE - Données privées de chaque utilisateur - Quota stockage compté sur l'iCloud de l'utilisateur - Idéal pour : données personnelles, préférences 3. SHARED DATABASE - Partage de données entre utilisateurs spécifiques - Basé sur les zones partagées depuis la private database - Idéal pour : collaboration, partage familial */ class CloudKitManager { // Référence au container CloudKit private let container: CKContainer // Accès aux différentes databases var publicDatabase: CKDatabase { container.publicCloudDatabase } var privateDatabase: CKDatabase { container.privateCloudDatabase } var sharedDatabase: CKDatabase { container.sharedCloudDatabase } init(containerIdentifier: String? = nil) { // Utilise le container par défaut ou un container spécifique if let identifier = containerIdentifier { container = CKContainer(identifier: identifier) } else { container = CKContainer.default() } } } ``` L'avantage majeur de CloudKit réside dans le stockage gratuit pour les données privées : chaque utilisateur utilise son propre quota iCloud, ce qui élimine les coûts de serveur pour le développeur. ## Configuration du projet Xcode Avant d'écrire du code, la configuration Xcode requiert plusieurs étapes essentielles pour activer CloudKit dans l'application. ```swift // ProjectConfiguration.swift // Étapes de configuration dans Xcode /* CONFIGURATION XCODE POUR CLOUDKIT : 1. SIGNING & CAPABILITIES ├── + Capability → iCloud ├── Cocher "CloudKit" └── Sélectionner ou créer un container (iCloud.com.yourcompany.appname) 2. BACKGROUND MODES (optionnel mais recommandé) ├── + Capability → Background Modes └── Cocher "Remote notifications" 3. CLOUDKIT DASHBOARD ├── Accéder via : https://icloud.developer.apple.com ├── Créer les Record Types nécessaires └── Définir les index pour les requêtes INFO.PLIST REQUIS : - UIBackgroundModes : ["remote-notification"] */ // Vérification du statut iCloud au lancement import CloudKit import SwiftUI @MainActor class CloudKitAuthManager: ObservableObject { @Published var accountStatus: CKAccountStatus = .couldNotDetermine @Published var isSignedIn: Bool = false @Published var errorMessage: String? func checkAccountStatus() async { do { // Vérifie si l'utilisateur est connecté à iCloud let status = try await CKContainer.default().accountStatus() accountStatus = status isSignedIn = status == .available if status != .available { errorMessage = statusMessage(for: status) } } catch { errorMessage = "Erreur vérification iCloud: \(error.localizedDescription)" } } private func statusMessage(for status: CKAccountStatus) -> String { switch status { case .available: return "iCloud disponible" case .noAccount: return "Aucun compte iCloud configuré" case .restricted: return "Accès iCloud restreint" case .couldNotDetermine: return "Impossible de déterminer le statut" case .temporarilyUnavailable: return "iCloud temporairement indisponible" @unknown default: return "Statut inconnu" } } } ``` > **Compte iCloud requis** > > Les utilisateurs doivent être connectés à iCloud pour que la synchronisation fonctionne. Une interface informant l'utilisateur et le guidant vers les Réglages améliore l'expérience en cas de compte non configuré. ## Définir les modèles de données CloudKit CloudKit utilise des `CKRecord` pour stocker les données. La création de modèles Swift mappés à ces records facilite la manipulation des données dans l'application. ```swift // CloudKitModels.swift // Définition des modèles synchronisés import CloudKit import Foundation // Protocole pour les modèles CloudKit protocol CloudKitRecord { static var recordType: String { get } var record: CKRecord { get } init?(record: CKRecord) } // Modèle Note synchronisé via CloudKit struct Note: Identifiable, CloudKitRecord { let id: UUID var title: String var content: String var createdAt: Date var modifiedAt: Date var isFavorite: Bool // Nom du type dans CloudKit Dashboard static var recordType: String { "Note" } // Convertit le modèle en CKRecord var record: CKRecord { // Utilise l'UUID comme identifiant du record let recordID = CKRecord.ID(recordName: id.uuidString) let record = CKRecord(recordType: Self.recordType, recordID: recordID) // Map les propriétés vers les champs CloudKit record["title"] = title as CKRecordValue record["content"] = content as CKRecordValue record["createdAt"] = createdAt as CKRecordValue record["modifiedAt"] = modifiedAt as CKRecordValue record["isFavorite"] = isFavorite as CKRecordValue return record } // Initialise depuis un CKRecord init?(record: CKRecord) { guard record.recordType == Self.recordType, let title = record["title"] as? String, let content = record["content"] as? String, let createdAt = record["createdAt"] as? Date, let modifiedAt = record["modifiedAt"] as? Date, let isFavorite = record["isFavorite"] as? Bool else { return nil } // Extrait l'UUID depuis le recordName guard let uuid = UUID(uuidString: record.recordID.recordName) else { return nil } self.id = uuid self.title = title self.content = content self.createdAt = createdAt self.modifiedAt = modifiedAt self.isFavorite = isFavorite } // Initialisation standard init( id: UUID = UUID(), title: String, content: String = "", createdAt: Date = Date(), modifiedAt: Date = Date(), isFavorite: Bool = false ) { self.id = id self.title = title self.content = content self.createdAt = createdAt self.modifiedAt = modifiedAt self.isFavorite = isFavorite } } ``` La bidirectionnalité du mapping permet de passer facilement du modèle Swift au CKRecord et inversement lors des opérations de synchronisation. ## Implémentation de CKSyncEngine CKSyncEngine, introduit dans iOS 17, simplifie drastiquement la synchronisation CloudKit. Ce framework gère automatiquement la complexité des opérations réseau, du caching et de la gestion des erreurs. ```swift // SyncEngine.swift // Configuration de CKSyncEngine pour la synchronisation automatique import CloudKit import OSLog // Logger dédié pour le debugging private let logger = Logger(subsystem: "com.app.sync", category: "SyncEngine") @MainActor class NoteSyncEngine: ObservableObject { private var syncEngine: CKSyncEngine? private let database: CKDatabase // Zone personnalisée pour les notes private let zoneID = CKRecordZone.ID( zoneName: "NotesZone", ownerName: CKCurrentUserDefaultName ) // Cache local des notes @Published private(set) var notes: [Note] = [] // Statut de synchronisation @Published private(set) var isSyncing: Bool = false @Published private(set) var lastSyncDate: Date? // Token de changements pour la reprise private var lastChangeToken: CKServerChangeToken? init() { database = CKContainer.default().privateCloudDatabase } // Initialise le sync engine au lancement func initialize() async throws { // Crée la zone si nécessaire try await createZoneIfNeeded() // Configure le sync engine let configuration = CKSyncEngine.Configuration( database: database, stateSerialization: loadSavedState(), delegate: self ) syncEngine = CKSyncEngine(configuration) logger.info("CKSyncEngine initialisé") } // Crée la zone CloudKit pour les records private func createZoneIfNeeded() async throws { let zone = CKRecordZone(zoneID: zoneID) do { _ = try await database.save(zone) logger.info("Zone créée: \(self.zoneID.zoneName)") } catch let error as CKError where error.code == .serverRecordChanged { // Zone existe déjà, OK logger.debug("Zone existe déjà") } } // Charge l'état sauvegardé pour la reprise private func loadSavedState() -> CKSyncEngine.State.Serialization? { guard let data = UserDefaults.standard.data(forKey: "syncEngineState"), let state = try? JSONDecoder().decode( CKSyncEngine.State.Serialization.self, from: data ) else { return nil } return state } // Sauvegarde l'état pour la prochaine session private func saveState(_ state: CKSyncEngine.State.Serialization) { if let data = try? JSONEncoder().encode(state) { UserDefaults.standard.set(data, forKey: "syncEngineState") } } } ``` La configuration du delegate permet de réagir aux événements de synchronisation et de fournir les données à synchroniser. ```swift // SyncEngineDelegate.swift // Extension pour le protocole CKSyncEngineDelegate extension NoteSyncEngine: CKSyncEngineDelegate { // Gère les événements de synchronisation func handleEvent(_ event: CKSyncEngine.Event, syncEngine: CKSyncEngine) { switch event { case .stateUpdate(let stateUpdate): // Sauvegarde l'état pour la reprise saveState(stateUpdate.stateSerialization) case .accountChange(let accountChange): // L'utilisateur a changé de compte iCloud handleAccountChange(accountChange) case .fetchedDatabaseChanges(let databaseChanges): // Nouvelles zones ou zones supprimées handleDatabaseChanges(databaseChanges) case .fetchedRecordZoneChanges(let zoneChanges): // Changements dans les records handleZoneChanges(zoneChanges) case .sentDatabaseChanges(let sentChanges): // Confirmation des changements envoyés handleSentChanges(sentChanges) case .sentRecordZoneChanges(let sentZoneChanges): // Records envoyés au serveur handleSentZoneChanges(sentZoneChanges) case .willFetchChanges, .willFetchRecordZoneChanges, .didFetchChanges, .didFetchRecordZoneChanges, .willSendChanges, .didSendChanges: // Événements de progression updateSyncingStatus(event) @unknown default: logger.warning("Événement inconnu: \(String(describing: event))") } } // Fournit les changements à envoyer au serveur func nextRecordZoneChangeBatch( _ context: CKSyncEngine.SendChangesContext, syncEngine: CKSyncEngine ) -> CKSyncEngine.RecordZoneChangeBatch? { // Récupère les records modifiés en attente d'envoi let pendingChanges = syncEngine.state.pendingRecordZoneChanges // Filtre pour la zone concernée let relevantChanges = pendingChanges.filter { change in switch change { case .saveRecord(let recordID): return recordID.zoneID == zoneID case .deleteRecord(let recordID): return recordID.zoneID == zoneID @unknown default: return false } } guard !relevantChanges.isEmpty else { return nil } // Construit le batch avec les records à sauvegarder var recordsToSave: [CKRecord] = [] var recordIDsToDelete: [CKRecord.ID] = [] for change in relevantChanges { switch change { case .saveRecord(let recordID): // Trouve la note correspondante dans le cache if let note = notes.first(where: { $0.id.uuidString == recordID.recordName }) { recordsToSave.append(note.record) } case .deleteRecord(let recordID): recordIDsToDelete.append(recordID) @unknown default: break } } return CKSyncEngine.RecordZoneChangeBatch( recordsToSave: recordsToSave, recordIDsToDelete: recordIDsToDelete, atomicByZone: true ) } // Traite les changements reçus du serveur private func handleZoneChanges( _ changes: CKSyncEngine.Event.FetchedRecordZoneChanges ) { // Traite les modifications for modification in changes.modifications { if let note = Note(record: modification.record) { // Met à jour ou ajoute la note if let index = notes.firstIndex(where: { $0.id == note.id }) { notes[index] = note } else { notes.append(note) } logger.debug("Note synchronisée: \(note.title)") } } // Traite les suppressions for deletion in changes.deletions { notes.removeAll { $0.id.uuidString == deletion.recordID.recordName } logger.debug("Note supprimée: \(deletion.recordID.recordName)") } // Met à jour la date de dernière sync lastSyncDate = Date() } private func handleAccountChange( _ change: CKSyncEngine.Event.AccountChange ) { switch change.changeType { case .signIn: logger.info("Utilisateur connecté à iCloud") Task { try? await initialize() } case .signOut: logger.info("Utilisateur déconnecté") notes.removeAll() case .switchAccounts: logger.info("Changement de compte iCloud") notes.removeAll() Task { try? await initialize() } @unknown default: break } } private func updateSyncingStatus(_ event: CKSyncEngine.Event) { switch event { case .willFetchChanges, .willSendChanges: isSyncing = true case .didFetchChanges, .didSendChanges: isSyncing = false default: break } } } ``` ## Opérations CRUD avec CKSyncEngine L'intégration des opérations CRUD avec CKSyncEngine nécessite d'informer le moteur des changements locaux pour qu'il les synchronise. ```swift // CRUDOperations.swift // Opérations CRUD intégrées avec CKSyncEngine extension NoteSyncEngine { // CREATE - Ajoute une nouvelle note func addNote(title: String, content: String) { let note = Note(title: title, content: content) // Ajoute au cache local notes.append(note) // Informe le sync engine du nouveau record let recordID = CKRecord.ID( recordName: note.id.uuidString, zoneID: zoneID ) syncEngine?.state.add(pendingRecordZoneChanges: [ .saveRecord(recordID) ]) logger.info("Note ajoutée localement: \(note.title)") } // UPDATE - Modifie une note existante func updateNote(_ note: Note, title: String? = nil, content: String? = nil) { guard let index = notes.firstIndex(where: { $0.id == note.id }) else { return } // Met à jour les propriétés modifiées var updatedNote = notes[index] if let title { updatedNote.title = title } if let content { updatedNote.content = content } updatedNote.modifiedAt = Date() // Met à jour le cache local notes[index] = updatedNote // Marque le record comme modifié let recordID = CKRecord.ID( recordName: note.id.uuidString, zoneID: zoneID ) syncEngine?.state.add(pendingRecordZoneChanges: [ .saveRecord(recordID) ]) logger.info("Note mise à jour: \(updatedNote.title)") } // DELETE - Supprime une note func deleteNote(_ note: Note) { // Supprime du cache local notes.removeAll { $0.id == note.id } // Marque le record comme supprimé let recordID = CKRecord.ID( recordName: note.id.uuidString, zoneID: zoneID ) syncEngine?.state.add(pendingRecordZoneChanges: [ .deleteRecord(recordID) ]) logger.info("Note supprimée: \(note.title)") } // TOGGLE FAVORITE - Modifie le statut favori func toggleFavorite(_ note: Note) { guard let index = notes.firstIndex(where: { $0.id == note.id }) else { return } notes[index].isFavorite.toggle() notes[index].modifiedAt = Date() let recordID = CKRecord.ID( recordName: note.id.uuidString, zoneID: zoneID ) syncEngine?.state.add(pendingRecordZoneChanges: [ .saveRecord(recordID) ]) } } ``` Cette architecture garantit que chaque modification locale est automatiquement synchronisée avec iCloud lorsque la connexion est disponible. ## Intégration avec SwiftUI L'intégration dans une interface SwiftUI utilise l'observable `NoteSyncEngine` pour afficher et manipuler les données synchronisées. ```swift // NotesView.swift // Interface SwiftUI avec synchronisation CloudKit import SwiftUI struct NotesListView: View { @StateObject private var syncEngine = NoteSyncEngine() @State private var showingAddNote = false @State private var searchText = "" // Filtrage des notes private var filteredNotes: [Note] { if searchText.isEmpty { return syncEngine.notes.sorted { $0.modifiedAt > $1.modifiedAt } } return syncEngine.notes.filter { note in note.title.localizedCaseInsensitiveContains(searchText) || note.content.localizedCaseInsensitiveContains(searchText) } } var body: some View { NavigationStack { List { // Section des favoris if !favorites.isEmpty { Section("Favoris") { ForEach(favorites) { note in NoteRowView(note: note, syncEngine: syncEngine) } } } // Toutes les notes Section("Notes") { ForEach(filteredNotes.filter { !$0.isFavorite }) { note in NoteRowView(note: note, syncEngine: syncEngine) } .onDelete(perform: deleteNotes) } } .searchable(text: $searchText, prompt: "Rechercher...") .navigationTitle("Notes") .toolbar { ToolbarItem(placement: .topBarLeading) { SyncStatusView( isSyncing: syncEngine.isSyncing, lastSync: syncEngine.lastSyncDate ) } ToolbarItem(placement: .topBarTrailing) { Button { showingAddNote = true } label: { Image(systemName: "plus") } } } .sheet(isPresented: $showingAddNote) { AddNoteView(syncEngine: syncEngine) } .task { // Initialise la synchronisation au lancement try? await syncEngine.initialize() } } } private var favorites: [Note] { filteredNotes.filter { $0.isFavorite } } private func deleteNotes(at offsets: IndexSet) { let notesToDelete = offsets.map { filteredNotes[$0] } for note in notesToDelete { syncEngine.deleteNote(note) } } } // Ligne de note avec actions struct NoteRowView: View { let note: Note let syncEngine: NoteSyncEngine var body: some View { NavigationLink { NoteDetailView(note: note, syncEngine: syncEngine) } label: { HStack { VStack(alignment: .leading, spacing: 4) { Text(note.title) .font(.headline) Text(note.content) .font(.subheadline) .foregroundStyle(.secondary) .lineLimit(2) Text(note.modifiedAt, style: .relative) .font(.caption2) .foregroundStyle(.tertiary) } Spacer() if note.isFavorite { Image(systemName: "star.fill") .foregroundStyle(.yellow) } } } .swipeActions(edge: .leading) { Button { syncEngine.toggleFavorite(note) } label: { Label( note.isFavorite ? "Retirer" : "Favori", systemImage: note.isFavorite ? "star.slash" : "star" ) } .tint(.yellow) } } } // Indicateur de statut de synchronisation struct SyncStatusView: View { let isSyncing: Bool let lastSync: Date? var body: some View { HStack(spacing: 4) { if isSyncing { ProgressView() .scaleEffect(0.8) Text("Sync...") .font(.caption) } else if let lastSync { Image(systemName: "checkmark.icloud") .foregroundStyle(.green) Text(lastSync, style: .time) .font(.caption) .foregroundStyle(.secondary) } else { Image(systemName: "icloud.slash") .foregroundStyle(.secondary) } } } } ``` L'interface affiche un indicateur de synchronisation et permet toutes les opérations CRUD avec mise à jour automatique via CloudKit. ## Intégration SwiftData avec CloudKit SwiftData offre une intégration native avec CloudKit via `ModelConfiguration`. Cette approche simplifie considérablement l'implémentation pour les applications utilisant SwiftData. ```swift // SwiftDataCloudKit.swift // Configuration SwiftData avec synchronisation CloudKit automatique import SwiftData import SwiftUI // Modèle SwiftData compatible CloudKit @Model final class SyncedNote { // CloudKit nécessite des optionnels ou valeurs par défaut var id: UUID = UUID() var title: String = "" var content: String = "" var createdAt: Date = Date() var modifiedAt: Date = Date() var isFavorite: Bool = false init(title: String, content: String = "") { self.title = title self.content = content } } // Configuration de l'app avec CloudKit @main struct CloudNotesApp: App { var sharedModelContainer: ModelContainer = { let schema = Schema([SyncedNote.self]) // Configuration avec CloudKit activé let modelConfiguration = ModelConfiguration( schema: schema, isStoredInMemoryOnly: false, // Active la synchronisation CloudKit cloudKitDatabase: .private("iCloud.com.yourcompany.cloudnotes") ) do { return try ModelContainer( for: schema, configurations: [modelConfiguration] ) } catch { fatalError("Erreur création ModelContainer: \(error)") } }() var body: some Scene { WindowGroup { ContentView() } .modelContainer(sharedModelContainer) } } ``` > **Contraintes SwiftData + CloudKit** > > Pour la compatibilité CloudKit, toutes les propriétés SwiftData doivent être optionnelles ou avoir des valeurs par défaut, et toutes les relations doivent être optionnelles. Ces contraintes garantissent la synchronisation correcte entre appareils. ```swift // SwiftDataCloudKitView.swift // Interface utilisant SwiftData avec CloudKit import SwiftUI import SwiftData struct SwiftDataNotesView: View { @Environment(\.modelContext) private var modelContext // Query automatiquement synchronisé via CloudKit @Query(sort: \SyncedNote.modifiedAt, order: .reverse) private var notes: [SyncedNote] @State private var newNoteTitle = "" var body: some View { NavigationStack { List { // Formulaire d'ajout Section { HStack { TextField("Nouvelle note...", text: $newNoteTitle) Button { addNote() } label: { Image(systemName: "plus.circle.fill") } .disabled(newNoteTitle.isEmpty) } } // Liste des notes Section { ForEach(notes) { note in NavigationLink { SwiftDataNoteEditor(note: note) } label: { VStack(alignment: .leading) { Text(note.title) .font(.headline) Text(note.modifiedAt, style: .relative) .font(.caption) .foregroundStyle(.secondary) } } } .onDelete(perform: deleteNotes) } } .navigationTitle("Notes iCloud") } } private func addNote() { let note = SyncedNote(title: newNoteTitle) modelContext.insert(note) newNoteTitle = "" // La sauvegarde et sync sont automatiques } private func deleteNotes(at offsets: IndexSet) { for index in offsets { modelContext.delete(notes[index]) } } } // Éditeur de note avec sauvegarde automatique struct SwiftDataNoteEditor: View { @Bindable var note: SyncedNote var body: some View { Form { Section("Titre") { TextField("Titre", text: $note.title) .onChange(of: note.title) { note.modifiedAt = Date() } } Section("Contenu") { TextEditor(text: $note.content) .frame(minHeight: 200) .onChange(of: note.content) { note.modifiedAt = Date() } } Section("Informations") { LabeledContent("Créé le") { Text(note.createdAt, style: .date) } LabeledContent("Modifié le") { Text(note.modifiedAt, style: .relative) } } } .navigationTitle("Éditer") } } ``` ## Gestion des conflits de synchronisation Les conflits surviennent lorsque le même record est modifié sur plusieurs appareils avant synchronisation. CloudKit fournit les outils pour détecter et résoudre ces situations. ```swift // ConflictResolution.swift // Stratégies de résolution de conflits CloudKit import CloudKit enum ConflictResolutionStrategy { case serverWins // Le serveur a toujours raison case clientWins // Le client écrase le serveur case merge // Fusion intelligente des champs case askUser // Demande à l'utilisateur } class ConflictResolver { let strategy: ConflictResolutionStrategy init(strategy: ConflictResolutionStrategy = .merge) { self.strategy = strategy } // Résout un conflit entre version locale et serveur func resolve( localNote: Note, serverRecord: CKRecord ) -> CKRecord { guard let serverNote = Note(record: serverRecord) else { // Si parsing échoue, utilise la version locale return localNote.record } switch strategy { case .serverWins: // Garde la version serveur telle quelle return serverRecord case .clientWins: // Écrase avec la version locale // Mais garde les metadata du serveur let record = serverRecord record["title"] = localNote.title as CKRecordValue record["content"] = localNote.content as CKRecordValue record["modifiedAt"] = localNote.modifiedAt as CKRecordValue record["isFavorite"] = localNote.isFavorite as CKRecordValue return record case .merge: // Fusion intelligente basée sur les timestamps return mergeRecords(local: localNote, server: serverNote, record: serverRecord) case .askUser: // Retourne serveur par défaut, l'UI gère l'affichage return serverRecord } } // Fusion intelligente champ par champ private func mergeRecords( local: Note, server: Note, record: CKRecord ) -> CKRecord { // Garde la version la plus récente de chaque champ // En pratique, on pourrait tracker les modifications par champ if local.modifiedAt > server.modifiedAt { // Local plus récent : prend les valeurs locales record["title"] = local.title as CKRecordValue record["content"] = local.content as CKRecordValue record["modifiedAt"] = local.modifiedAt as CKRecordValue record["isFavorite"] = local.isFavorite as CKRecordValue } // Sinon garde les valeurs serveur (déjà dans record) return record } } // Extension pour gérer les erreurs de conflit dans CKSyncEngine extension NoteSyncEngine { func handleSentZoneChanges( _ changes: CKSyncEngine.Event.SentRecordZoneChanges ) { // Traite les succès for savedRecord in changes.savedRecords { logger.debug("Record sauvegardé: \(savedRecord.recordID.recordName)") } // Traite les échecs avec gestion des conflits for failedSave in changes.failedRecordSaves { let recordID = failedSave.record.recordID let error = failedSave.error if let ckError = error as? CKError, ckError.code == .serverRecordChanged, let serverRecord = ckError.serverRecord { // Conflit détecté ! handleConflict( localRecord: failedSave.record, serverRecord: serverRecord ) } else { logger.error("Échec sauvegarde: \(error.localizedDescription)") } } } private func handleConflict(localRecord: CKRecord, serverRecord: CKRecord) { guard let localNote = Note(record: localRecord) else { return } let resolver = ConflictResolver(strategy: .merge) let resolvedRecord = resolver.resolve( localNote: localNote, serverRecord: serverRecord ) // Relance la sauvegarde avec le record résolu syncEngine?.state.add(pendingRecordZoneChanges: [ .saveRecord(resolvedRecord.recordID) ]) // Met à jour le cache local si nécessaire if let resolvedNote = Note(record: resolvedRecord), let index = notes.firstIndex(where: { $0.id == resolvedNote.id }) { notes[index] = resolvedNote } } } ``` ## Gestion du mode hors ligne Une application robuste doit fonctionner même sans connexion internet. CKSyncEngine gère automatiquement la mise en queue des opérations, mais une persistance locale améliore l'expérience. ```swift // OfflineSupport.swift // Persistance locale pour le mode hors ligne import Foundation class LocalPersistence { private let fileManager = FileManager.default private let notesURL: URL init() { // Stockage dans le dossier Documents let documentsPath = fileManager.urls( for: .documentDirectory, in: .userDomainMask ).first! notesURL = documentsPath.appending(path: "cached_notes.json") } // Sauvegarde les notes localement func saveNotes(_ notes: [Note]) { do { let data = try JSONEncoder().encode(notes) try data.write(to: notesURL) } catch { print("Erreur sauvegarde locale: \(error)") } } // Charge les notes depuis le cache local func loadNotes() -> [Note] { guard fileManager.fileExists(atPath: notesURL.path), let data = try? Data(contentsOf: notesURL), let notes = try? JSONDecoder().decode([Note].self, from: data) else { return [] } return notes } } // Extension Note pour Codable extension Note: Codable { enum CodingKeys: String, CodingKey { case id, title, content, createdAt, modifiedAt, isFavorite } } // Extension du SyncEngine avec support hors ligne extension NoteSyncEngine { private var localPersistence: LocalPersistence { LocalPersistence() } // Charge les données au démarrage (avant sync) func loadCachedData() { let cachedNotes = localPersistence.loadNotes() if !cachedNotes.isEmpty { notes = cachedNotes logger.info("Chargé \(cachedNotes.count) notes depuis le cache") } } // Sauvegarde après chaque modification func persistLocally() { localPersistence.saveNotes(notes) } } ``` > **Stratégie de persistance** > > L'utilisation combinée de CKSyncEngine et d'un cache local JSONassure une expérience fluide. L'utilisateur voit immédiatement ses données au lancement, puis les mises à jour CloudKit s'appliquent en arrière-plan. ## Optimisation des performances Les bonnes pratiques d'optimisation garantissent une synchronisation efficace sans impact sur l'autonomie de la batterie. ```swift // PerformanceOptimization.swift // Techniques d'optimisation pour CloudKit import CloudKit import Network class SyncOptimizer { private let networkMonitor = NWPathMonitor() private let monitorQueue = DispatchQueue(label: "NetworkMonitor") @Published private(set) var isConnected = false @Published private(set) var isExpensiveConnection = false init() { startNetworkMonitoring() } // Monitore la connectivité réseau private func startNetworkMonitoring() { networkMonitor.pathUpdateHandler = { [weak self] path in DispatchQueue.main.async { self?.isConnected = path.status == .satisfied self?.isExpensiveConnection = path.isExpensive } } networkMonitor.start(queue: monitorQueue) } // Détermine si la sync doit se faire maintenant func shouldSyncNow(priority: SyncPriority) -> Bool { guard isConnected else { return false } switch priority { case .immediate: // Sync immédiate (modification utilisateur) return true case .background: // Évite les connexions coûteuses pour le background return !isExpensiveConnection case .batch: // Batch uniquement sur WiFi return !isExpensiveConnection } } enum SyncPriority { case immediate // Modification utilisateur active case background // Refresh automatique case batch // Opérations groupées } } // Batching des opérations pour efficacité extension NoteSyncEngine { // Groupe plusieurs modifications avant sync private var pendingModifications: [CKRecord.ID] = [] private var batchTimer: Timer? func scheduleSync(for recordID: CKRecord.ID) { pendingModifications.append(recordID) // Annule le timer précédent batchTimer?.invalidate() // Démarre un nouveau timer de 2 secondes batchTimer = Timer.scheduledTimer(withTimeInterval: 2.0, repeats: false) { [weak self] _ in self?.flushPendingSync() } } private func flushPendingSync() { guard !pendingModifications.isEmpty else { return } // Envoie tous les changements en attente let changes = pendingModifications.map { CKSyncEngine.PendingRecordZoneChange.saveRecord($0) } syncEngine?.state.add(pendingRecordZoneChanges: changes) pendingModifications.removeAll() } } ``` ## Tests et debugging CloudKit Le debugging CloudKit nécessite des outils spécifiques pour observer les opérations de synchronisation. ```swift // CloudKitDebugging.swift // Outils de debugging pour CloudKit import CloudKit import OSLog class CloudKitDebugger { private let logger = Logger(subsystem: "com.app", category: "CloudKit") // Affiche l'état complet de synchronisation func printSyncState(engine: CKSyncEngine) { let state = engine.state logger.debug(""" ══════════════════════════════════════ CLOUDKIT SYNC STATE ══════════════════════════════════════ Pending record changes: \(state.pendingRecordZoneChanges.count) Pending database changes: \(state.pendingDatabaseChanges.count) Has changes to send: \(state.hasPendingUploads) ══════════════════════════════════════ """) } // Vérifie la connectivité CloudKit func checkCloudKitStatus() async { do { let status = try await CKContainer.default().accountStatus() logger.info("Account status: \(String(describing: status))") // Vérifie les permissions let permissions = try await CKContainer.default() .status(forApplicationPermission: .userDiscoverability) logger.info("Permissions: \(String(describing: permissions))") } catch { logger.error("CloudKit check failed: \(error.localizedDescription)") } } // Liste tous les records dans une zone func listRecords(in zoneID: CKRecordZone.ID) async { let database = CKContainer.default().privateCloudDatabase let query = CKQuery( recordType: "Note", predicate: NSPredicate(value: true) ) do { let (results, _) = try await database.records( matching: query, inZoneWith: zoneID ) logger.info("Found \(results.count) records:") for (id, result) in results { switch result { case .success(let record): logger.debug(" - \(id.recordName): \(record["title"] ?? "no title")") case .failure(let error): logger.error(" - \(id.recordName): ERROR \(error)") } } } catch { logger.error("Query failed: \(error)") } } } // Configuration des logs CloudKit en développement #if DEBUG extension NoteSyncEngine { func enableVerboseLogging() { // Active les logs détaillés CloudKit UserDefaults.standard.set(true, forKey: "com.apple.cloudkit.verbose") } } #endif ``` ## Conclusion CloudKit avec SwiftUI offre une solution puissante et gratuite pour la synchronisation cross-device. L'introduction de CKSyncEngine simplifie considérablement l'implémentation tout en gardant un contrôle fin sur le processus de synchronisation. ### Points clés à retenir - ✅ CloudKit stocke les données privées sur le quota iCloud de l'utilisateur (gratuit pour le développeur) - ✅ CKSyncEngine (iOS 17+) automatise la complexité de la synchronisation - ✅ SwiftData offre une intégration CloudKit native via `ModelConfiguration` - ✅ La gestion des conflits nécessite une stratégie explicite (merge, server wins, etc.) - ✅ Le cache local assure le fonctionnement hors ligne - ✅ Le batching des opérations optimise la consommation batterie - ✅ Les zones personnalisées permettent une organisation logique des données - ✅ Le monitoring réseau aide à adapter la stratégie de synchronisation --- Source: SharpSkill (https://sharpskill.dev), tech interview preparation for your real stack. HTML version of this page: https://sharpskill.dev/fr/blog/ios/cloudkit-swiftui-synchronisation-cross-device