Fiche 09.06 — FileManager et cache d'images
Objectif
Maîtriser le stockage fichier sur iOS (Documents vs Caches vs tmp), lire/écrire du Codable en JSON sur disque, et construire un cache d'images à deux niveaux (mémoire NSCache + disque) parce qu'AsyncImage ne suffit pas dès qu'on veut persister. Savoir aussi quand utiliser FileManager plutôt que UserDefaults / Keychain / SwiftData.
1. Les trois dossiers du sandbox
Chaque app a un sandbox. Trois dossiers comptent au quotidien :
| Dossier | Sauvegardé (iCloud/iTunes) | Purgeable par iOS | Usage |
|---|---|---|---|
| Documents | Oui | Non | Données utilisateur irremplaçables (fichiers créés par l'user, exports) |
| Caches | Non | Oui (sous pression disque) | Données reconstructibles : images téléchargées, réponses réseau |
| tmp | Non | Oui (à tout moment) | Fichiers jetables, le temps d'une opération |
Réflexe UIKit : c'est exactement le même FileManager qu'en UIKit, aucune nouveauté SwiftUI ici. Ce qui change, c'est où tu déclenches l'I/O (dans un actor / Task, pas sur le main).
Règle App Store : tout ce qui est dans Documents est sauvegardé sur iCloud. Si tu y mets 200 Mo d'images téléchargeables, Apple peut rejeter l'app. Les caches vont dans Caches.
2. Écrire et lire un Codable en JSON sur disque
.atomic écrit dans un fichier temporaire puis renomme : pas de fichier à moitié écrit si l'app crashe en plein milieu.
3. Sous-dossiers, suppression, taille d'un dossier
totalFileAllocatedSize = place réellement occupée sur disque (alignée sur les blocs), plus juste que fileSize.
4. Protection des données (en bref)
iOS chiffre les fichiers via la Data Protection. Tu choisis quand ils sont déchiffrés :
.complete: fichier illisible quand l'appareil est verrouillé..completeUnlessOpen: lisible si déjà ouvert avant le verrouillage (téléchargements en tâche de fond)..completeUntilFirstUserAuthentication(défaut) : lisible après le premier déverrouillage post-boot..none: pas de protection.
Mets .complete sur les fichiers sensibles (données santé, tokens). Mais ne mets jamais un secret pur dans un fichier : ça va dans le Keychain (fiche 09.xx).
5. Pourquoi AsyncImage ne suffit pas
Limites concrètes d'AsyncImage :
- Cache mémoire non garanti : un re-render / un scroll qui recycle la cellule peut re-télécharger.
- Pas de cache disque persistant entre lancements.
- Pas de downsampling : une image 4000×3000 occupe ~48 Mo en RAM même affichée en 80 pt.
- Pas de contrôle sur les priorités / annulation fine.
Réflexe UIKit : pas idéal d'utiliser AsyncImage pour une liste qui scrolle vite. Préférable : un ImageCache maison (section 7), exactement comme tu cachais des UIImage dans une NSCache côté UIKit.
6. Downsampling avant de cacher (rappel perf)
Ne stocke jamais l'image pleine résolution en RAM. Downsample avec ImageIO (cf. fiche perf images) :
Tu caches le Data brut sur disque (pour pouvoir re-downsampler à une autre taille) et l'UIImage downsamplée en mémoire.
7. Un ImageCache mémoire + disque (stratégie mémoire → disque → réseau)
Vue d'appel :
Alternative "zéro code" : URLCache.shared (configuré avec un memoryCapacity / diskCapacity) cache automatiquement les réponses HTTP cacheables — utile si le serveur envoie les bons headers Cache-Control. Mais tu n'as alors pas de downsampling ni de contrôle sur les UIImage, d'où le cache maison ci-dessus pour une liste qui scrolle.
8. FileManager vs UserDefaults vs Keychain vs SwiftData (arbre de décision)
Erreur classique : stocker un tableau de 5000 objets en JSON via FileManager alors qu'on filtre/trie dessus → c'est le job de SwiftData. À l'inverse, mettre une image de 3 Mo dans UserDefaults : interdit (UserDefaults charge tout en RAM au lancement).
Points à connaître
- Documents est sauvegardé sur iCloud : y stocker des données reconstructibles peut faire rejeter l'app. Caches pour tout ce qui se re-télécharge.
- Caches est purgeable : iOS peut le vider quand l'espace manque. Ton code doit gérer le "fichier disparu" → re-fetch réseau, jamais un crash.
- I/O sur le main thread = freeze. Mets l'I/O dans un
actor/Task.Data(contentsOf:)est synchrone et bloquant. - Ne réinvente pas le Keychain avec un fichier
.complete: un secret va dans le Keychain, point. hashValuen'est PAS stable entre lancements : pour nommer un fichier de cache disque persistant, utilise un hash déterministe (SHA256 via CryptoKit, comme en section 7). Sinon, après relance, tu ne retrouves jamais ton fichier sur disque.
Exercice (15 min)
Écris un ImageCache (actor) avec uniquement les niveaux mémoire + disque (sans réseau, on te fournit le Data) :
func store(_ data: Data, for key: String): écrit leDatadans un sous-dossierCaches/imgs/et garde l'UIImagedownsamplée à 100×100 enNSCache.func image(for key: String) -> UIImage?: mémoire d'abord, sinon disque (re-downsample + remonte en mémoire), sinonnil.func diskUsage() -> Int: taille totale du dossier disque (réutilisedirectorySizede la section 3).- Bonus : la
keypeut contenir des/(c'est une URL) — dérive le nom de fichier via un hash SHA256 (import CryptoKit) stable entre lancements, comme en section 7.
Vérifie que relancer l'app (mémoire vidée) retrouve bien l'image depuis le disque.
Question d'entretien
Q : Tu télécharges une image (ou un PDF) depuis le réseau. Tu la stockes dans Documents ou dans Caches ? R : Dans Caches. C'est une donnée reconstructible : on peut la re-télécharger. Documents est sauvegardé sur iCloud et destiné aux données irremplaçables créées par l'utilisateur — y mettre des téléchargements gonfle les backups inutilement et peut faire rejeter l'app à la review. Contrepartie : Caches étant purgeable par iOS, le code doit gérer l'absence du fichier en re-fetchant.
Q : Pourquoi ne pas juste utiliser AsyncImage partout ? R : Il n'offre pas de cache disque persistant entre lancements, son cache mémoire n'est pas garanti (re-download au scroll), et il ne downsample pas — une grosse image occupe sa taille pleine en RAM. Pour une liste qui scrolle, un cache mémoire (NSCache) + disque (Caches) avec downsampling est nettement plus performant.
Résumé
- Documents (iCloud, irremplaçable) vs Caches (purgeable, reconstructible) vs tmp (jetable).
urls(for:in:)/temporaryDirectory. - Codable →
JSONEncoder→Data→write(to:options:[.atomic]); relire avecData(contentsOf:)+JSONDecoder. - Sous-dossiers :
createDirectory(withIntermediateDirectories: true); taille viaenumerator+totalFileAllocatedSize. FileProtectionType.completepour les fichiers sensibles ; les secrets purs vont au Keychain.- AsyncImage insuffisant en liste →
ImageCache: mémoire (NSCache) → disque (Caches) → réseau, avec downsampling (ImageIO) avant de cacher. - Décision : secret → Keychain ; réglage simple → UserDefaults ; données requêtables → SwiftData ; blob/fichier → FileManager (Documents ou Caches selon réversibilité).