部落格
追查一次介面凍結:當卡死不在你的業務程式碼裡
畫面仍在渲染,卻完全不響應觸控,WebSocket 隨後逾時斷線。真正的原因不在業務程式碼,而是 SwiftUI 一個可穩定重現的布局反饋循環。這是完整的排查過程。
繼續查看該欄目下的產品與工程更新。
瀏覽此分類這是一篇除錯復盤,不是宣傳文章。如果當時某個判斷是錯的,我們會如實寫出來。因為這次排查,我們判斷錯了不止一次。
現象
一位使用者在 iPad 上使用聊天模式,讓 agent 寫一段很長的回答。就在回答還在逐字輸出的過程中,整個應用程式凍結了:畫面渲染是正常的,但滑不動、也點不動,任何按鈕都沒有反應。又過了一會兒,手機與伺服器之間的 WebSocket 連線,以「請求逾時」(-1001)的錯誤斷開了。
在使用者看來,這像是一次網路故障。但我們手上有一份關鍵的旁證。當時有一個唯讀的協定觀察器,正以第二個客戶端的身份,旁聽著同一條伺服器資料流。它的記錄說明:那段時間裡,伺服器完全正常,該發出的串流事件一個都沒少,那一輪回答也順利結束了。
卡住的是手機端,而且卡在那一輪回答即將結束、做最後一次渲染的時刻。至於 WebSocket 的斷線,只是結果而非原因:負責接收封包的循環同樣跑在主執行緒上,主執行緒一旦凍結,接收封包就停了,伺服器發來的資料堆在 TCP 緩衝區裡進不來,幾十秒後系統便把這條連線判為逾時。
三個錯誤方向
主執行緒凍結的當下,我們抓了一次採樣。幾千幀的呼叫堆疊,全部停在 SwiftUI 的布局更新裡,沒有任何業務程式碼在執行,也沒有任何狀態在被寫入——_printChanges 自始至終一片安靜。
這本身就是一條重要線索。如果這是一個由資料驅動的重算循環,_printChanges 會不停刷屏;它安靜,恰恰說明循環發生在布局層,而不是資料層。
可布局層的哪裡?我們先後懷疑過三個地方,每一個都用一次受控的模擬器重現去驗證:同樣的長回答,同樣的操作,每次只改動一個變數。
第一個懷疑對象,是 UITextView 的自我測量。 我們的訊息氣泡用 UITextView 承載可選取的富文字,它的 intrinsicContentSize 每次都會觸發一次全文的 TextKit 布局,聽起來很像元凶。於是我們把它換成純 SwiftUI 的 Text——照樣凍結。排除。
第二個懷疑對象,是 TextKit 2。 iOS 16 之後 UITextView 預設啟用 TextKit 2,它的增量布局在一大段不捲動的文字上,對固定的(文字,寬度)組合,測量結果並不總是穩定的。我們強制退回 TextKit 1——照樣凍結。排除。
第三個懷疑對象,是渲染本身的開銷。 長氣泡的 Markdown 分段、富文字重建,確實都不便宜。我們加了快取——峰值降了下來,但凍結依舊。它是放大器,不是根本原因。
三個方向全部走到了死胡同。兩輪獨立的靜態分析,都能挑出「看起來很可疑」的放大器,卻沒有一個能解釋這個持續不散、回答結束後也不恢復的 100% CPU 占用。
用二分法定位
既然靜態分析找不到源頭,我們改用最笨、但最可靠的方法:把介面裡可能相關的東西逐一關掉,看哪一個能讓 CPU 占用降到零。
先關掉自動捲動,也就是新訊息到達後、把列表捲動到底部的那句 proxy.scrollTo。凍結當即消失,CPU 歸零。
再單獨換一個變數:把承載訊息列表的 LazyVStack 換成會一次性布局的 VStack,其餘全部保留,包括自動捲動。凍結同樣消失了。
兩次實驗合在一起,指向同一個結論:
只要聊天內容超過一個畫面,
ScrollView搭配LazyVStack時,任何一次scrollTo——無論有沒有動畫,無論錨定的是某條訊息還是一個占位視圖——都會讓捲動視圖陷入一個純布局的反饋循環。
它的成因是這樣的。LazyVStack 只對可見區域做精確布局,螢幕外內容的尺寸是估算出來的。當你 scrollTo 到某個位置,捲動偏移的變化觸發了螢幕外內容的按需載入,載入又讓內容總高度被重新估算,高度一變,錨定的目標位置也跟著移動,於是又要再捲一次。這個「重新錨定 ⇄ 重新估算尺寸」的循環,背後沒有任何資料變化在推動它,純粹在布局層裡空轉,也因此永遠不會自己停下來。這正是主執行緒占滿 100%、_printChanges 卻毫無動靜的原因。
修復
方向明確之後,修復就收斂了:
- 把訊息列表容器從
LazyVStack換成會一次性布局的VStack。 一段聊天內容至多幾十個氣泡,一次性布局的開銷可以忽略,而它的總高度是確定的,不給上面那個循環留下空轉的空間。 - 自動捲動改為無動畫,並對準列表末尾一個 1 像素高的占位視圖,使用
anchor: nil(「捲到可見即可」的語義)。 不再對一條可能比畫面還高的訊息做錨定捲動——那套錨定定位機制,正是上一次採樣裡的那台「永動機」。 - 順帶保留這次找到的兩處真實最佳化:Markdown 分段與渲染改用可預期的 LRU 快取,而不是會在記憶體吃緊時被系統整體清空的
NSCache,把每一次重算都變成一次查表。
我們用同一段 2500 字的 Markdown 長回答做驗證。修復前,那一輪結束後主執行緒永久停在 100%,介面完全無法操作;修復後,回答一結束,CPU 立刻回落到 0%,上下捲動、翻看歷史、程式碼區塊渲染,全部正常。
寫給 SwiftUI 開發者的四點
- 在「單條內容可能高於畫面」的場景裡,
ScrollView+LazyVStack+scrollTo是天然不穩定的組合。 聊天、日誌、長文串流渲染,都屬於這一類。 - 排查「停不下來的布局循環」時,
_printChanges的沉默是關鍵訊號。 它意味著循環在布局層而非資料層,此時盯著@State和onChange是找不到的,只能對視圖樹做二分排查。 - 自動捲動優先用
anchor: nil對準一個極小的尾部占位視圖,而不是錨定一條尺寸不確定的真實內容。 - 能一次性布局,就別圖方便用
Lazy。Lazy省下的是螢幕外布局的開銷,但它引入的尺寸估算,恰恰是這個反饋循環的燃料;當列表本身不長時,這筆帳是虧的。
想在自己的機器上試試 Tactic Remote?iOS 應用程式已在 App Store 免費上架,配套應用程式與 CLI 同樣免費。這次修復背後更完整的架構故事,見《讓對話歷史交給伺服器》。