onAppear
是 SwiftUI 中极其关键的生命周期方法,用于在视图呈现时注入关键逻辑。由于视图实例可能会频繁地被创建和重建,开发者通常会选择在这些方法中准备数据、执行初始化操作。理论上,这些生命周期方法的调用时机应当是可预测和一致的。然而,在某些特定场景下,onAppear
可能会出现非预期的、不必要的调用,这不仅可能导致性能开销,更可能引发应用状态的不可控变化。本文将揭示这一容易被忽视的 SwiftUI 行为陷阱,并提供临时的应对策略。
两个问题
近期,苹果开发者论坛接连出现了两个帖子,均反映了相同的症状:onAppear
被异常调用。
以下是简化后的 问题一 代码:
struct NextLevelView: View {
@State private var isLogin: Bool = false
var body: some View {
if !isLogin {
Button("Login") {
isLogin.toggle()
}
} else {
NavigationStack {
NavigationLink("Next Level") {
Button("Logout") {
isLogin.toggle()
}
.navigationTitle("Next Level")
}
// 当 isLogin 设置 false 时,onAppear 仍会被调用一次
.onAppear { print("Link onAppear") }
.navigationTitle("Root")
}
// NavigationStack 外面的 onAppear 不会出现调用异常
.onAppear { print("NavigationStack onAppear") }
}
}
}
通过视频可以看出:当 NavigationStack
处于选择分支中时,用户导航到新页面后,调整分支状态(切换到不包含 NavigationStack
的分支),此时 NavigationStack
闭包根视图中的所有 onAppear
(如果有多个)都会被异常调用(最后的 Link onAppear
不应该出现)。
在 问题二 中,NavigationStack
同样处在一个选择分支中:
struct TabViewTest: View {
@State private var isLogin: Bool = false
var body: some View {
if !isLogin {
Button("Login") {
isLogin.toggle()
}
} else {
TabView {
NavigationStack {
Text("AA")
Button("Logout") {
isLogin.toggle()
}
.onAppear { print("onAppear: start 111") }
.navigationTitle("AA")
}
.onAppear { print("onAppear: NavigationStack 111") }
.tabItem { Text("AA") }
NavigationStack {
Text("BB")
Button("Logout") {
isLogin.toggle()
}
.onAppear { print("onAppear: start 222") }
.navigationTitle("BB")
}
.onAppear { print("onAppear: NavigationStack 222") }
.tabItem { Text("AA") }
}
}
}
}
当我们按照以下顺序操作:点击 Login
-> 点击 Tab BB
-> 点击 Logout
,可以看到在切换到不包含 NavigationStack
的分支时,Tab AA
视图中所有 onAppear
都会被异常调用,无论其处在导航容器的内部或外部。
这是 Bug 吗?
基于之前分析 onChange
调用异常 的经验,我首先在 macOS 平台上运行了上述两段代码。结果显示一切正常,并不存在异常调用的情况。由此可见,这确实是一个 Bug。
此外,我在 iOS 上进行了进一步验证。通过将 NavigationStack
替换为 NavigationView
,onAppear
替换为 task
,问题仍然表现出惊人的一致性。从目前的测试来看,这个异常至少可以追溯到 iOS 15 版本。
有规律吗?
目前可以总结出的规律如下:
- 导航容器必须位于某个条件分支中
- 导航容器需要执行某些操作(如进入新的导航页面或创建多个导航容器实例)
- 异常调用发生在切换到不包含导航容器的分支时
这个 Bug 影响大吗?
如果你仅在 onAppear
中对当前视图的局部状态进行调整,尽管会出现重复调用,但通常不会造成实质性影响(可能会有微小的性能开销)。
然而,若在 onAppear
中修改上层视图或全局状态,则可能导致应用状态的不可预期变化。例如:
.onAppear {
glableState.toggle() // 导致在切换到不包含 NavigationStack 的分支后,全局状态仍出现了变化
}
解决方案
onAppear
的调用时机与其所处的容器类型密切相关。例如,在 TabView
中,每个 Tab 切换到前台时,其中的 onAppear
都会被调用。而在某些容器中,onAppear
仅在容器的生命周期中调用一次。
由于开发者会根据当前使用的容器在 onAppear
中执行不同的逻辑操作,因此并不存在一个完全统一的解决方案。
对于问题一,我们可以构建一个在容器生命周期中仅被调用一次的 onAppear
,从而避免多次调用:
struct OnceAppearModifier: ViewModifier {
@State private var called = false
private let action: (() -> Void)?
init(perform action: (() -> Void)? = nil) {
self.action = action
}
func body(content: Content) -> some View {
content
.onAppear {
guard !called, let action else { return }
action()
called = true
}
}
}
extension View {
public func onceAppear(perform action: (() -> Void)? = nil) -> some View {
modifier(OnceAppearModifier(perform: action))
}
}
将 NavigationLink
上的 onAppear
替换为 onceAppear
后,逻辑闭包将不会被重复调用。
对于问题二,为了保留每次切换 Tab
时 onAppear
的调用方式,我们可以创建一个具备绑定值的 onAppear
版本,确保仅在特定状态下执行逻辑闭包:
extension View {
public func onAppear(enable: Binding<Bool>, perform action: (() -> Void)? = nil) -> some View {
return onAppear {
if enable.wrappedValue {
action?()
}
}
}
}
将出现异常的 onAppear
替换为 .onAppear(enable: $isLogin)
即可解决问题。
使用
Binding
类型的原因在于,我们需要在视图刷新时直接访问状态对应的底层值。关于这一机制的更多细节,可以参考 一段因 @State 注入机制所产生的“灵异代码” 一文。
无奈与展望
每次撰写这类技术问题排查文章,内心总不免感到些许无奈。SwiftUI 至今发展,在诸多场景中仍难以保证行为的一致性,确实令人唏嘘。根据最新的使用统计,苹果在 iOS 18 中已有 23% 的二进制文件包含 SwiftUI 代码。或许只有当苹果自身的一方应用大规模遇到这类 SwiftUI 异常时,这些问题才能得到根本性的修复。
我已就此问题向苹果提交了反馈报告( FB16117635 )。