Blog
Auf der Spur eines eingefrorenen Interfaces: Wenn der Freeze nicht im eigenen Code steckt
Der Bildschirm wird noch gerendert, reagiert aber nicht mehr auf Berührungen — danach bricht die WebSocket-Verbindung mit einem Timeout ab. Die eigentliche Ursache liegt nicht im Business-Code, sondern in einer reproduzierbaren Layout-Rückkopplungsschleife in SwiftUI. Hier ist der vollständige Diagnoseweg.
Weitere Produkt- und Engineering-Updates aus dieser Kategorie.
Kategorie öffnenDies ist ein Debugging-Rückblick, kein Werbeartikel. Wenn eine Einschätzung damals falsch war, schreiben wir es so auf. Denn bei dieser Fehlersuche haben wir uns mehr als einmal geirrt.
Das Symptom
Ein Nutzer verwendete den Chat-Modus auf dem iPad und ließ den Agent eine sehr lange Antwort verfassen. Während die Antwort noch Zeichen für Zeichen erschien, fror die gesamte App ein: Das Bild wurde zwar noch gerendert, aber weder Scrollen noch Tippen funktionierte, kein einziger Button reagierte. Kurz darauf trennte sich die WebSocket-Verbindung zwischen Gerät und Server mit einem „Anfrage-Timeout" (Fehler -1001).
Für den Nutzer sah das nach einem Netzwerkproblem aus. Wir hatten jedoch ein entscheidendes Gegenbeweisstück. Ein schreibgeschützter Protokollbeobachter war zur gleichen Zeit als zweiter Client verbunden und hörte denselben Datenstrom des Servers mit. Sein Protokoll zeigte: Der Server arbeitete in diesem Zeitraum völlig normal, alle Streaming-Events wurden ausgeliefert, und die Antwort wurde erfolgreich abgeschlossen.
Das Einfrieren lag auf der Client-Seite — genau in dem Moment, als die Antwort fast fertig war und ein letztes Mal gerendert werden sollte. Der WebSocket-Abbruch war Folge, nicht Ursache: Die Empfangsschleife lief ebenfalls auf dem Main-Thread. Sobald der Main-Thread blockiert war, kamen keine Pakete mehr an; eingehende Daten stauten sich im TCP-Puffer, und nach einigen Sekunden wertete das System die Verbindung als Timeout.
Drei falsche Spuren
Während der Main-Thread blockiert war, haben wir eine Zeitstichprobe gezogen. Tausende von Call-Stack-Frames steckten allesamt in SwiftUIs Layout-Update-Routine fest — kein Business-Code wurde ausgeführt, kein State wurde geschrieben, und _printChanges blieb die ganze Zeit still.
Das an sich war ein wichtiger Hinweis. Wäre es eine datenbetriebene Neuberechnungsschleife gewesen, hätte _printChanges pausenlos gefeuert. Die Stille bedeutete: Die Schleife fand auf der Layout-Ebene statt, nicht auf der Datenebene.
Aber wo genau in der Layout-Ebene? Wir haben nacheinander drei Stellen verdächtigt und jede mit einem kontrollierten Simulator-Reproduzierversuch überprüft: gleiche lange Antwort, gleiche Interaktion, jeweils nur eine Variable verändert.
Erster Verdächtiger: die Selbstvermessung von UITextView. Unsere Nachrichtenblasen nutzen UITextView für auswählbaren Rich-Text; dessen intrinsicContentSize löst bei jedem Aufruf ein vollständiges TextKit-Layout aus — das klang verdächtig. Wir ersetzten es durch SwiftUIs natives Text-Element — das Einfrieren blieb. Ausgeschlossen.
Zweiter Verdächtiger: TextKit 2. Seit iOS 16 aktiviert UITextView standardmäßig TextKit 2, dessen inkrementelles Layout bei langem, nicht scrollendem Text für dieselbe (Text, Breite)-Kombination keine stabilen Messwerte liefert. Wir erzwangen den Rückfall auf TextKit 1 — das Einfrieren blieb. Ausgeschlossen.
Dritter Verdächtiger: der Rendering-Aufwand selbst. Das Parsen von Markdown-Abschnitten und das Aufbauen von Rich-Text in langen Nachrichtenblasen ist tatsächlich nicht billig. Wir fügten Caching hinzu — der Spitzenwert sank, aber das Einfrieren blieb. Das war ein Verstärker, nicht die Wurzelursache.
Alle drei Ansätze führten in Sackgassen. Zwei unabhängige statische Analysen förderten zwar „verdächtige" Verstärker zutage, aber keiner davon konnte das dauerhaft anhaltende, auch nach Ende der Antwort nicht verschwindende 100%-CPU-Problem erklären.
Lokalisierung per Bisektion
Da statische Analyse nicht weiterhalf, wählten wir den simpelsten, aber zuverlässigsten Ansatz: Wir schalteten der Reihe nach alle möglicherweise beteiligten Interface-Elemente ab und beobachteten, welches davon die CPU-Last auf null sinken ließ.
Zuerst schalteten wir das automatische Scrollen ab — den proxy.scrollTo-Aufruf, der die Liste nach jeder neuen Nachricht ans Ende scrollt. Das Einfrieren verschwand sofort, die CPU-Last fiel auf null.
Als nächstes tauschten wir als einzige Variable den LazyVStack, der die Nachrichtenliste enthält, gegen einen VStack aus, der alles auf einmal anordnet — alles andere blieb unverändert, einschließlich des automatischen Scrollens. Auch damit verschwand das Einfrieren.
Beide Experimente zusammen deuten auf dieselbe Schlussfolgerung hin:
Sobald der Chat-Inhalt länger als eine Bildschirmseite ist, führt jeder
scrollTo-Aufruf in einerScrollViewmitLazyVStack— unabhängig davon, ob animiert oder nicht, ob auf eine Nachricht oder einen Platzhalter zielend — dazu, dass die Scroll-Ansicht in eine reine Layout-Rückkopplungsschleife gerät.
So kommt es dazu: LazyVStack berechnet das Layout nur für den sichtbaren Bereich exakt; die Größen von Inhalten außerhalb des Bildschirms werden geschätzt. Wenn scrollTo zu einer bestimmten Position scrollt, löst die Verschiebung des Scroll-Offsets das Nachladen von Inhalten außerhalb des Sichtbereichs aus. Dieses Nachladen lässt die geschätzte Gesamthöhe des Inhalts neu berechnen. Ändert sich die Höhe, verschiebt sich auch die Zielposition des Ankers — was einen weiteren Scroll-Vorgang auslöst. Diese Schleife aus „Anker neu setzen ⇄ Größe neu schätzen" wird durch keine Datenänderung angetrieben; sie dreht sich auf der Layout-Ebene im Leerlauf und kommt daher von selbst niemals zum Stillstand. Das erklärt sowohl den durchgehenden 100%-CPU-Verbrauch auf dem Main-Thread als auch das völlige Schweigen von _printChanges.
Die Lösung
Sobald die Richtung klar war, konvergierten die Korrekturen:
- Den Nachrichtenlisten-Container von
LazyVStackaufVStackumstellen, der alles auf einmal anordnet. Ein Chat-Verlauf umfasst höchstens ein paar Dutzend Nachrichtenblasen; der Aufwand für ein einmaliges Layout ist vernachlässigbar, und die Gesamthöhe ist fest — kein Spielraum mehr für die Leerlaufschleife. - Das automatische Scrollen auf nicht-animiert umstellen und auf einen 1 Pixel hohen Platzhalter am Ende der Liste ausrichten, mit
anchor: nil(Semantik: „nur sichtbar machen, kein präzises Ankern"). Keine Ankerpositionen mehr für Nachrichten, die potenziell höher als der Bildschirm sind — genau dieser Ankermechanismus war der „Perpetuum-Mobile"-Antrieb aus dem früheren Profiling-Lauf. - Die zwei echten Optimierungen, die wir dabei entdeckt haben, wurden beibehalten: Markdown-Parsing und Rendering nutzen jetzt einen deterministischen LRU-Cache statt
NSCache, das beim Speicherdruck vom System komplett geleert werden kann — jede Neuberechnung wird so zu einem einfachen Cache-Lookup.
Wir validierten die Korrektur mit demselben 2500 Zeichen langen Markdown-Inhalt. Vor dem Fix blieb der Main-Thread nach Abschluss der Antwort dauerhaft bei 100%, und das Interface war vollständig nicht bedienbar. Nach dem Fix fiel die CPU-Last sofort auf 0%, sobald die Antwort endete — Scrollen, Zurückblättern und Code-Block-Rendering funktionierten alle einwandfrei.
Vier Hinweise für SwiftUI-Entwickler
- In Szenarien, in denen ein einzelner Inhalt höher als der Bildschirm sein kann, ist
ScrollView+LazyVStack+scrollToeine von Natur aus instabile Kombination. Chat-Ansichten, Logs und das Streaming langer Texte fallen alle in diese Kategorie. - Bei der Diagnose einer „nicht endenden Layout-Schleife" ist das Schweigen von
_printChangesein entscheidendes Signal. Es bedeutet, die Schleife liegt auf der Layout-Ebene, nicht auf der Datenebene —@StateundonChangezu beobachten führt nirgendwohin; nur eine Bisektion des View-Baums hilft weiter. - Für automatisches Scrollen
anchor: nilauf einen minimalen Platzhalter am Ende verwenden, statt auf einen echten Inhalt mit unbekannter Größe zu ankern. - Wenn einmaliges Layout möglich ist, nicht voreilig
Lazyeinsetzen.Lazyspart den Layout-Aufwand für unsichtbare Inhalte, aber die damit eingeführten Größenschätzungen sind genau der Treibstoff dieser Rückkopplungsschleife — bei kurzen Listen ist die Bilanz negativ.
Möchten Sie Tactic Remote auf Ihrem eigenen Rechner ausprobieren? Die iOS-App ist kostenlos im App Store erhältlich, die begleitende Serveranwendung und das CLI ebenfalls. Die vollständige Architekturgeschichte hinter diesem Fix finden Sie in „Gesprächsverlauf gehört auf den Server".
Tactic Remote ausprobieren
Coding-Agenten vom Smartphone aus steuern
Verbinde dich mit Claude Code, Codex und anderen Agenten auf deinem Mac-, Windows- oder Linux-Rechner. Prüfe den Fortschritt und sende die nächste Anweisung vom iPhone oder iPad.