SwiftUI onAppear 异常调用的陷阱与应对策略

发表于

为您每周带来有关 Swift 和 SwiftUI 的精选资讯!

onAppear 是 SwiftUI 中极其关键的生命周期方法,用于在视图呈现时注入关键逻辑。由于视图实例可能会频繁地被创建和重建,开发者通常会选择在这些方法中准备数据、执行初始化操作。理论上,这些生命周期方法的调用时机应当是可预测和一致的。然而,在某些特定场景下,onAppear 可能会出现非预期的、不必要的调用,这不仅可能导致性能开销,更可能引发应用状态的不可控变化。本文将揭示这一容易被忽视的 SwiftUI 行为陷阱,并提供临时的应对策略。

两个问题

近期,苹果开发者论坛接连出现了两个帖子,均反映了相同的症状:onAppear 被异常调用。

以下是简化后的 问题一 代码:

Swift
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 同样处在一个选择分支中:

Swift
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 替换为 NavigationViewonAppear 替换为 task,问题仍然表现出惊人的一致性。从目前的测试来看,这个异常至少可以追溯到 iOS 15 版本。

有规律吗?

目前可以总结出的规律如下:

  • 导航容器必须位于某个条件分支中
  • 导航容器需要执行某些操作(如进入新的导航页面或创建多个导航容器实例)
  • 异常调用发生在切换到不包含导航容器的分支时

这个 Bug 影响大吗?

如果你仅在 onAppear 中对当前视图的局部状态进行调整,尽管会出现重复调用,但通常不会造成实质性影响(可能会有微小的性能开销)。

然而,若在 onAppear 中修改上层视图或全局状态,则可能导致应用状态的不可预期变化。例如:

Swift
.onAppear {
  glableState.toggle() // 导致在切换到不包含 NavigationStack 的分支后,全局状态仍出现了变化
}

解决方案

onAppear 的调用时机与其所处的容器类型密切相关。例如,在 TabView 中,每个 Tab 切换到前台时,其中的 onAppear 都会被调用。而在某些容器中,onAppear 仅在容器的生命周期中调用一次。

由于开发者会根据当前使用的容器在 onAppear 中执行不同的逻辑操作,因此并不存在一个完全统一的解决方案。

对于问题一,我们可以构建一个在容器生命周期中仅被调用一次的 onAppear,从而避免多次调用:

Swift
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 后,逻辑闭包将不会被重复调用。

对于问题二,为了保留每次切换 TabonAppear 的调用方式,我们可以创建一个具备绑定值的 onAppear 版本,确保仅在特定状态下执行逻辑闭包:

Swift
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 )。

每周一晚,与全球开发者同步,掌握 Swift & SwiftUI 最新动向
可随时退订,干净无 spam