Retour à la liste

Blog

Traquer un gel d'interface SwiftUI : quand le blocage n'est pas dans votre code métier

L'écran continuait à se rafraîchir, mais ne répondait plus aux touchers — le WebSocket a fini par expirer. La vraie cause n'était pas dans le code métier, mais dans une boucle de rétroaction de mise en page reproductible de SwiftUI. Voici l'intégralité de l'investigation.

Publié le Tags: engineering / swiftui / debugging / ios / performance
Blog

Suivez les mises à jour produit et ingénierie de cette catégorie.

Voir la catégorie

Ceci est un post-mortem de débogage, pas un article promotionnel. Si un diagnostic s'est révélé erroné, nous le disons clairement — car dans cette investigation, nous nous sommes trompés plus d'une fois.

Le symptôme

Un utilisateur travaillait en mode chat sur iPad, en demandant à un agent de produire une longue réponse. Pendant que cette réponse s'affichait encore caractère par caractère, l'application s'est figée : l'affichage continuait à se rafraîchir, mais il devenait impossible de faire défiler ou de taper quoi que ce soit, aucun bouton ne répondait. Quelques instants plus tard, la connexion WebSocket entre l'appareil et le serveur s'est coupée avec une erreur « délai d'attente dépassé » (-1001).

Pour l'utilisateur, cela ressemblait à une panne réseau. Pourtant, nous disposions d'une preuve indirecte cruciale. Un observateur de protocole en lecture seule, connecté en tant que second client sur le même flux de données serveur, avait tout enregistré : durant cette période, le serveur fonctionnait parfaitement, chaque événement de streaming avait bien été émis, et la réponse s'était terminée normalement.

C'est le client mobile qui s'était bloqué — précisément au moment où la réponse allait se conclure et déclencher un dernier rendu. La coupure WebSocket n'était qu'une conséquence, pas la cause : la boucle de réception des paquets s'exécute elle aussi sur le thread principal, et lorsque ce thread se bloque, les paquets ne peuvent plus être traités ; les données envoyées par le serveur s'accumulent dans le tampon TCP, et au bout de quelques dizaines de secondes, le système considère la connexion comme expirée.

Trois fausses pistes

Au moment du blocage du thread principal, nous avons capturé un échantillon. Des milliers d'échantillons de pile d'appels, tous bloqués dans les mises à jour de mise en page SwiftUI — aucun code métier en cours d'exécution, aucun état en train d'être écrit. _printChanges est resté silencieux du début à la fin.

C'était en soi une information importante. Si une boucle pilotée par des données avait recalculé l'état, _printChanges n'aurait pas arrêté de s'afficher ; son silence indiquait que la boucle se produisait au niveau de la mise en page, pas au niveau des données.

Mais où dans la mise en page ? Nous avons successivement suspecté trois endroits, en validant chaque hypothèse par une reproduction contrôlée en simulateur : même longue réponse, même manipulation, une seule variable modifiée à la fois.

Premier suspect : la mesure intrinsèque de UITextView. Nos bulles de message utilisent UITextView pour afficher du texte riche sélectionnable. Son intrinsicContentSize déclenche à chaque fois une passe TextKit complète sur l'intégralité du texte — ce qui semblait être le coupable idéal. Nous l'avons remplacé par un Text SwiftUI natif. Le gel persistait. Piste écartée.

Deuxième suspect : TextKit 2. Depuis iOS 16, UITextView utilise TextKit 2 par défaut, dont la mise en page incrémentale peut produire des mesures instables pour un bloc de texte non défilant avec une combinaison fixe (texte, largeur). Nous avons forcé le retour à TextKit 1. Le gel persistait. Piste écartée.

Troisième suspect : le coût du rendu lui-même. Le découpage Markdown et la reconstruction du texte riche pour les longues bulles ne sont effectivement pas gratuits. Nous avons ajouté un cache — les pics d'utilisation du CPU ont diminué, mais le gel persistait. C'était un amplificateur, pas la cause racine.

Les trois pistes aboutissaient toutes à une impasse. Deux passes indépendantes d'analyse statique avaient permis d'identifier des amplificateurs « suspects », mais aucun d'eux n'expliquait cette occupation à 100% du CPU, persistante et sans récupération même après la fin de la réponse.

Localisation par bissection

L'analyse statique n'ayant pas trouvé la source, nous avons opté pour la méthode la plus laborieuse mais la plus fiable : désactiver un à un les éléments potentiellement impliqués dans l'interface, et observer lequel fait tomber l'utilisation CPU à zéro.

