Swift并发先控隔离
Swift 并发最容易被误解成“把回调换成 async/await”。语法确实更顺了,但真正让项目变稳的不是少写几层闭包,而是把哪些代码能并发、哪些状态只能串行、哪些任务必须跟页面一起结束讲清楚。没有这层隔离,代码看起来现代,问题仍然老派:页面消失后网络还在跑,后台任务改了 UI 状态,多个请求回来顺序不一致。
我更愿意把 Swift 并发先当成边界设计,而不是性能优化。actor、MainActor、TaskGroup、取消传播这些能力都很有用,但它们不是越多越好。一个页面里如果既有 UI 状态、远程数据、本地缓存、埋点和重试,就应该先画出谁拥有状态、谁能修改状态、谁负责结束任务。
并发问题先从状态主人开始
页面状态如果没有主人,任何异步任务都可能在任意时刻写它。列表刷新、分页加载、搜索联想和详情预取经常同时存在,返回顺序一乱,用户看到的就可能是旧请求覆盖新请求。先确定状态属于 ViewModel、actor 还是主线程 UI,是避免串改的第一步。
比较稳的做法是把 UI 可见状态放在 MainActor 约束下,把可并发计算和远程访问放在独立服务里。这样不是为了追求层次漂亮,而是让代码审查时一眼能看出哪些地方允许并发,哪些地方必须回到主线程。
反过来,如果一个方法既发请求、又改 UI、又写缓存、又触发下一轮任务,它很可能已经跨过了太多边界。后续任何一个 await 都可能让状态在你没预料的时候被别人改变。并发代码的难点不在“能不能同时跑”,而在“同时跑完以后谁有资格决定最终状态”。
状态主人必须清楚,才能避免旧任务覆盖新状态。
await之后要重新确认世界
很多时序 bug 都发生在 await 之后。你发起请求时页面还是 A 状态,请求回来时用户可能已经切到 B 状态,筛选条件可能已经变了,甚至当前对象已经不该再展示结果。await 不是普通函数调用,它会把执行权交出去,让其他任务有机会改变上下文。
所以,关键状态不要只在调用前判断一次。请求回来以后要检查任务是否取消、当前查询条件是否仍然一致、结果是否属于当前页面。这个检查看起来啰嗦,但它比“偶现旧数据覆盖新数据”好排查得多。
在搜索联想、分页加载、详情页预取里,这个规则尤其重要。比如用户连续输入三次关键词,第三次请求先回来,第一次请求后回来,如果没有版本号或请求序号,第一次结果就可能覆盖第三次结果。解决办法不是指望网络按顺序返回,而是让状态更新只接受最新的那一份。
MainActor不是万能保险
很多团队看到 UI 更新报错,就把整个 ViewModel 标成 MainActor。短期看问题少了,长期看后台计算、JSON 解析、缓存合并也被绑到主线程,性能和职责都会变得含糊。MainActor 应该保护 UI 相关状态,不应该成为所有逻辑的避风港。
更细的拆法是:UI 状态变更在 MainActor,耗时操作离开 MainActor,结果回写时再切回来。这样代码稍微多一点,但每段逻辑的运行环境更清楚。尤其是图片解码、列表差异计算、批量格式化这类任务,不适合因为方便就放在主线程里。
验收时不要只看没有线程警告,还要看滚动是否掉帧、快速切换页面是否出现旧结果、取消任务后是否仍有 UI 更新。MainActor 能保护线程安全,但不能自动保护业务时序。线程安全只是底线,状态语义才是体验稳定的关键。
Task生命周期要跟页面关系明确
SwiftUI 里任务可能来自 .task、按钮点击、ViewModel 初始化或后台服务。它们的生命周期不一样:有的应该跟随视图消失取消,有的应该跨页面继续,有的应该由业务队列接管。把这些任务混在一起,是后来排查泄漏的常见源头。
我会给任务分三类:视图任务、业务任务、后台补偿任务。视图任务离开页面就取消;业务任务跟随业务对象,比如一次上传或一次支付;后台补偿任务要持久化进度,不能只靠内存里的 Task。
如果一个页面退出后仍然能看到日志继续写 UI 状态,那就是生命周期断了。不要用“反正不会崩”放过它,因为下一次它可能覆盖新页面的数据。更麻烦的是,这类问题通常不在普通成功路径里出现,而是在弱网、快速返回、系统切后台、重复点击时集中爆发。
不同任务寿命不同,不能都交给页面销毁兜底。
下面这段不是让你照抄,而是把边界写成团队能讨论的形式:
- @MainActor
- final class ArticleListModel: ObservableObject {
- @Published private(set) var state: ViewState = .idle
- private var reloadToken = UUID()
- func reload() async {
- let token = UUID()
- reloadToken = token
- state = .loading
- do {
- let items = try await service.fetchItems()
- guard !Task.isCancelled, reloadToken == token else { return }
- state = .loaded(items)
- } catch is CancellationError {
- guard reloadToken == token else { return }
- state = .idle
- } catch {
- guard reloadToken == token else { return }
- state = .failed(error)
- }
- }
- }
这段代码里真正值得看的是 reloadToken。它不是复杂设计,只是把“只有最新任务能改状态”这件事显式写出来。很多团队的问题不是不会用并发,而是不愿意把这些隐含规则写进代码。
actor适合保护资源而不是包装所有服务
actor 的价值在于保护共享可变状态,比如令牌刷新、缓存索引、下载队列、计数器。它不应该被拿来包装所有服务方法,否则你只是把一堆异步调用串成了新的瓶颈。
判断是否需要 actor,可以问两个问题:这个对象内部有没有可变状态;这些状态是否会被多个任务同时访问。如果答案是否定的,普通类型加清楚的调用约束就足够。比如一个纯网络服务只根据参数发请求,它未必需要 actor;但一个 token 刷新器要避免多个请求同时刷新,就很适合 actor。
actor 也要注意重入。一个 actor 方法里 await 之后,其他调用可能插进来改变状态。不要以为进了 actor 就完全串行到结束,关键状态变更前后仍然要想清楚。真正稳的写法,是在 await 前后都把状态条件整理明白,而不是把 actor 当成万能锁。
取消不是礼貌动作
取消在 Swift 并发里是协作式的。父任务取消了,子任务要不要停,停在哪里,是否需要清理,都需要代码配合。只创建 Task 不保存引用、不检查 Task.isCancelled、不传递取消语义,最后取消按钮就只是一个 UI 装饰。
网络请求、解析循环、批量处理都应该在合适位置检查取消。清理动作也要设计好:临时文件是否删除,缓存是否回滚,状态是否标记为 canceled。取消不是失败,但它必须是可解释的结束。
最有用的测试不是让任务成功跑完,而是在每个 await 前后取消一次。只要状态都能停在合理位置,Swift 并发才真正进入了工程可控状态。
取消链路要能一路追到资源清理。
并发测试要模拟不舒服的顺序
只测成功路径,基本测不出并发边界。你需要故意让第二次请求先返回、第一次请求后返回;让用户在请求中途返回页面;让解析执行到一半取消;让 token 刷新同时被多个接口触发。并发测试的价值,是把现实里偶发的坏顺序提前搬到测试里。
如果项目已经有依赖注入,可以把网络层换成可控延迟的假实现。没有也没关系,至少要在 ViewModel 层写几个时序测试:旧请求不能覆盖新请求,取消后不能写入 loaded,页面释放后不能继续触发 UI 变更。这些测试不追求覆盖所有代码,但能守住最容易出事故的边界。
日志也要跟上。每个任务最好能带上 requestId、页面标识和触发来源。线上发现“偶现错页数据”时,如果日志里只有接口名,你只能猜;如果有任务来源和状态转移,就能看出到底是旧请求回写、重复触发,还是取消没有传下去。
最后看的是可解释性
Swift 并发写得好不好,不是看用了多少新语法,而是看出问题时能不能解释。某个状态是谁写的,某个任务为什么还活着,某个取消为什么没生效,某个 actor 为什么变成瓶颈,这些问题如果都能从代码结构里回答,后续维护就轻很多。
我会把落地顺序定得很朴素:先找状态主人,再拆 MainActor 范围,然后整理任务生命周期,最后补取消和测试。不要一开始就追求把所有异步逻辑改成最漂亮的结构。并发改造最怕一次性换太多,问题会从“旧代码难看”变成“新代码不可解释”。
当团队能用同一套语言讨论状态主人、任务寿命和取消边界时,Swift 并发才不只是语法升级,而是真的帮应用减少了那些最烦人的偶现问题。
团队迁移要留出过渡层
如果一个项目已经有大量回调和 Combine,不建议一口气全改。更稳的方式是在边界处先做适配:网络层可以逐步提供 async 方法,旧调用继续保留;ViewModel 先把关键页面改成结构化任务;后台服务再慢慢整理 actor 和取消语义。
迁移过程中最容易出问题的是混用。旧回调没有取消语义,新 Task 以为自己能取消,结果底层请求还在跑;Combine 管道仍然发值,SwiftUI 页面已经销毁。过渡期要把这些桥接点单独标出来,哪怕代码暂时不完美,也要让团队知道风险在哪里。
我的取舍是:先改用户最容易感知的时序问题,再改内部结构。比如搜索页旧结果覆盖、详情页返回后继续刷新、上传任务取消失败,这些比“所有服务都换成 async”更值得优先处理。并发改造不是为了显得先进,而是为了让坏顺序少一点。
