如何让SwiftUI检测传递给NSViewRepresentable的嵌套@Observable对象的变化?
我之前也踩过一模一样的坑,折腾了好一阵才摸透问题的本质,下面给你拆解清楚问题根源,再给两个实用的解决方案:
问题到底出在哪?
你的Item是@Observable的引用类型,而ContentView里的items是@State修饰的数组(值类型)。当你点击按钮执行items[0].children.append(Item(name: "Baz"))时:
- 被修改的是
items[0]这个对象的内部属性children,Item自己的objectWillChange确实会发送通知,但SwiftUI只会追踪OutlineViewRepresentable的输入参数items的变化。 - 数组是值类型,它的元素是
Item的引用,修改元素内部属性并不会改变数组本身的结构或元素引用,所以SwiftUI判定items没有变化,不会调用updateNSView,outlineView自然不会重新加载数据。
而你测试的“给根数组加元素”能生效,是因为数组的结构真的变了,SwiftUI检测到变化后触发了updateNSView。
解决方案一:在Coordinator中主动监听@Observable对象变化(推荐)
这种方法不需要改动View层的逻辑,直接在负责NSOutlineView逻辑的Coordinator里监听每个Item的内部变化,一旦有更新就主动刷新列表,是最优雅的解决方式。
1. 改造Coordinator,添加监听逻辑
@MainActor class Coordinator: NSObject, NSOutlineViewDelegate, NSOutlineViewDataSource { // 弱引用持有outlineView,避免循环引用 weak var outlineView: NSOutlineView? var items: [Item] = [] { didSet { // 切换新数据时清空旧的监听令牌 observationTokens.removeAll() } } // 保存每个Item的监听令牌,防止重复监听 private var observationTokens: [ObjectIdentifier: ObservationToken] = [] // 对外暴露的监听入口,在updateNSView中调用 func setupItemObservations() { guard let outlineView = outlineView else { return } items.forEach { observeItem($0, outlineView: outlineView) } } // 递归监听单个Item及其所有子Item的变化 private func observeItem(_ item: Item, outlineView: NSOutlineView) { let itemID = ObjectIdentifier(item) // 已经监听过的Item直接跳过 guard observationTokens[itemID] == nil else { return } // 用withObservationTracking追踪我们关心的属性(这里是children和displayText) let token = withObservationTracking { // 访问属性告诉系统我们要监听它的变化 _ = item.children _ = item.displayText } onChange: { [weak self, weak outlineView] in guard let outlineView = outlineView else { return } // 必须在主线程刷新UI Task { @MainActor in outlineView.reloadData() // 重新监听该Item(因为新增了子Item,需要追加监听) self?.observeItem(item, outlineView: outlineView) } } observationTokens[itemID] = token // 递归监听所有子Item item.children.forEach { observeItem($0, outlineView: outlineView) } } // 原有的dataSource和delegate方法保持不变... }
2. 修改OutlineViewRepresentable的updateNSView方法
在更新NSView时,把outlineView传给Coordinator并启动监听:
func updateNSView(_ nsView: MyScrollView, context: Context) { context.coordinator.items = items // 传递outlineView引用给Coordinator context.coordinator.outlineView = nsView.outlineView // 启动监听逻辑 context.coordinator.setupItemObservations() nsView.outlineView.reloadData() }
这样不管是修改根Item的children,还是嵌套子Item的属性,只要Item内部有变化,Coordinator都会收到通知并主动刷新列表。
解决方案二:手动触发SwiftUI的View更新(临时救急)
如果你只是需要快速解决问题,不想动太多代码,可以在修改Item后手动触发items数组的“变化”,让SwiftUI检测到并调用updateNSView:
修改ContentView里的按钮点击逻辑:
Button("Add Child") { items[0].children.append(Item(name: "Baz")) // 手动创建新数组,让SwiftUI认为items发生了变化 items = items.map { $0 } }
这种方法的原理是生成一个和原数组元素引用完全一致的新数组,让SwiftUI判定items有变化。但这属于投机取巧的写法,复杂场景下可能触发不必要的View更新,不推荐长期使用。
补充:为什么修改Equatable/Hashable没用?
你之前尝试重写Equatable和Hashable的实现,但SwiftUI对于数组的更新判断,核心看的是数组的结构和元素引用,而不是元素的Equatable实现(除非你手动在某个逻辑里做==对比),所以那种方法根本解决不了问题。
内容来源于stack exchange

