理有意为之还是技术缺陷?SwiftUI 多层导航中的 onChange 异常

发表于

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

SwiftUI 提供的 onChange 修饰器,使开发者能够在视图中监听特定值的变化,并在值发生改变时执行相应的操作。直觉上,只要某个视图位于当前可见的视图树分支中( 活动中 ),在观察的值发生变化时,对应的闭包就应该被触发。但在某些特定的导航场景下,onChange 修饰器似乎会“选择性失聪”,明明观察的值发生了变化,却诡异地保持沉默。这究竟是苹果精心设计的特性,还是一个隐藏已久的代码缺陷?本文将揭示这一现象并对开发者给予必要的提醒。

问题

读者 Eric0625 在我的博客上 留言,分享了一个关于 onChange 异常行为的奇怪现象。

在他的场景中,RootViewMiddleViewTopView 分别使用 onChange 监听同一个跨视图的状态值,并且状态值可以在 TopView 中被修改。

当导航层级为 NavigationStack -> RootView -> TopView 时,RootViewTopViewonChange 闭包能够正常触发。然而,当导航层级变更为 NavigationStack -> RootView -> MiddleView -> TopView 时,MiddleView 中的 onChange 却未被触发。

初见此留言,我下意识地认为这可能是代码实现上的问题。按照我对 SwiftUI 的理解,无论导航堆栈有多少层级,每个层级的视图都应该处于活动状态,理应能够响应状态的变化。因此,状态发生改变时,每个视图中对应的 onChange 闭包都应该被调用。

然而,在详细运行和分析了他的代码后,我确认了这一非预期的现象确实存在。

问题究竟出在何处?

找寻线索

我首先怀疑这是否是某个特定版本的 Bug。通过在 Xcode 15 和 Xcode 16 下,并跨越 iOS 15 至 iOS 18 系统进行全面测试,我发现这是一个长期且普遍存在的现象。

随后,我开始排查是否由导航方式造成。在测试了传统 NavigationView 和多种适用于 NavigationStack 的编程式导航后,可以确认无论采用哪种导航方式,这一异常现象都始终存在。

最后,我将目光转向状态的声明和响应方式。无论是改变状态的逐级传递方式,还是将 ObservableObject 替换为使用 Observation 的形式,问题仍然无法解决。

通过系统性的排查,我们可以确定系统版本、导航逻辑、状态声明方式等因素并非根本原因。在对比两个场景后,唯一的不同点在于添加了一个 MiddleView,导致导航层级的数量发生了变化。问题或许就出在这里。

为了便于深入测试和复现,我对测试代码进行了重新梳理,使其更加清晰和易于操作:

Swift
class ViewModel: ObservableObject {
    @Published var count = 0
}

struct Root: View {
    @StateObject private var viewModel = ViewModel()
    var body: some View {
        NavigationStack {
            SubView(level: 1)
                .navigationDestination(for: Int.self) { value in
                    SubView(level: value)
                }
        }
        .environmentObject(viewModel)
    }
}

struct SubView: View {
    let level: Int
    @EnvironmentObject var viewModel: ViewModel
    var body: some View {
        VStack {
            // 通过打印 update,验证 viewModel.count 变化时视图的响应性
            let _ = print("level \(level) Updated")
            Button("Count++") {
                viewModel.count += 1
            }
            NavigationLink(value: level + 1) {
                Text("Goto Next Level")
            }
        }
        .buttonStyle(.borderedProminent)
        .navigationTitle(Text("Level: \(level)"))
        // 在每个层级通过 onChange 响应 viewModel.count 的变化
        .onChange(of: viewModel.count){ _ in
            print("Level *\(level)* onChanged")
        }
    }
}

这段代码允许我们轻松构建任意导航层级。通过点击 Goto Next Level,可以进入新的层级并显示当前层级数;点击 Count++ 则会使 viewModel.count 递增,直觉上应触发所有视图中的 onChange 闭包。

onChange 的规律

运行上述代码,当导航层级处于一层或两层时,无论是 update 还是 onChange,都如我们预期般被准确触发。

然而,当导航层级扩展到两层以上(3+ 层)时,情况发生了显著变化。在导航堆栈中,所有层级的 update 都会被触发,但只有最底层和最顶层的视图中的 onChange 闭包才会被调用。

若将”只有最顶层和最底层视图的 onChange 被触发”视为一个规律,我们会发现,这一模式同样适用于一层或两层的导航堆栈场景。

task(id:) 与 onReceive

在众多开发场景中,开发者常常将 task(id:) 作为 onAppear + onChange 组合的替代方案。将代码中的 onChange 替换为 task(id:) 后,一个新的异常现象随即浮现。

Swift
.task(id: viewModel.count){
    print("Level *\(level)* onChanged in task(id:)")
}

在导航堆栈中,当观察值发生变化时,仅有最顶层视图的 task(id:) 会被触发。

考虑到我们目前使用 ObservableObject 来声明状态,那么使用 onReceive 来响应状态变化会呈现怎样的情况?将 task(id:) 替换为 onReceive

Swift
.onReceive(viewModel.$count){ _ in
    print("Level *\(level)* receive count notification")
}

onChangetask(id:) 不同,导航堆栈中所有层级的视图的 onReceive 都能准确响应每个变化产生的通知,这一点完全符合我们的预期。

有意为之还是 Bug?

至此,我们可以从上面的现象中总结出导航堆栈中不同层级视图的响应规律:

  • 只有最底层和最顶层视图的 onChange 会被触发。
  • 在观察值变化时,仅有最顶层视图的 task(id:) 会被触发。
  • 所有层级中的 onReceive 都会被触发。

虽然我们已经理清了这些行为模式,但一个根本性的疑问仍然萦绕心头:这究竟是苹果有意为之的设计(By Design),还是一个长期潜伏的技术缺陷?

当我以为这可能是类似于 SwiftUI 仅保留 ForEach 子视图最顶层的状态 的设计行为时,我在 macOS 15.1 上以 macOS 应用的方式重新测试了上述代码。

在 macOS 中,无论导航堆栈有多少层级,每个层级中的 onChangetask(id:) 都会如预期般被调用。这使得这个异常现象更像是 iOS 系统中长期存在的一个 Bug。

我已将这一现象反馈给苹果(FB15963726)。然而,无论最终调查结果如何,对于开发者而言,在构建多层导航容器时( 尤其在 iOS 系统中 ),必须提前预料并应对 onChangetask(id:) 可能无法触发的情况,以避免陷入由此带来的技术困境。

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