欢迎关注我的微信公众号:OpenFlutter,感恩
这个问题让我付出了沉重的代价——我的 SwiftUI App 每隔几秒就会随机重新加载数据。
起初,我以为是我的 API 出了问题。
接着,我责怪我的 @State 变量。
然后是 Combine。
再后来是 CoreData。
有那么一刻,我甚至迁怒于 Xcode(说实话,它有时确实该背锅)。
但真正的罪魁祸首比我想象的要简单得多,也隐蔽得多:
那个看起来无辜的 .onAppear()。
像许多从 UIKit 转向 SwiftUI 的 iOS 开发者一样,我在任何地方都使用了 .onAppear()。
它感觉像是发起异步工作的自然之所——获取数据、加载图片、与 CoreData 同步以及启动后台更新。
它曾经运行得完美无缺……直到它失灵了。
突然间,我的 API 调用开始触发两次。
列表会闪烁。
有些视图会不停刷新。
最奇怪的是什么?它只是偶尔发生——这种“测不准错误(Heisenbug)”在你开启 Xcode 屏幕录制时又无法重现。
事实证明,SwiftUI 中的 .onAppear() 的含义和你想象的并不一样。
1. .onAppear() 并非 viewDidLoad
当你从 UIKit 转来时,你期望获得某些生命周期保证。
viewDidLoad 只运行一次。viewWillAppear 在视图即将出现时每次都会运行。
你可以预测这些时刻。
然而,SwiftUI 是一个完全不同的野兽。
SwiftUI 视图是结构体(structs),而不是类。
根据状态如何变化、哪个父视图触发了重新渲染,或者 SwiftUI 如何优化视图层级,它们可以被多次重新创建。
这意味着你的 .onAppear() 可以一遍又一遍地触发——不只是在视图第一次出现时,而是每当 SwiftUI 觉得需要重新附加(reattaching)该视图时。
示例:
struct UserListView: View {
@State private var users: [User] = []
var body: some View {
List(users) { user in
Text(user.name)
}
.onAppear {
Task {
await loadUsers()
}
}
}
func loadUsers() async {
try? await Task.sleep(nanoseconds: 2_000_000_000)
users = ["John", "Ava", "Noah"].map { User(name: $0) }
}
}
看起来没问题,对吧?
然而,如果任何父视图发生了变化——比如一个筛选器(filter)、导航状态,或是一个绑定(binding)更新——SwiftUI 就可以**重新创建(recreate)**这个视图。
然后 .onAppear() 就会再次触发。
现在你的 loadUsers() 就会运行多次。
- 如果这是一个 API 调用,你将反复访问服务器。
- 如果这是 CoreData 操作,你将触发不必要的获取(fetches) 。
- 如果是 UI 状态更新,你就会看到闪烁和重置。
这一切都仅仅是因为 SwiftUI 认为它只是在重新渲染一个结构体。它并不知道你在里面进行了异步工作。
2. 在 .onAppear() 中进行异步工作是危险的
让我们看看当你将 .onAppear() 与 Task 混用时,究竟会发生什么:
.onAppear {
Task {
await loadData()
}
}
乍一看,这似乎是无害的。
但这里有一个微妙的问题:
这个异步 Task 并没有绑定到(tied to)你的视图的生命周期。
因此,即使视图消失了(比如用户导航离开了),这个 Task 仍然在后台运行。
当它最终完成时,它会尝试更新一个 @State 变量……而这个变量可能已经不存在了。
这就是你最终遇到奇怪的运行时崩溃(runtime crashes)的原因,例如:
Publishing changes from background threads is not allowed
或者
Fatal error: Modifying state after view is gone
这些错误并非随机出现。它们是在 .onAppear() 内部启动的孤立异步任务所导致的直接后果。
你只是在没有意识到的情况下制造了竞态条件(race condition) 。
3. SwiftUI 的正确做法:改用 .task { }
苹果公司知道这是一个问题。
因此,他们在 iOS 15 中引入了 .task { }。
乍一看,它和 .onAppear() 很像,但区别巨大。
.task { } 是专门为异步工作而设计的。
它会在视图消失时自动取消你的任务。
这意味着,如果用户导航离开或视图被销毁,SwiftUI 会安全地取消你的异步调用——没有内存泄漏,也没有僵尸更新(zombie updates)。
让我们用正确的方法重写之前的示例:
struct UserListView: View {
@State private var users: [User] = []
var body: some View {
List(users) { user in
Text(user.name)
}
.task {
await loadUsers()
}
}
func loadUsers() async {
try? await Task.sleep(nanoseconds: 2_000_000_000)
users = ["John", "Ava", "Noah"].map { User(name: $0) }
}
}
现在,它的行为就完全符合你的预期:
- 任务在每次视图出现时只运行一次。
- 如果视图消失,SwiftUI 会自动取消它。
- 你无需手动管理任务的生命周期。
- 没有“幽灵任务”(Ghost tasks)。
- 没有重复加载。
- 没有竞态条件。
4. 但是等等——为什么 .task 如此有效?
因为 SwiftUI 在内部将其绑定到了视图的“身份”(identity) 。
每个 SwiftUI 视图都有一个唯一的身份,这个身份决定了它何时处于“活动”状态。
当该身份发生变化时(例如,不同的 id、新的状态或导航事件),SwiftUI 就会取消任何与其相关的 .task。
这就是支持 .task(id:) 工作的机制,这是一个更高级的版本,它允许你控制任务何时重启:
.task(id: user.id) {
await fetchProfile(for: user)
}
因此,每当 user.id 发生变化时,你的异步任务就会重新启动。
如果 user.id 没有变化,任务就会保持稳定——不会有重复的获取。
这对于像分页列表或依赖于选择的动态视图等复杂 UI 来说,是极其有用的。
5. .onAppear() 仍有意义的场景
公平地说,.onAppear() 并非一无是处。
它只是有不同的用途。
.onAppear() 非常适合用于:
- 同步状态更新
- 动画触发
- 日志记录或分析事件
- 不涉及
await 或长时间操作的 UI 更改
例如:
.onAppear {
isVisible = true
analytics.log("UserList visible")
}
这样做完全没问题。
没有异步工作,没有外部依赖,自然也没有问题。只要你的代码中出现了 await,就应该把它移出 .onAppear()。
6. 幽灵重载问题(The Phantom Reload Problem)
误用 .onAppear() 最令人沮丧的副作用之一发生在列表中。
想象一下这种情况:
ForEach(users) { user in
UserRow(user: user)
.onAppear {
Task {
await fetchProfilePicture(for: user)
}
}
}
这看起来无害——在每个用户行出现时获取他们的个人资料图片。
但在实际操作中,当你滚动时,SwiftUI 会回收(recycles)视图。
因此,随着单元格不断出现和消失,.onAppear() 会被一遍又一遍地触发。
恭喜你,你刚刚制造了一场后台网络风暴(background network storm)。
修复方法:
改用 .task(id:),或者在视图层级的更高层级预取(prefetch)你的数据。
ForEach(users) { user in
UserRow(user: user)
.task(id: user.id) {
await fetchProfilePicture(for: user)
}
}
现在,每个用户的图片获取任务都绑定到了它的身份(identity)。
当视图消失时,SwiftUI 会取消该任务。
这样你就避免了所有那些重复的获取。
7. 真实世界的生产环境示例
我曾经为一个基于 SwiftUI 的电子商务应用工作,它有一个标签栏(tab bar)。
“首页”标签有一个仪表板视图,该视图在启动时需要获取多个 API 数据——促销信息、用户数据、购物车数量等。
代码看起来是这样的:
struct HomeView: View {
@State private var data: HomeData?
var body: some View {
VStack {
if let data {
HomeDashboard(data: data)
} else {
ProgressView()
}
}
.onAppear {
Task {
await fetchHomeData()
}
}
}
}
在开发过程中,一切似乎都很正常。
但在生产环境中,用户发现应用运行缓慢。
网络日志显示,每当他们切换标签时,就会出现重复的请求(duplicate requests)。
为什么?
因为 SwiftUI 在底层会根据内存和导航状态**销毁并重新创建(destroys and recreates)**标签视图。
每次重新创建都会触发 .onAppear(),从而启动一个新的异步任务——即使数据已经加载完毕。
在改用 .task { } 之后,这个问题一夜之间就消失了。
8. 调试技巧:打印生命周期事件
如果你不确定你的视图出现了多少次,可以试试这个快速技巧:
.onAppear { print("✅ Appeared!") }
.onDisappear { print("❌ Disappeared!") }
你会对这些事件触发的频率感到震惊——有时甚至当你只是在同级视图之间导航,或者切换更高层级的状态时,它们也会触发。
那一刻你就会意识到 .onAppear() 对异步工作来说有多么危险。
9. 额外技巧:将 .task 与 .refreshable 结合使用 ✨
当你处理数据获取时,这种组合简直就是纯粹的 SwiftUI 黄金搭档:
struct ArticleList: View {
@State private var articles: [Article] = []
var body: some View {
List(articles) { article in
Text(article.title)
}
.task { await loadArticles() }
.refreshable { await loadArticles() }
}
func loadArticles() async {
try? await Task.sleep(nanoseconds: 1_000_000_000)
articles = ["SwiftUI", "Concurrency", "Combine"].map { Article(title: $0) }
}
}
这为你带来了:
- 安全的初始加载
- 轻松实现下拉刷新(pull-to-refresh)
- 自动任务取消
- 简洁的、声明式的语法
- 无需过度思考
10. 经验法则
这是最简单的记忆方法:
如果你的函数包含 await,那么它就不属于 .onAppear()。
就是这样。
-
.onAppear() = 用于轻量级、同步的 UI 触发器。
-
.task { } = 用于异步的、可取消的、并绑定到视图生命周期的工作。
11. 针对旧版 iOS 怎么办?
如果你需要支持 iOS 14 或更早的版本,.task { } 是不可用的。
在这种情况下,你仍然可以使 .onAppear() 变得安全——只需手动添加取消逻辑。
示例:
struct LegacyView: View {
@State private var task: Task<Void, Never>?
var body: some View {
VStack {
Text("Legacy Async Work")
}
.onAppear {
task = Task {
await loadData()
}
}
.onDisappear {
task?.cancel()
}
}
func loadData() async {
try? await Task.sleep(nanoseconds: 2_000_000_000)
}
}
不像之前那么优雅,但它能让你的异步工作保持在控制之下。
12. 吸取的教训
这段经历教会了我这些:
- SwiftUI 视图是短暂的(ephemeral)——把它们视为快照,而不是屏幕。
-
.onAppear() 可以(而且将会)多次触发——不要依赖它进行一次性的设置。
- 异步工作需要是可取消的(cancelable)——
.task { } 免费为你提供了这一点。
- 除非你确切知道何时会触发,否则不要在视图结构体内部放置副作用(side effects)。
- 如果你看到随机的重载或闪烁,首先检查你的
.onAppear() 调用。
13. 我的最终看法
如果你的 SwiftUI App 随机重新加载数据,
如果你的 API 调用触发了两次,
如果你的加载指示器无故闪烁——
不要想太多。
检查你的 .onAppear()。
在大多数情况下,用 .task { } 替换它会立即修复 90% 的这些问题。
SwiftUI 提供了正确的工具;你只需要将它们用于其预期的目的。
因为 .onAppear() 并没有坏——它只是不适合承担异步逻辑的重担。
结语(Final Thoughts)
我曾经以为 .onAppear() 是无害的。
直到它悄无声息地让我的 SwiftUI App 看起来不稳定且不可预测。
一旦我用 .task 替换了它,一切都豁然开朗——无论是字面上还是象征意义上。
UI 停止了闪烁。
API 停止了过度触发。
我的异步代码第一次感觉真正属于 SwiftUI 的世界。
所以,如果你正在与随机重载、奇怪的时序问题或无形的后台任务作斗争——不用再找了。
你很可能用错了 .onAppear()。