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.

Synchronisation CloudKit avec SwiftUI pour développeurs iOS

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.

CloudKitDatabases.swiftswift
// 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.

ProjectConfiguration.swiftswift
// É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.

CloudKitModels.swiftswift
// 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.

SyncEngine.swiftswift
// 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.

SyncEngineDelegate.swiftswift
// 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
        }
    }
}

Prêt à réussir tes entretiens iOS ?

Entraîne-toi avec nos simulateurs interactifs, fiches express et tests techniques.

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.

CRUDOperations.swiftswift
// 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.

NotesView.swiftswift
// 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.

SwiftDataCloudKit.swiftswift
// 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.

SwiftDataCloudKitView.swiftswift
// 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.

ConflictResolution.swiftswift
// 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.

OfflineSupport.swiftswift
// 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.

PerformanceOptimization.swiftswift
// 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.

CloudKitDebugging.swiftswift
// 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

Passe à la pratique !

Teste tes connaissances avec nos simulateurs d'entretien et tests techniques.

Tags

#cloudkit
#swiftui
#icloud
#synchronisation
#swift

Partager

Articles similaires