Comment SwiftUI décide quoi redessiner
Une annexe de SwiftUI n’est pas React
Le framework observe. Il a simplement mieux à faire que tout redessiner.
L’article 05 couvrait ce que Renala dessine et pourquoi : propriété de l’état, virtualisation de la barre latérale, rendu Canvas, couche d’accessibilité, navigation. Cette annexe couvre l’autre moitié : comment SwiftUI décide ce qu’il ne faut pas dessiner. Comprendre le modèle de mise à jour change la façon dont on structure le code. Dans une application de treemap avec des dizaines de milliers de rectangles, c’est ce qui détermine si l’interface reste réactive.
La différence entre un treemap réactif et un treemap saccadé, ce n’est souvent pas ce qu’on calcule, mais ce qu’on demande accidentellement au framework de recalculer. SwiftUI est sélectif sur ce qu’il redessine. La question, c’est de savoir si votre code le lui permet.
Le comportement par défaut de React : le parent entraîne tout
Le modèle de réévaluation de React est explicite et prévisible. Quand l’état change dans un composant, React rappelle la fonction de ce composant. Puis celle de chaque enfant. Puis de chaque petit-enfant. Jusqu’en bas.
Que les props aient changé ou non, cela ne change rien. Le parent se réévalue, les enfants suivent. Point.
React.memo est l’opt-in qui permet d’en sortir. Enveloppez un composant dans React.memo et React comparera les nouvelles props aux anciennes. Si elles sont superficiellement égales, le composant saute son rendu. Sans cette enveloppe, la comparaison des props n’a jamais lieu.
useMemo est un mécanisme distinct. Il mémoïse une valeur à l’intérieur d’un rendu, mais n’empêche pas le composant lui-même d’être réévalué. On peut utiliser useMemo pour produire un objet référentiellement stable qui aide un enfant React.memo en aval à éviter sa propre réévaluation, mais useMemo seul n’a aucun effet sur l’exécution du composant qui l’appelle.1
Le modèle mental : tout se réexécute sauf indication contraire. Le développeur fait la comptabilité ; React fait le diff du DOM.
Le modèle de React est transparent : tout se réexécute, à vous d’y tailler des exceptions. SwiftUI renverse la logique. Il essaie de sauter le maximum, mais seulement quand il peut prouver que rien n’a changé. Ce qui compte comme preuve dépend de ce que le framework peut voir.
Le comportement par défaut de SwiftUI : suivre les lectures, sauter le reste
SwiftUI inverse le réglage par défaut. Quand le body d’un parent s’exécute, les structs de vues enfants sont toujours recréées, mais SwiftUI peut sauter l’évaluation du body d’un enfant quand ses propriétés stockées n’ont pas changé. En plus, @Observable suit les propriétés que chaque vue lit pendant l’évaluation de body, de sorte que les mutations ultérieures n’invalident que les vues qui ont lu la propriété modifiée.
Le mécanisme sous-jacent est @Observable (introduit à la WWDC 2023). Quand SwiftUI évalue le body d’une vue, il enregistre chaque accès aux propriétés des objets @Observable. Comme un tableur : SwiftUI ne recalcule que les cellules dont les entrées ont changé. Le suivi est automatique, sans formule à déclarer. Si ViewA lit viewModel.scanState et ViewB lit viewModel.currentRoot, une mutation de scanState invalide ViewA mais pas ViewB. Ni abonnements, ni tableaux de dépendances, ni fonctions de sélection.
C’est plus fin que ce que React propose nativement. L’analogue React le plus proche est useSelector dans Redux ou un observer MobX, mais ceux-là exigent un câblage explicite. SwiftUI l’automatise.
L’identité : structurelle et explicite
Le suivi des dépendances, c’est la moitié de l’histoire. L’autre moitié, c’est l’identité : comment SwiftUI sait que cette SidebarRow dans l’évaluation courante est la même que celle de l’évaluation précédente.
L’identité compte parce qu’elle détermine si SwiftUI compare ou remplace. Quand il reconnaît une vue comme étant la même que la dernière fois, il compare les propriétés stockées de la struct aux valeurs précédentes. Si elles n’ont pas changé, il peut sauter l’évaluation de body. Si la vue est nouvelle (nouvelle identité), il n’y a rien à comparer, et body s’exécute sans condition.
SwiftUI utilise deux types d’identité :
- L’identité structurelle : la position de la vue dans la hiérarchie
body. Le premierTextd’unHStackest identifié par sa position : c’est le premierTextde cetHStack. Si la hiérarchie change de forme (une branche conditionnelle bascule), SwiftUI peut considérer la vue comme nouvelle et abandonner son état. - L’identité explicite : fournie par
ForEach(items, id: \.someProperty)ou le modificateur.id(). C’est ainsi que SwiftUI suit les éléments d’une liste ou distingue des vues susceptibles de se déplacer.
La session WWDC 2021 Demystify SwiftUI reste la meilleure référence publique sur le rôle de l’identité et des dépendances dans les mises à jour. Apple ne documente pas entièrement l’algorithme interne de comparaison, mais la session expose le modèle assez clairement pour raisonner sur la performance.
SwiftUI suit donc les dépendances et compare les entrées pour décider quoi sauter. Les deux mécanismes reposent sur la capacité du framework à voir les entrées. Les closures, c’est là que cette visibilité s’arrête.
Là où les closures cassent le modèle
Dans Renala, les mises à jour de progression du scan déclenchaient la réévaluation de milliers de lignes de la barre latérale dont les données n’avaient pas changé. La cause : chaque ligne recevait une closure fraîche pour son menu contextuel, et SwiftUI ne pouvait pas distinguer la nouvelle de l’ancienne.
En Swift, une closure est un type de référence : chaque allocation produit une référence distincte. SwiftUI ne peut pas regarder à l’intérieur d’une closure pour en déterminer l’équivalence fonctionnelle : il voit des entrées différentes et suppose le pire.
Prenons une vue parente qui passe une closure d’action à un enfant :
// Le parent crée une closure fraîche à chaque évaluation de body
ChildView(onTap: { viewModel.handleTap(item) })
À chaque évaluation du body du parent, le framework voit naître une nouvelle closure. Quand SwiftUI compare les entrées de l’enfant, l’ancienne et la nouvelle closure sont des objets différents, même si elles capturent les mêmes variables et font la même chose. SwiftUI constate des entrées différentes et réévalue l’enfant.
Prise isolément, une réévaluation inutile ne coûte rien. Multipliée par des milliers de lignes dans une barre latérale, elle pèse.
La correction : des références stables de ViewModel
La correction dans Renala a été simple : passer la référence du ViewModel, pas des closures dérivées.
// Avant : closure fraîche à chaque évaluation du body du parent
SidebarRow(node: node, onSelect: { viewModel.select(node) })
// Après : référence stable, l'enfant appelle les méthodes directement
SidebarRow(node: node, viewModel: viewModel)
Le ViewModel est une classe (un type de référence en Swift, comme un objet en JavaScript). Les références à la même instance sont référentiellement égales d’une évaluation à l’autre. Quand SwiftUI compare les entrées de l’enfant, la référence du ViewModel n’a pas changé, et le framework a davantage de chances de sauter la réévaluation. (Les règles exactes de comparaison ne font pas partie de l’API publique ; il faut traiter ce comportement comme observé, pas comme un contrat documenté.)
@Observable continue de suivre les propriétés que l’enfant lit dans son body. Passer le ViewModel entier ne signifie pas que l’enfant réagit à chaque mutation : il ne réagit qu’aux propriétés qu’il lit effectivement. L’enfant reste lié à l’état qu’il lit, tout en offrant à SwiftUI une entrée stable, facile à comparer.
C’est un motif à mesurer, pas une optimisation garantie. Profilez avec Instruments avant et après. Dans Renala, la différence était mesurable dans la barre latérale.
Le modèle de mise à jour de SwiftUI récompense le code qui offre au framework des entrées stables et comparables. Les closures sont invisibles ; les références ne le sont pas. L’identité et le suivi des dépendances font le reste, mais seulement quand les entrées coopèrent. Dans une application de treemap qui dessine des dizaines de milliers de nœuds, la différence entre « sauter » et « redessiner » sépare le fluide du figé.
Aparté : Canvas et @Environment
Canvas introduit une contrainte qui mérite d’être signalée : @Environment ne peut pas être déclaré à l’intérieur d’une closure Canvas. @Environment est un property wrapper, et les property wrappers ne s’appliquent qu’aux propriétés stockées des types, pas aux variables locales dans des closures. La solution : déclarer @Environment sur la vue englobante et laisser la closure le capturer, ou utiliser context.environment.colorScheme depuis le GraphicsContext transmis à la closure.
Pour aller plus loin
- Demystify SwiftUI (WWDC 2021) : la référence principale sur l’identité, le cycle de vie et les dépendances.
- Discover Observation in SwiftUI (WWDC 2023) :
@Observableet le suivi au niveau des propriétés. - SwiftUI documentation : la référence officielle.
Footnotes
-
React.memoempêche la réévaluation en comparant les props.useMemomémoïse une valeur à l’intérieur d’un rendu, mais n’empêche pas le rendu lui-même. Ils se complètent mais servent des objectifs différents. ↩