Nous avons d'abord désactivé le défilement automatique — la ligne proxy.scrollTo qui amène la liste en bas à l'arrivée d'un nouveau message. Le gel a immédiatement disparu, le CPU est retombé à zéro.

Puis, en ne changeant qu'une seule variable : nous avons remplacé le LazyVStack portant la liste de messages par un VStack à mise en page immédiate, en conservant tout le reste, y compris le défilement automatique. Le gel a également disparu.

Les deux expériences pointaient vers la même conclusion :

Dès que le contenu du chat dépasse la hauteur de l'écran, l'utilisation de ScrollView avec LazyVStack provoque l'entrée dans une boucle de rétroaction purement de mise en page lors de n'importe quel appel à scrollTo — qu'il soit animé ou non, qu'il cible un message ou une vue de substitution.

Voici le mécanisme. LazyVStack n'effectue une mise en page précise que pour la zone visible ; la taille du contenu hors écran est estimée. Lorsque vous appelez scrollTo vers une position donnée, le changement d'offset déclenche le chargement à la demande du contenu hors écran, ce qui force une réévaluation de la hauteur totale du contenu ; cette hauteur modifiée déplace la position cible de l'ancre, ce qui provoque un nouveau défilement. Cette boucle « réancrer ⇄ réestimer les dimensions » ne repose sur aucune modification de données — elle tourne à vide au niveau de la mise en page, et ne peut donc jamais s'arrêter d'elle-même. C'est précisément pourquoi le thread principal était à 100% alors que _printChanges restait muet.

La correction

Une fois la direction établie, la correction s'est précisée :

  • Remplacer le conteneur de la liste de messages de LazyVStack par un VStack à mise en page immédiate. Une conversation compte au plus quelques dizaines de bulles ; le surcoût d'une mise en page immédiate est négligeable, et la hauteur totale est déterministe — ce qui ne laisse aucun espace à la boucle de rétroaction pour tourner à vide.
  • Passer le défilement automatique en mode sans animation, ciblant une vue de substitution de 1 pixel en bas de liste, avec anchor: nil (sémantique « rendre visible, c'est suffisant »). Fini l'ancrage sur un message potentiellement plus haut que l'écran — ce mécanisme d'ancrage était précisément le « mouvement perpétuel » mis en évidence par l'échantillonnage.
  • Conserver au passage les deux vraies optimisations découvertes en chemin : un cache LRU prévisible pour le découpage Markdown et le rendu, plutôt qu'un NSCache que le système peut vider en bloc sous pression mémoire — chaque recalcul devient ainsi une simple consultation de table.

Nous avons validé la correction avec une longue réponse Markdown de 2 500 mots. Avant le correctif, à la fin de cette réponse, le thread principal restait bloqué à 100% en permanence et l'interface était totalement inopérante ; après le correctif, dès la fin de la réponse, le CPU retombe immédiatement à 0%, le défilement, l'historique et le rendu des blocs de code fonctionnent tous normalement.

Quatre points pour les développeurs SwiftUI

  1. Dans les scénarios où un élément de contenu peut être plus haut que l'écran, ScrollView + LazyVStack + scrollTo est une combinaison naturellement instable. Le chat, les journaux, le rendu en streaming de longs textes — tous appartiennent à cette catégorie.
  2. Pour diagnostiquer une « boucle de mise en page qui ne s'arrête pas », le silence de _printChanges est un signal clé. Il signifie que la boucle se produit au niveau de la mise en page et non des données ; dans ce cas, scruter @State et onChange ne mènera nulle part — seule la bissection de l'arbre de vues permettra de localiser le problème.
  3. Pour le défilement automatique, préférez anchor: nil ciblant une vue de substitution minuscule en bas de liste, plutôt qu'un ancrage sur du contenu réel aux dimensions incertaines.
  4. Si une mise en page immédiate est possible, n'utilisez pas Lazy par commodité. Lazy économise la mise en page hors écran, mais l'estimation des dimensions qu'il introduit est précisément le carburant de cette boucle de rétroaction ; lorsque la liste n'est pas longue, le calcul est défavorable.

Vous voulez essayer Tactic Remote sur votre machine ? L'application iOS est disponible gratuitement sur l'App Store, et l'application compagnon ainsi que le CLI sont également gratuits. Pour l'histoire d'architecture plus complète derrière cette correction, consultez « Confier l'historique de session au serveur ».

Essayez Tactic Remote

Pilotez vos agents de programmation depuis votre téléphone

Connectez-vous à Claude Code, Codex et aux autres agents sur votre Mac, PC Windows ou Linux. Consultez leur progression et envoyez la prochaine instruction depuis votre iPhone ou iPad.