Fiche 09.07 — Mode offline et synchronisation locale/distante
Objectif
Faire en sorte que ton app reste utilisable sans réseau : afficher le cache local immédiatement (stale-while-revalidate), rafraîchir depuis le réseau quand c'est possible, et rejouer les écritures faites offline. On vise du simple et robuste, pas un moteur de sync type CloudKit/CRDT.
1. Le principe : le store local est la source de vérité
Réflexe UIKit naïf : la View appelle l'API, reçoit du JSON, l'affiche. Si pas de réseau → écran vide ou erreur.
Réflexe moderne : l'UI n'observe jamais le réseau, elle observe le store local (SwiftData ou fichier). Le réseau ne fait qu'alimenter ce store.
Conséquences directes :
- offline, l'UI affiche quand même les dernières données connues ;
- un seul endroit décide ce qui s'affiche → pas de désynchro entre écrans ;
- "refresh" = relancer le réseau, pas recharger l'UI à la main.
C'est le pattern single source of truth. En UIKit tu le bricolais à la main (Core Data + NSFetchedResultsController) ; en SwiftUI c'est natif via @Query (SwiftData) ou un store @Observable.
2. Stale-while-revalidate
L'idée : montre tout de suite le cache (potentiellement périmé), puis rafraîchis en arrière-plan. L'utilisateur ne voit jamais de spinner plein écran si du cache existe.
Point clé : en cas d'échec réseau, on ne vide pas articles. Le cache reste à l'écran.
Réflexe UIKit -> moderne : avant tu enchaînais URLSession + reload de la table ; ici await refresh() met à jour une propriété @Observable et SwiftUI re-render tout seul.
3. La couche cache (fichier simple)
Pas besoin de SwiftData pour démarrer. Un fichier JSON dans le dossier Caches suffit et reste testable.
actor te protège des écritures concurrentes sans DispatchQueue ni lock manuel. URL.cachesDirectory (iOS 16+) remplace le vieux FileManager.default.urls(for:in:).
Note : .cachesDirectory peut être purgé par le système sous pression disque. Pour des données que l'utilisateur a créées et qui ne doivent PAS disparaître, utilise URL.documentsDirectory (ou SwiftData).
4. Détecter online/offline avec NWPathMonitor
NWPathMonitor (framework Network) te donne l'état de connexion en temps réel. On l'expose en @Observable pour que l'UI réagisse.
Injecte-le dans l'environnement une seule fois :
Piège classique : isConnected == true ne garantit pas qu'une requête réussira (captive portal, serveur down, DNS). Traite-le comme un indice UI, pas comme une certitude. La vérité reste : "la requête a-t-elle marché ?".
5. Outbox : la file d'écritures à rejouer
Le cas dur de l'offline, ce sont les écritures (create/update/delete). Solution simple et éprouvée : quand on est offline, on enregistre l'action localement avec un flag pending, on l'applique optimistement à l'UI, et on la rejoue quand le réseau revient.
L'outbox est juste une liste persistée (même mécanisme que le cache) :
Côté store, créer un article offline = l'ajouter localement + empiler dans l'outbox :
6. Rejouer l'outbox quand la connexion revient
Deux déclencheurs : (a) après chaque action, (b) quand NetworkMonitor repasse online.
Pour déclencher au retour réseau, observe la transition dans une vue racine :
Deux pièges à anticiper :
- Idempotence : si le flush envoie un
createpuis crashe avant ledequeue, tu rejoueras le même create → doublon. Mets unid(UUID) généré côté client dans la requête pour que le serveur déduplique (PUT /articles/{id}). - Ordre : rejoue les opérations dans l'ordre d'enqueue (FIFO), sinon un
updatepeut partir avant lecreate.
7. Conflits : last-write-wins et ses limites
Quand deux appareils modifient la même donnée offline, il faut une règle. La plus simple est last-write-wins (LWW) : la dernière écriture (timestamp le plus récent) gagne.
C'est suffisant pour 90 % des apps grand public. Mais sache ce que tu perds :
- Perte de données silencieuse : si A met le titre et B met le texte hors ligne, le perdant écrase l'autre champ entièrement. Pas de merge par champ.
- Horloges non fiables : les timestamps viennent d'appareils dont l'heure peut être fausse. Idéalement, c'est le serveur qui horodate (ou un compteur de version).
- Pas d'historique : tu ne peux pas proposer "garder les deux" à l'utilisateur.
Si ces limites posent problème, c'est le signal pour passer à du versioning (numéro de version + rejet 409 Conflict) ou à une vraie solution de sync (CloudKit, CRDT). Mais ne commence jamais par là : LWW d'abord, on complexifie seulement si le métier l'exige.
8. Exposer l'état offline dans l'UI
Un simple badge suffit, branché sur le NetworkMonitor et sur le nombre d'opérations en attente.
Règle UX : ne montre l'état que quand il compte (offline, ou sync en cours). Online + rien en attente → pas de badge, l'app a l'air "normale".
Points à connaître
- Ne pas vider le cache sur erreur réseau. L'échec du
refresh()doit laisser les données affichées intactes. Effacer = écran vide à chaque coupure réseau. isConnectedn'est pas une garantie. Un chemin "satisfait" ne veut pas dire que ton serveur répond. La seule vérité est le résultat de la requête ; le monitor sert l'UI et le déclenchement du flush, pas la logique de fond.- Idempotence du flush. Sans
idclient + déduplication serveur, un crash entre l'envoi et ledequeuecrée des doublons. Génère l'idcôté client. .cachesDirectoryest purgeable. Pour des données créées par l'utilisateur (pas un simple cache rechargeable), persiste dans Documents ou SwiftData, sinon le système peut les supprimer.
Exercice (15-20 min)
Crée un petit écran "Articles" qui démontre stale-while-revalidate + badge offline :
- Un
ArticlesStore@Observableavecarticles: [Article], unArticleCache(fichier JSON dans.cachesDirectory) et unArticleAPIfake qui attendTask.sleep(for: .seconds(1))puis renvoie une liste codée en dur (ou lance une erreur pour simuler l'offline). load(): lit le cache d'abord (affichage immédiat), puisrefresh()depuis l'API et réécrit le cache.- Un
NetworkMonitor(NWPathMonitor) injecté dans.environment. Pour tester sans vrai réseau, ajoute un booléenforceOfflinedans ton API fake qui faitthrow URLError(.notConnectedToInternet). - La vue :
List(store.articles), unOfflineBadgedans la barre de navigation, et.refreshable { await store.refresh() }.
Critère de réussite : ferme/rouvre l'app en mode forceOffline → les articles s'affichent quand même (depuis le cache) avec le badge "Hors ligne". Repasse online → le badge disparaît et la liste se rafraîchit.
Bonus : ajoute un bouton "Ajouter" qui empile dans une Outbox quand offline, et flush au retour réseau via .onChange(of:).
Question d'entretien
Q : "Comment gères-tu le mode offline d'une app iOS ?"
R modèle : "Je pars d'un principe : le store local est la source de vérité, l'UI l'observe, le réseau l'alimente. En lecture, j'applique du stale-while-revalidate : j'affiche le cache immédiatement puis je rafraîchis en arrière-plan, et surtout je ne vide jamais le cache si la requête échoue. En écriture, j'utilise une outbox : l'action est appliquée localement de façon optimiste et empilée avec un flag pending, puis rejouée quand NWPathMonitor détecte le retour réseau — en m'assurant de l'idempotence avec un id client. Pour les conflits je commence en last-write-wins, en sachant que ça peut perdre des données et que je passerais à du versioning serveur si le métier l'exige. Et j'expose clairement l'état offline dans l'UI."
Q : "Pourquoi ne pas juste tester isConnected avant chaque appel réseau ?"
R modèle : "Parce que isConnected est un indice, pas une garantie : captive portal, DNS, serveur down… le chemin peut être 'satisfait' alors que la requête échoue. Je tente donc toujours la requête et je traite l'échec proprement (garder le cache, empiler en outbox). Le monitor me sert pour l'UI et pour déclencher le rejeu de l'outbox, pas pour décider si je tente ou non."
Résumé
- Source de vérité = store local ; l'UI observe le store, le réseau alimente le store.
- Stale-while-revalidate : afficher le cache d'abord, rafraîchir ensuite, ne jamais vider sur erreur.
- Cache simple = fichier JSON via
actor(ou SwiftData)..cachesDirectorypurgeable → Documents pour les données utilisateur. - Outbox : écritures offline appliquées optimistement + flag pending, rejouées au retour réseau ; gérer idempotence (id client) et ordre (FIFO).
NWPathMonitorexposé en@Observable→ état online/offline réactif, mais indice seulement.- Conflits : last-write-wins pour démarrer ; conscient de ses limites (perte de champ, horloges).
- Badge UI affiché seulement quand offline ou sync en cours.
- Reste simple : pas de moteur de sync complet tant que le besoin métier ne le justifie pas.