ブログ
UIフリーズを追う:バグがビジネスロジックの外にあるとき
画面はまだレンダリングされているのにタッチに一切反応せず、やがて WebSocket がタイムアウトで切断される。原因はビジネスロジックではなく、SwiftUI で安定して再現できるレイアウトのフィードバックループだった。調査の全過程を記録する。
このカテゴリのプロダクト/開発アップデートを確認できます。
カテゴリを見るこれはデバッグの振り返りであって、宣伝記事ではない。当時の判断が間違っていたなら、そのまま書く。今回の調査では、一度ならず判断を誤った。
現象
あるユーザーが iPad のチャットモードで agent に長い回答を書かせていた。回答がまだ一字一字ストリーミングされている最中に、アプリ全体がフリーズした。画面のレンダリングは続いているが、スクロールもタップもできず、どのボタンも反応しない。しばらくして、端末とサーバーの間の WebSocket 接続が「リクエストタイムアウト」(-1001)エラーで切断された。
ユーザーの目には、これはネットワーク障害に見えた。しかし手元に重要な傍証があった。当時、読み取り専用のプロトコルオブザーバーが、2番目のクライアントとして同じサーバーのデータストリームを傍受していた。その記録によれば、その間サーバーは完全に正常に動作しており、ストリーミングイベントもすべて送出されていて、そのターンの回答も問題なく完了していた。
フリーズしていたのはクライアント側であり、しかもそのターンの回答が終わろうとして最後のレンダリングを行う瞬間に固まっていた。WebSocket の切断は原因ではなく結果だ。パケット受信ループも同じくメインスレッド上で動いており、メインスレッドがフリーズすれば受信も止まる。サーバーが送ったデータは TCP バッファに溜まって取り出せなくなり、数十秒後にシステムがその接続をタイムアウトと判定した。
3つの間違った方向
メインスレッドがフリーズした直後にサンプリングを取った。数千フレーム分のコールスタックは、すべて SwiftUI のレイアウト更新で止まっていた。ビジネスロジックのコードは一切実行されておらず、どの状態も書き込まれていない——_printChanges は終始まったく無音だった。
これ自体が重要な手がかりだ。データ駆動の再計算ループであれば、_printChanges が絶え間なく出力されるはずだ。沈黙しているということは、ループがデータ層ではなくレイアウト層で起きていることを示している。
では、レイアウト層のどこか。私たちは3つの箇所を順番に疑い、それぞれ制御されたシミュレーター再現で検証した。同じ長い回答、同じ操作、毎回変更する変数は1つだけ。
最初の容疑対象は、UITextView の自己測定だった。 メッセージバブルでは選択可能なリッチテキストを UITextView で実装しており、その intrinsicContentSize は毎回全文の TextKit レイアウトを走らせる——もっともらしい犯人に思えた。そこで純粋な SwiftUI の Text に置き換えた。それでもフリーズした。排除。
2番目の容疑対象は TextKit 2 だった。 iOS 16 以降、UITextView はデフォルトで TextKit 2 を使用しており、スクロールしない長いテキストに対してその増分レイアウトは、同じ(テキスト、幅)の組み合わせでも測定結果が必ずしも安定しない。TextKit 1 に強制ロールバックした。それでもフリーズした。排除。
3番目の容疑対象は、レンダリング自体のコストだった。 長いバブルの Markdown パース、リッチテキストの再構築は確かに安くない。キャッシュを追加したところ、ピーク時の負荷は下がったが、フリーズは続いた。これは増幅器であり、根本原因ではない。
3つの方向はすべて行き詰まった。2回の独立した静的解析でも「怪しそうな」増幅器は見つかったが、回答が終わっても回復しない持続的な 100% CPU 占有を説明できるものは1つもなかった。
二分法による特定
静的解析で根本原因が見つからなかったため、最も地道だが最も信頼できる方法に切り替えた。画面上で関係しそうなものを1つずつ無効化し、どれが CPU 占有をゼロに戻すかを確認する。
まず自動スクロール——新しいメッセージが届いたときにリストを最下部にスクロールする proxy.scrollTo の呼び出し——を無効にした。フリーズはすぐに消え、CPU もゼロに戻った。
次に変数を1つだけ入れ替えた。メッセージリストを保持している LazyVStack を一括レイアウトの VStack に変え、自動スクロールを含む他のすべては維持した。フリーズはやはり消えた。
2つの実験が合わさって、同一の結論を示した。
チャットのコンテンツが1画面を超える場合、
ScrollViewとLazyVStackを組み合わせた状態では、scrollToを1回呼び出すだけで——アニメーションの有無を問わず、特定のメッセージへのアンカーでも、プレースホルダービューへのアンカーでも——スクロールビューが純粋なレイアウトのフィードバックループに陥る。
その仕組みはこうだ。LazyVStack は可視領域だけを精密にレイアウトし、画面外のコンテンツのサイズは推定値で補完する。scrollTo で特定の位置に移動すると、スクロールオフセットの変化が画面外コンテンツのオンデマンドロードをトリガーする。ロードによってコンテンツの総高さが再推定され、高さが変わるとアンカー先の目標位置も移動するため、さらにもう一度スクロールが必要になる。この「再アンカー ⇄ サイズ再推定」のループは、背後でデータ変化が何も起きていないため、レイアウト層だけで空回りし続け、自然には止まらない。メインスレッドが 100% 占有されているのに _printChanges が完全に無音である理由がこれだ。
修正
原因が明確になれば、修正は絞り込める。
- メッセージリストのコンテナを
LazyVStackから一括レイアウトのVStackに変更する。 チャットのコンテンツはせいぜい数十個のバブルであり、一括レイアウトのコストは無視できる。また総高さが確定値になるため、前述のループが空回りする余地がなくなる。 - 自動スクロールはアニメーションなしに変更し、リスト末尾にある高さ 1px のプレースホルダービューに
anchor: nil(「可視範囲に入れば十分」というセマンティクス)でスクロールする。 画面より背の高い可能性があるメッセージへのアンカースクロールはやめる——あのアンカー位置計算の仕組みこそ、先のサンプリングで見えた「永久機関」だった。 - 今回見つかった2つの実質的な最適化もそのまま残す。Markdown のパースとレンダリングには、メモリ逼迫時にシステムが一括クリアしてしまう
NSCacheではなく、挙動が予測可能な LRU キャッシュを使用することで、再計算のたびにキャッシュをルックアップできる。
同じ 2500 字の Markdown 長文回答で検証した。修正前は、そのターンが終わるとメインスレッドが恒久的に 100% に張り付き、画面が完全に操作不能になる。修正後は、回答が終わった瞬間に CPU が 0% に戻り、スクロール、履歴閲覧、コードブロックレンダリングがすべて正常に動作する。
SwiftUI 開発者へのポイント4つ
- 「1件のコンテンツが画面の高さを超える可能性がある」シナリオでは、
ScrollView+LazyVStack+scrollToは本質的に不安定な組み合わせだ。 チャット、ログ、長文のストリーミングレンダリングはすべてこれに該当する。 - 「止まらないレイアウトループ」を調査するとき、
_printChangesの沈黙は決定的なシグナルだ。 それはループがデータ層ではなくレイアウト層で起きていることを意味する。@StateやonChangeをいくら追っても根本原因は見つからない——ビュー木を二分法で絞り込む以外に手はない。 - 自動スクロールでは、サイズが不定な実コンテンツへのアンカーではなく、極小の末尾プレースホルダービューに
anchor: nilを使うことを優先する。 - 一括レイアウトで済むなら、安易に
Lazyを使わない。Lazyが節約するのは画面外レイアウトのコストだが、それが導入するサイズ推定こそがこのフィードバックループの燃料だ。リスト自体が短い場合、この選択は割に合わない。
Tactic Remote を手元で試してみたい方へ:iOS アプリは App Store で無料公開中です。コンパニオンアプリと CLI も無料です。今回の修正の背景にあるより完全なアーキテクチャの話は、「会話履歴はサーバーに任せる」をご覧ください。
Tactic Remote を試す
スマートフォンからコーディングエージェントを操作
Mac、Windows、Linux 上の Claude Code や Codex などに接続。iPhone や iPad で進捗を確認し、次の指示を送れます。