返回文章列表

Swift Concurrency 系列 07|SwiftUI 和 async/await 结合时最常见的坑

真正的坑通常不在语法,而在于你有没有把“页面生命周期”和“任务生命周期”对齐

SwiftUI 和 async/await 单独看都很优雅,但它们一结合,最容易暴露的问题恰恰不是语法,而是生命周期。

更准确地说,是两个生命周期之间的错位:

  • SwiftUI 页面什么时候出现、重绘、消失
  • 异步任务什么时候开始、暂停、结束、取消

如果你没有把这两件事对齐,代码即使今天能跑,后面也很容易长出:

  • 重复请求
  • 页面闪动
  • 旧结果回写
  • loading 状态错乱
  • 页面离开后任务还在更新状态

所以这篇文章真正想讲的是:SwiftUI 异步页面为什么容易乱,以及这些乱背后的根源是什么。

一、最常见的误判:把 View 当成稳定对象

这是很多 UIKit 背景开发者最容易带进来的习惯。

大家会默认:

  • 页面出现一次
  • 请求发一次
  • 请求回来后更新当前页面

但 SwiftUI 里的 View 更像状态描述,而不是一个你可以长期抓着不放的稳定实例。

这意味着如果你脑子里默认的是“我眼前这个 View 会一直在,所以这个任务自然属于它”,后面就很容易出问题。

SwiftUI 没有要求你把任务绑在一个稳定对象上,它只是很容易让你误以为自己已经这么做了。

二、onAppear 最大的坑,不是会重复触发,而是你把它当成了一次性初始化入口

很多文章会说:onAppear 可能执行多次。
这句话没错,但还不够。

真正危险的地方在于,很多人把它写成了“页面初始化”的事实标准入口:

.onAppear {
    Task {
        await loadData()
    }
}

问题不在这段代码一定错,而在于你很容易在心智上偷偷加了一句:

“这个页面出现时,只会跑这一遍。”

一旦你这么想,后面就会接连出现:

  • 重复请求
  • 重复重置状态
  • 重复埋点
  • 数据刚展示好又被清掉重来

所以更稳的思路不是“想办法让 onAppear 只触发一次”,而是:
让你的异步流程本身具备幂等、去重或可替换能力。

三、第二个坑:把页面状态和任务状态混在一起

一个 SwiftUI 页面里常见的状态包括:

  • 当前筛选条件
  • 当前数据内容
  • 是否加载中
  • 错误信息
  • 当前正在跑的任务

如果这些状态没有被明确分层,就很容易混成一团。

最常见的坏味道是:

  • items
  • isLoading
  • error
  • isRefreshing
  • keyword
  • selectedTab

每个值单独都合理,但你很难说清它们彼此之间是什么关系。
于是页面就会进入一种很别扭的状态:

  • 看起来什么状态都有
  • 但没有一个状态真正表达了“页面现在处于什么语义”

在这种情况下,异步结果一回来,任何状态都可能被改一把,问题只是早晚暴露。

四、第三个坑:旧结果回写当前 UI

这是 SwiftUI 异步页面里最高频的问题之一。

典型场景包括:

  • 用户快速切换 tab
  • 搜索关键词连续变化
  • 筛选条件反复切换
  • 页面先后触发刷新和首次加载

你以为自己只是“发了多个任务”,其实真正的问题是:

旧任务虽然还合法完成了,但它已经不再对应当前页面状态。

一旦旧结果仍然能写当前 UI,你看到的表象通常只是:

  • 页面闪一下
  • 列表突然回退
  • loading 状态突然结束
  • 错误提示莫名弹出来

这些现象很像“小 bug”,但根源非常一致:
任务结果的有效性没有被管理。

五、第四个坑:把所有异步入口都摊在 View 上

一个页面如果同时出现这些入口:

  • onAppear { Task { ... } }
  • refreshable { await ... }
  • onChange(of:) { Task { ... } }
  • 按钮点击里再开一个 Task

它们单独看都很合法,但放在一起就会迅速变成一个问题:

任务关系失控。

你会越来越难回答:

  • 谁是主入口
  • 谁应该取消谁
  • 谁才有资格改当前展示状态
  • 某个状态更新到底对应哪轮任务

所以很多 SwiftUI 异步页面不是不会写,而是入口太多,每个入口都能直接触发任务,最后没有统一协调层。

六、第五个坑:默认“只要能更新 UI 就行”

SwiftUI 把很多 UI 更新细节隐藏掉了,这会让人产生一种错觉:只要我最后把状态改掉,页面自然会刷新。

但真实问题不在“会不会刷新”,而在“有没有资格在这个时机刷新”。

比如:

  • 当前结果是不是已经过期
  • 当前页面是不是还活着
  • 当前状态是不是仍然匹配这轮任务
  • 当前修改是不是应该在主 actor 语义下进行

如果这些事情没有被认真对待,页面可能不会立刻崩,但会慢慢累积出一堆“偶发错乱”。

七、更稳的做法:让 View 触发意图,让状态层管理任务

我更推荐的组织方式是:

  • View 负责表达用户意图
  • ViewModel 或状态层负责管理任务和结果资格
  • View 只消费已经整理好的状态

也就是说,View 最好少知道这些细节:

  • 是否取消旧任务
  • 哪个结果已经过期
  • 当前 loading 是首次加载还是刷新
  • 错误状态是否应该覆盖旧内容

这些问题如果都留在 View 上,页面很快就会从“声明式 UI”变成“声明式外壳包着一层分散异步逻辑”。

八、一个更接近实战的建议

如果一个 SwiftUI 页面已经开始变复杂,我通常会强迫自己先回答下面几个问题:

  1. 页面有哪些任务入口。
  2. 同类任务之间是并存、替换还是忽略。
  3. 哪些状态是页面语义状态,哪些只是内部过程状态。
  4. 旧结果是否还允许改当前页面。
  5. 页面离开后,还有哪些任务应该继续,哪些应该停止。

这几个问题一旦答不上来,通常说明不是 API 用法有问题,而是页面任务模型还没立住。

九、结论:SwiftUI 异步页面真正难的,是生命周期对齐

很多人以为 SwiftUI 和 async/await 的坑主要在语法上。
但更真实的情况是:

  • View 不是你以为的稳定对象
  • onAppear 不是一次性初始化语义
  • 旧结果不会因为“已经过期”就自动失效
  • 页面层如果任务入口太多,就一定会开始乱

所以真正稳的 SwiftUI 异步页面,不是“哪里能开任务就开哪里”,而是:

先把页面生命周期和任务生命周期对齐,再去写具体异步代码。

只有这件事先成立,SwiftUI 的轻量和 async/await 的优雅,才会真正变成优势,而不是把混乱写得更顺手。