Fiche 15.07 — Listes et images performantes + Instruments
Objectif
Faire scroller une liste à 120 fps même avec des images réseau. En UIKit tu optimisais cellForRowAt, le prepareForReuse et le décodage hors main thread à la main ; ici les cellules sont des View que SwiftUI recompose, mais les mêmes lois physiques s'appliquent : cellule légère, identité stable, et jamais décoder une image 4000px dans une vignette 80px. On finit par diagnostiquer un lag réel avec Instruments.
1. Pont UIKit : le lag au scroll, mêmes causes
En UIKit, une UITableView qui rame venait presque toujours de trois choses : du travail lourd dans cellForRowAt, un layout coûteux par cellule, et le décodage d'images sur le main thread. La réutilisation de cellules (dequeueReusableCell) cachait une partie du coût.
En SwiftUI, List et LazyVStack recyclent aussi (lazy = seules les cellules visibles existent), mais toi tu n'écris plus cellForRowAt : tu écris une View que SwiftUI recompose. Le piège : c'est facile d'y mettre du travail lourd sans s'en rendre compte (un DateFormatter créé à chaque body, un tri, un décodage d'image synchrone).
Règle : la cellule doit être une fonction quasi-instantanée de données déjà prêtes. Tout calcul lourd se fait en amont (ViewModel), pas dans body.
2. Cellules légères : identité stable, pas d'AnyView
Trois réflexes qui changent tout :
- Identité stable.
iddoit être une vraie clé métier (UUID serveur), pas l'index du tableau. Avec un mauvaisid, SwiftUI recrée des cellules au lieu de les recycler → scroll qui saccade et images qui re-clignotent. C'est l'équivalent d'unprepareForReusecassé. - Pas d'
AnyViewdans une cellule.AnyViewefface le type : SwiftUI ne peut plus differ finement et recrée la sous-arborescence. Préfère@ViewBuilder, unif/switchtypé, ou unenumde contenu. - Pas de travail dans la cellule. Formatage de date, calcul, tri, filtrage : tout ça doit être pré-calculé.
Pour la pagination (charger la suite quand on approche du bas), voir la fiche 07.09 — c'est le complément direct : on ne charge jamais 10 000 lignes d'un coup.
3. Le piège AsyncImage
AsyncImage est pratique pour un prototype, mais en production il a deux limites qui tuent une liste :
- Pas de cache disque fiable. Il s'appuie sur l'
URLCachepartagé, mais le comportement est opaque et souvent il re-télécharge quand la cellule revient à l'écran. Au scroll, tu vois les images re-clignoter. - Pas de downsampling. Il décode l'image à sa taille native. Une photo 4000×3000 décodée pour une vignette 80×80, c'est ~48 Mo en mémoire (4000×3000×4 octets) et un décodage coûteux sur un thread interne → hitch au scroll.
Pour une vraie liste, on prend le contrôle : downsampling + cache (sections suivantes), ou une lib éprouvée (section 7).
4. Downsampling : décoder à la bonne taille
Le réflexe clé de la perf image : ne jamais décoder plus de pixels que l'écran n'en affiche. CGImageSource permet de générer une vignette déjà décodée à la taille cible, sans jamais matérialiser l'image pleine en mémoire.
Point critique : appelle ça hors du main thread. Dans une fonction async, fais-le dans un contexte détaché du MainActor :
En UIKit tu faisais exactement ça (UIGraphicsImageRenderer ou CGImageSource dans une DispatchQueue.global). Le principe est identique, seul l'emballage async/await change.
5. Cache mémoire (NSCache) + cache disque (URLCache)
Télécharger + downsampler à chaque apparition, c'est gâché. Deux niveaux de cache :
- Mémoire :
NSCachestocke lesUIImagedéjà décodées et downsamplées. Il se purge tout seul sous pression mémoire (avantage sur unDictionary). - Disque :
URLCacheévite de re-télécharger les octets bruts. À configurer une fois au lancement.
La vue de cellule consomme ça via .task (annulé automatiquement si la cellule disparaît avant la fin) :
.task(id: url) est le détail qui sauve la réutilisation : quand SwiftUI recycle la cellule pour une autre photo, l'id change, l'ancienne tâche est annulée et une nouvelle démarre. C'est le prepareForReuse de SwiftUI.
6. Libs : Kingfisher / Nuke (quand les utiliser)
Tout ce qui précède (téléchargement, downsampling, cache 2 niveaux, annulation, dédup des requêtes), des libs le font déjà, testé en prod et optimisé :
- Nuke : très performant, modulaire, downsampling et prefetch intégrés. API SwiftUI via
LazyImage. - Kingfisher : le plus répandu,
KFImageen SwiftUI, simple à brancher.
Quand les prendre : dès qu'une app affiche sérieusement des images réseau (feed, galerie, e-commerce). Coder son loader maison est un bon exercice de compréhension, mais en mission tu choisis une lib éprouvée. En entretien, savoir pourquoi AsyncImage ne suffit pas et ce que la lib résout (downsampling, cache, annulation, prefetch) vaut plus que de l'avoir intégrée.
7. Instruments en pratique
print ne mesure pas un hitch au scroll. Instruments oui. Lancement : Xcode → Product → Profile (⌘I), choisir un template, profiler sur device réel (le simulateur ment sur le CPU et le GPU).
Time Profiler — où part le CPU.
- Échantillonne la stack à intervalle régulier.
- Active Invert Call Tree (les feuilles, donc le code qui s'exécute réellement, remontent en haut) et regarde la Heaviest Stack Trace dans le panneau de droite.
- Coche Hide System Libraries pour ne voir que ton code.
- Symptôme classique d'une liste qui lague :
CGImageSourceCreateImageAtIndex/ décodage qui apparaît sur le main thread → c'est ton downsampling manquant.
SwiftUI (template dédié, Xcode 15+) — combien de fois body est recalculé.
- Montre les View body re-evaluations et les Core Animation commits.
- Une cellule dont le
bodyest rappelé en boucle pendant le scroll = identité instable ou dépendance trop large (voir fiche 15.06).
Allocations & Leaks — mémoire.
- Allocations : graphe mémoire qui grimpe sans redescendre au scroll = images non downsamplées ou cache sans limite.
- Leaks : retain cycles (closure qui capture
selffort dans uneTasklongue). Complément du Memory Graph (fiche 15.02).
Hangs / Hitches — le ressenti utilisateur.
- Un hang = main thread bloqué > ~250 ms (l'UI gèle). Un hitch = une frame loupée au scroll (saccade).
- Le template Animation Hitches (ou les diagnostics du Organizer en prod) pointe les frames manquées. Cible : 0 frame perdue pendant un scroll.
Points à connaître
id= index dans unForEachcasse la réutilisation : cellules recréées, images qui re-clignotent, scroll qui saute. Utilise toujours une identité stable.AnyViewdans une cellule désactive le diffing fin → recompositions complètes. Reste sur des types concrets /@ViewBuilder.- Décoder à la taille native est l'erreur n°1 : une vignette 80px qui décode une image 4000px explose la mémoire et provoque des hitches. Downsample toujours.
AsyncImagere-télécharge souvent au scroll (pas de cache disque fiable) : OK en prototype, à remplacer par un loader cache + downsampling ou une lib en prod.- Profile sur device réel : le simulateur a le CPU du Mac et fausse Time Profiler.
Exercice
Pars d'une List de ~200 Photo (URLs d'images haute résolution, ex. picsum.photos) affichées en vignettes 80×80 avec AsyncImage — elle lague et la mémoire grimpe. Remplace AsyncImage par un ThumbnailView branché sur un ThumbnailLoader (downsampling CGImageSource + NSCache). Profile avant/après avec Time Profiler (Invert Call Tree) et Allocations, et note la différence de mémoire et de frames perdues au scroll. Objectif : scroll fluide et mémoire qui se stabilise.
Question d'entretien
« Ta liste d'images lague au scroll. Comment tu diagnostiques et tu corriges ? »
Je profile sur device réel. Time Profiler avec Invert Call Tree me dit où part le CPU : si je vois du décodage d'image (
CGImageSource…) sur le main thread, le problème est le décodage d'images pleine résolution dans des petites cellules. Allocations confirme : la mémoire grimpe sans redescendre. La correction : downsampling viaCGImageSource+kCGImageSourceThumbnailMaxPixelSizepour décoder à la taille d'affichage, hors main thread, plus un cache mémoireNSCacheet un cache disqueURLCache. Je vérifie aussi l'identité des cellules (id stable, pas d'AnyView) et avec le template SwiftUI que lesbodyne sont pas recalculés en boucle. En prod réelle je pars souvent sur Nuke ou Kingfisher qui font tout ça.
« Pourquoi AsyncImage ne suffit pas en production ? »
Pas de cache disque fiable (il re-télécharge souvent au scroll) et aucun downsampling : il décode l'image à sa taille native, donc une vignette peut décoder une image 4000px et saturer la mémoire. En prototype c'est très bien ; pour une liste qui scrolle, il faut un cache et du downsampling, maison ou via une lib.
« C'est quoi la différence entre un hang et un hitch ? »
Un hang, c'est le main thread bloqué assez longtemps (~250 ms+) pour que l'UI gèle complètement. Un hitch, c'est une frame loupée pendant une animation ou un scroll — une micro-saccade. On chasse les hangs avec Time Profiler / le Organizer, et les hitches avec le template Animation Hitches.
Résumé
- Cellule = fonction instantanée de données prêtes : pas de calcul, pas de formatter, pas d'
AnyViewdansbody. - Identité stable (
idmétier, pas l'index) sinon la réutilisation casse et le scroll saccade. AsyncImage: OK prototype, mais pas de cache disque fiable ni de downsampling → re-télécharge et sature la mémoire.- Downsampling via
CGImageSource+kCGImageSourceThumbnailMaxPixelSize, hors main thread, pour décoder à la taille d'affichage. - Cache mémoire
NSCache(images décodées) + cache disqueURLCache(octets bruts) ;.task(id:)annule la requête quand la cellule est recyclée. - En prod : Nuke ou Kingfisher font tout ça, testés et optimisés.
- Instruments : Time Profiler (Invert Call Tree, Heaviest Stack), SwiftUI (body re-evaluations), Allocations & Leaks, Hangs/Hitches — toujours sur device réel.
- Compléments : pagination (07.09), re-renders et granularité @Observable (15.06), Memory Graph (15.02), Time Profiler (15.03).