博客
追查一次界面卡死:当冻结不在你的业务代码里
画面还在渲染,却完全不响应触摸,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 同样免费。这次修复背后更完整的架构故事,见《让会话历史交给服务器》。