SwiftUI中@Binding、@Published与Observable值传递异常排查
我正在开发一款iPhone应用,在使用@Binding、@ObservedObject和@Published属性时遇到了问题。原本认为实现方式正确,但实际运行仍有异常。减少视图嵌套层级后功能恢复正常,以下是复现问题的最小示例:
import SwiftUI // character class TopLevelParent: ObservableObject { var id: UUID = UUID() @Published var prop: [Child1] = [Child1(), Child1(), Child1()] } class TestState: ObservableObject { @Published var tlp: TopLevelParent = TopLevelParent() } // level class Child1: ObservableObject, Identifiable { var id: UUID = UUID() @Published var prop: [Child2] = [Child2(), Child2()] } // levelskill class Child2: ObservableObject, Identifiable { var id: UUID = UUID() @Published var clicked: Int = 0 @Published var maxClick: Int = 3 @Published var prop: [YoungestChild] = [YoungestChild(), YoungestChild(), YoungestChild(), YoungestChild(), YoungestChild()] } // skill class YoungestChild: ObservableObject, Identifiable { var id: UUID = UUID() @Published var name: String = "name" @Published var hasIncreased: Bool = false } struct TestView: View { @EnvironmentObject var state: TestState var body: some View { VStack { NavigationStack { List { ForEach(state.tlp.prop, id:\.id) { prop in NavigationLink(destination: TestView2(testclass2: prop)) { VStack { TestView5(testclass2: prop) } } } } } } } } struct TestView2: View { @ObservedObject var testclass2: Child1 @State private var activeSkillBonus: UUID? var body: some View { VStack (alignment: .leading) { let testclass3 = testclass2.prop VStack (alignment: .leading) { ForEach(testclass3) { index in TestView3(testclass3: index, activeSkillBonuses: $activeSkillBonus) } } } } } struct TestView3: View { @ObservedObject var testclass3: Child2 @Binding var activeSkillBonuses: UUID? var body: some View { VStack (alignment: .leading) { VStack { Text("Skill Boosts - \(testclass3.clicked)/\(testclass3.maxClick)") Text("\(testclass3.id)") } .foregroundStyle(Color.white) .background(RoundedRectangle(cornerRadius: 20) .foregroundStyle(Color.oxblood)) .onTapGesture{ withAnimation { if activeSkillBonuses == testclass3.id { activeSkillBonuses = nil } else { activeSkillBonuses = testclass3.id } } } if activeSkillBonuses == testclass3.id { VStack { ForEach(testclass3.prop) { index in TestView4(testclass4: index, testclass3: testclass3) } } } } } } struct TestView4: View { @ObservedObject var testclass4: YoungestChild @ObservedObject var testclass3: Child2 var body: some View { HStack { VStack(alignment: .leading) { Text(testclass4.name) } } .foregroundStyle(testclass4.hasIncreased ? Color.red : Color.primary) .background(testclass4.hasIncreased ? Color.green : Color.brown) .onTapGesture { testclass3.objectWillChange.send() if testclass4.hasIncreased { testclass3.clicked -= 1 } else { testclass3.clicked += 1 } testclass4.hasIncreased.toggle() } } } struct TestView5: View { @ObservedObject var testclass2: Child1 var body: some View { VStack { ForEach(testclass2.prop) {testclass3 in Text("Skill Bonus - \(testclass3.clicked)/\(testclass3.maxClick)") } } } } struct TestView_Previews: PreviewProvider { static var previews: some View { TestView() .environmentObject(TestState()) } }
具体场景:TestView作为顶层视图,在NavigationStack中包含多个TestView5;点击TestView5会打开TestView2,其包含多个TestView3;每个TestView3下的TestView4被点击时会更新Child2的数值,该数值可在TestView3中正常更新,但TestView5始终显示初始值(0/3)。
问题原因
核心问题在于**Child1作为ObservableObject,无法感知其内部@Published数组中Child2对象的属性变化**:
@Published对于数组类型,只会在数组本身被替换(比如重新赋值新数组)或数组元素的引用发生变化时,发送objectWillChange通知。- 当
Child2的clicked属性更新时,只有直接观察该Child2的视图(比如TestView3)会收到刷新通知;而TestView5观察的是Child1,Child1没有收到Child2变化的通知,因此不会触发TestView5的刷新。
解决方案
方案1:让Child1监听Child2的状态变化
修改Child1类,添加对其内部Child2对象的订阅,当Child2状态变化时,主动触发Child1的更新通知:
class Child1: ObservableObject, Identifiable { var id: UUID = UUID() @Published var prop: [Child2] = [Child2(), Child2()] private var cancellables = Set<AnyCancellable>() init() { setupChildSubscriptions() } // 监听prop数组的变化,为每个Child2添加状态订阅 private func setupChildSubscriptions() { $prop .sink { [weak self] newChildren in self?.cancellables.removeAll() newChildren.forEach { child in child.objectWillChange .sink { [weak self] _ in self?.objectWillChange.send() } .store(in: &self!.cancellables) } } .store(in: &cancellables) } }
这样只要Child2的状态发生变化,Child1就会发送更新通知,TestView5就能同步刷新显示最新的clicked值。
方案2:改用值类型(Struct)替代引用类型(Class)
SwiftUI对值类型的状态更新天然友好,当Struct的属性变化时,整个实例会被视为新值,自动触发视图刷新。将所有嵌套数据模型改为Struct:
// 替换原有的Class定义 struct Child1: Identifiable { var id: UUID = UUID() var prop: [Child2] = [Child2(), Child2()] } struct Child2: Identifiable { var id: UUID = UUID() var clicked: Int = 0 var maxClick: Int = 3 var prop: [YoungestChild] = Array(repeating: YoungestChild(), count: 5) } struct YoungestChild: Identifiable { var id: UUID = UUID() var name: String = "name" var hasIncreased: Bool = false }
此时需要注意:修改Struct属性时,需要通过@Binding或者在顶层ObservableObject中对数组进行修改(因为值类型是不可变的,需要生成新实例替换原数组元素)。这种方式能简化状态管理,避免嵌套ObservableObject的通知传递问题。
方案3:在TestView5中直接观察Child2(不推荐)
如果只针对当前场景,也可以修改TestView5,让其直接观察每个Child2对象:
struct TestView5: View { @ObservedObject var testclass2: Child1 var body: some View { VStack { ForEach(testclass2.prop) { testclass3 in // 用ObservedObject包装Child2,触发视图刷新 TestView5Child(testclass3: testclass3) } } } } // 新增子视图观察单个Child2 struct TestView5Child: View { @ObservedObject var testclass3: Child2 var body: some View { Text("Skill Bonus - \(testclass3.clicked)/\(testclass3.maxClick)") } }
这种方式虽然能解决问题,但如果层级更深,会导致视图嵌套冗余,不如前两种方案优雅。
内容的提问来源于stack exchange,提问作者phroureo

