如何告诉 SwiftUI 视图绑定到嵌套的 Observable 对象

How to tell SwiftUI views to bind to nested ObservableObjects

我有一个 SwiftUI 视图,它接收一个名为 appModel 的 EnvironmentObject。然后它在其 body 方法中读取值 appModel.submodel.count。我希望这会将我的视图绑定到 submodel 上的 属性 count,以便它在 属性 更新时重新呈现,但这似乎没有发生。

这是一个错误吗?如果没有,在 SwiftUI 中将视图绑定到环境对象的嵌套属性的惯用方法是什么?

具体来说,我的模型是这样的...

class Submodel: ObservableObject {
  @Published var count = 0
}

class AppModel: ObservableObject {
  @Published var submodel: Submodel = Submodel()
}

我的观点是这样的...

struct ContentView: View {
  @EnvironmentObject var appModel: AppModel

  var body: some View {
    Text("Count: \(appModel.submodel.count)")
      .onTapGesture {
        self.appModel.submodel.count += 1
      }
  }
}

当我 运行 应用程序并点击标签时,count 属性 确实增加了,但标签没有更新。

我可以通过将 appModel.submodel 作为 属性 传递给 ContentView 来解决这个问题,但我想尽可能避免这样做。

嵌套模型在 SwiftUI 中尚不可用,但您可以这样做

class SubModel: ObservableObject {
    @Published var count = 0
}

class AppModel: ObservableObject {
    @Published var submodel: SubModel = SubModel()
    
    var anyCancellable: AnyCancellable? = nil
    
    init() {
        anyCancellable = submodel.objectWillChange.sink { [weak self] (_) in
            self?.objectWillChange.send()
        }
    } 
}

基本上,您的 AppModelSubModel 捕获事件并将其进一步发送到 View

编辑:

如果您不需要 SubModel 成为 class,那么您也可以尝试这样的操作:

struct SubModel{
    var count = 0
}

class AppModel: ObservableObject {
    @Published var submodel: SubModel = SubModel()
}

看起来像错误。当我将 xcode 更新到最新版本时,它在绑定到嵌套的 Observable 对象时可以正常工作

所有三个 ViewModel 都可以通信和更新

// First ViewModel
class FirstViewModel: ObservableObject {
var facadeViewModel: FacadeViewModels

facadeViewModel.firstViewModelUpdateSecondViewModel()
}

// Second ViewModel
class SecondViewModel: ObservableObject {

}

// FacadeViewModels Combine Both 

import Combine // so you can update thru nested Observable Objects

class FacadeViewModels: ObservableObject { 
lazy var firstViewModel: FirstViewModel = FirstViewModel(facadeViewModel: self)
  @Published var secondViewModel = secondViewModel()
}

var anyCancellable = Set<AnyCancellable>()

init() {
firstViewModel.objectWillChange.sink {
            self.objectWillChange.send()
        }.store(in: &anyCancellable)

secondViewModel.objectWillChange.sink {
            self.objectWillChange.send()
        }.store(in: &anyCancellable)
}

func firstViewModelUpdateSecondViewModel() {
     //Change something on secondViewModel
secondViewModel
}

感谢 Sorin 提供 Combine 解决方案。

我有一个解决方案,我认为它比订阅 child(查看)模型更优雅。这很奇怪,我不知道它为什么起作用。

解决方案

定义一个继承自 ObservableObject 的基础 class,并定义一个仅调用 objectWillChange.send() 的方法 notifyWillChange()。任何派生的 class 然后覆盖 notifyWillChange() 并调用 parent 的 notifyWillChange() 方法。 需要在方法中包装 objectWillChange.send(),否则对 @Published 属性的更改不会导致任何 View 更新。这可能与检测到 @Published 更改的方式有关。我相信 SwiftUI/Combine 在引擎盖下使用反射...

我对 OP 的代码做了一些细微的补充:

  • count 包装在一个方法调用中,该方法调用在计数器递增之前调用 notifyWillChange()。这是传播更改所必需的。
  • AppModel又包含一个@Published属性、title,用于导航栏的标题。这表明 @Published 适用于 parent object 和 child(在下面的示例中,模型初始化后 2 秒更新)。

代码

基础模型

class BaseViewModel: ObservableObject {
    func notifyWillUpdate() {
        objectWillChange.send()
    }
}

型号

class Submodel: BaseViewModel {
    @Published var count = 0
}


class AppModel: BaseViewModel {
    @Published var title: String = "Hello"
    @Published var submodel: Submodel = Submodel()

    override init() {
        super.init()
        DispatchQueue.main.asyncAfter(deadline: .now() + 2) { [weak self] in
            guard let self = self else { return }
            self.notifyWillChange() // XXX: objectWillChange.send() doesn't work!
            self.title = "Hello, World"
        }
    }

    func increment() {
        notifyWillChange() // XXX: objectWillChange.send() doesn't work!
        submodel.count += 1
    }

    override func notifyWillChange() {
        super.notifyWillChange()
        objectWillChange.send()
    }
}

风景

struct ContentView: View {
    @EnvironmentObject var appModel: AppModel
    var body: some View {
        NavigationView {
            Text("Count: \(appModel.submodel.count)")
                .onTapGesture {
                    self.appModel.increment()
            }.navigationBarTitle(appModel.title)
        }
    }
}

AppModel 中的 var 子模型不需要 属性 包装器 @Published。 @Published 的目的是发出新值和 objectWillChange。 但是变量永远不会改变,只会启动一次。

订阅者 anyCancellable 和 ObservableObject-protocol 通过 sink-objectWillChange 构造将子模型中的更改传播到视图,并导致视图重绘。

class SubModel: ObservableObject {
    @Published var count = 0
}

class AppModel: ObservableObject {
    let submodel = SubModel()
    
    var anyCancellable: AnyCancellable? = nil
    
    init() {
        anyCancellable = submodel.objectWillChange.sink { [weak self] (_) in
            self?.objectWillChange.send()
        }
    } 
}

我最近在我的博客上写道:Nested Observable Objects. The gist of the solution, if you really want a hierarchy of ObservableObjects, is to create your own top-level Combine Subject to conform to the ObservableObject protocol,然后将您想要触发更新的任何逻辑封装到更新该主题的命令式代码中。

例如,如果您有两个“嵌套”class,例如

class MainThing : ObservableObject {
    @Published var element : SomeElement
    init(element : SomeElement) {
        self.element = element
    }
}
class SomeElement : ObservableObject {
    @Published var value : String
    init(value : String) {
        self.value = value
    }
}

然后您可以将顶级 class(在本例中为 MainThing)扩展为:

class MainThing : ObservableObject {
    @Published var element : SomeElement
    var cancellable : AnyCancellable?
    init(element : SomeElement) {
        self.element = element
        self.cancellable = self.element.$value.sink(
            receiveValue: { [weak self] _ in
                self?.objectWillChange.send()
            }
        )
    }
}

从嵌入式 ObservableObject 抓取一个发布者,并在 SomeElement class 上的 属性 value 是时向本地发布者发送更新修改的。您可以扩展它以使用 CombineLatest 从多个属性或主题的任意数量的变体发布流。

虽然这不是一个“只管去做”的解决方案,因为这种模式的逻辑结论是在您扩展了视图的层次结构之后,您最终可能会得到一个视图的巨大样本订阅了那个会失效和重绘的发布者,可能会导致过度的、彻底的重绘和相对较差的更新性能。我建议您看看是否可以将您的视图重构为特定于 class,并将其与 class 匹配,以将 SwiftUI 视图失效的“爆炸半径”降至最低。

嵌套 ObservableObject 模型尚不可用。

但是,您可以通过手动订阅每个模型来使其工作。 .

我想补充一点,您可以通过扩展使这个手动过程更加精简和可读:

class Submodel: ObservableObject {
  @Published var count = 0
}

class AppModel: ObservableObject {
  @Published var submodel = Submodel()
  @Published var submodel2 = Submodel2() // the code for this is not defined and is for example only
  private var cancellables: Set<AnyCancellable> = []

  init() {
    // subscribe to changes in `Submodel`
    submodel
      .subscribe(self)
      .store(in: &cancellables)

    // you can also subscribe to other models easily (this solution scales well):
    submodel2
      .subscribe(self)
      .store(in: &cancellables)
  }
}

这是扩展名:

extension ObservableObject where Self.ObjectWillChangePublisher == ObservableObjectPublisher  {

  func subscribe<T: ObservableObject>(
    _ observableObject: T
  ) -> AnyCancellable where T.ObjectWillChangePublisher == ObservableObjectPublisher {
    return objectWillChange
      // Publishing changes from background threads is not allowed.
      .receive(on: DispatchQueue.main)
      .sink { [weak observableObject] (_) in
        observableObject?.objectWillChange.send()
      }
  }
}

我是这样做的:

import Combine

extension ObservableObject {
    func propagateWeakly<InputObservableObject>(
        to inputObservableObject: InputObservableObject
    ) -> AnyCancellable where
        InputObservableObject: ObservableObject,
        InputObservableObject.ObjectWillChangePublisher == ObservableObjectPublisher
    {
        objectWillChange.propagateWeakly(to: inputObservableObject)
    }
}

extension Publisher where Failure == Never {
    public func propagateWeakly<InputObservableObject>(
        to inputObservableObject: InputObservableObject
    ) -> AnyCancellable where
        InputObservableObject: ObservableObject,
        InputObservableObject.ObjectWillChangePublisher == ObservableObjectPublisher
    {
        sink { [weak inputObservableObject] _ in
            inputObservableObject?.objectWillChange.send()
        }
    }
}

所以调用方:

class TrackViewModel {
    private let playbackViewModel: PlaybackViewModel
    
    private var propagation: Any?
    
    init(playbackViewModel: PlaybackViewModel) {
        self.playbackViewModel = playbackViewModel
        
        propagation = playbackViewModel.propagateWeakly(to: self)
    }
    
    ...
}

Here's a gist.

@Published 不是为引用类型设计的,所以将它添加到 AppModel 属性 上是一个编程错误,即使编译器或运行时没有抱怨。本来直观的是像下面这样添加 @ObservedObject 但遗憾的是这默默地什么都不做:

class AppModel: ObservableObject {
    @ObservedObject var submodel: SubModel = SubModel()
}

我不确定禁止嵌套 ObservableObjects 是 SwiftUI 有意为之还是将来要填补的空白。按照其他答案中的建议连接父对象和子对象非常混乱且难以维护。 SwiftUI 的想法似乎是将视图拆分成更小的视图并将子对象传递给子视图:

struct ContentView: View {
    @EnvironmentObject var appModel: AppModel

    var body: some View {
        SubView(model: appModel.submodel)
    }
}

struct SubView: View {
    @ObservedObject var model: SubModel

    var body: some View {
        Text("Count: \(model.count)")
            .onTapGesture {
                model.count += 1
            }
    }
}

class SubModel: ObservableObject {
    @Published var count = 0
}

class AppModel: ObservableObject {
    var submodel: SubModel = SubModel()
}

子模型突变实际上在传递到子视图时传播!

但是,没有什么可以阻止另一个开发人员从父视图调用 appModel.submodel.count,这很烦人,没有编译器警告,甚至没有一些 Swift 强制不这样做的方法。

来源:https://rhonabwy.com/2021/02/13/nested-observable-objects-in-swiftui/

Sorin Lica 的解决方案可以解决问题,但是在处理复杂视图时会导致代码异味。

似乎更好的建议是仔细研究您的观点,并修改它们以提出更多、更有针对性的观点。构建您的视图,以便每个视图显示对象结构的单个级别,将视图与符合 ObservableObject 的 类 相匹配。在上述情况下,您可以创建一个视图来显示 Submodel(甚至多个视图),以显示您想要显示的 属性。将 属性 元素传递给该视图,让它为您跟踪发布者链。

struct SubView: View {
  @ObservableObject var submodel: Submodel

  var body: some View {
      Text("Count: \(submodel.count)")
      .onTapGesture {
        self.submodel.count += 1
      }
  }
}

struct ContentView: View {
  @EnvironmentObject var appModel: AppModel

  var body: some View {
    SubView(submodel: appModel.submodel)
  }
}

此模式意味着制作更多、更小和集中的视图,并让 SwiftUI 内部的引擎进行相关跟踪。这样您就不必处理簿记问题,您的视图也可能会变得相当简单。

您可以在 post 中查看更多详细信息:https://rhonabwy.com/2021/02/13/nested-observable-objects-in-swiftui/

请参阅以下 post 以获取解决方案:[arthurhammer.de/2020/03/combine-optional-flatmap][1]。这是与 $ 发布者以 Combine-Way 的方式解决问题。

假设 class Foto 有一个注释结构和注释发布者,它们发布一个注释结构。在 Foto.sample(orientation: .Portrait) 中,注释结构通过注释发布者异步“加载”。普通香草组合....但要将其放入 View 和 ViewModel,请使用:

class DataController: ObservableObject {
    @Published var foto: Foto
    @Published var annotation: LCPointAnnotation
    @Published var annotationFromFoto: LCPointAnnotation

    private var cancellables: Set<AnyCancellable> = []

        
    init() {
      self.foto = Foto.sample(orientation: .Portrait)
      self.annotation = LCPointAnnotation()
      self.annotationFromFoto = LCPointAnnotation()
    
      self.foto.annotationPublisher
        .replaceError(with: LCPointAnnotation.emptyAnnotation)
        .assign(to: \.annotation, on: self)
        .store(in: &cancellables)
    
      $foto
        .flatMap { [=10=].$annotation }
        .replaceError(with: LCPointAnnotation.emptyAnnotation)
        .assign(to: \.annotationFromFoto, on: self)
        .store(in: &cancellables)
    
    }
 }

注:[1]:https://arthurhammer.de/2020/03/combine-optional-flatmap/

注意flatMap中上面的$annotation,是publisher!

 public class Foto: ObservableObject, FotoProperties, FotoPublishers {
   /// use class not struct to update asnyc properties!
   /// Source image data
   @Published public var data: Data
   @Published public var annotation = LCPointAnnotation.defaultAnnotation
   ......
   public init(data: Data)  {
      guard let _ = UIImage(data: data),
            let _ = CIImage(data: data) else {
           fatalError("Foto - init(data) - invalid Data to generate          CIImage or UIImage")
       }
      self.data = data
      self.annotationPublisher
        .replaceError(with: LCPointAnnotation.emptyAnnotation)
        .sink {resultAnnotation in
            self.annotation = resultAnnotation
            print("Foto - init annotation = \(self.annotation)")
        }
        .store(in: &cancellables)
    }

如果您需要嵌套可观察对象,这是我能找到的最好的方法。

class ChildModel: ObservableObject {
    
    @Published
    var count = 0
    
}

class ParentModel: ObservableObject {
    
    @Published
    private var childWillChange: Void = ()
    
    let child = ChildModel()
    
    init() {
        child.objectWillChange.assign(to: &$childWillChange)
    }
    
}

您无需订阅子对象的 objectWillChange 发布者并解雇父对象的发布者,而是将值分配给已发布的 属性 并自动触发父对象的 objectWillChange。

您可以在顶层视图中创建一个变量,该变量等同于顶层视图中的函数或已发布的变量 class。然后传递它并将其绑定到每个子视图。如果它在任何子视图中发生变化,那么顶视图将被更新。

代码结构:

struct Expense : Identifiable {
    var id = UUID()
    var name: String
    var type: String
    var cost: Double
    var isDeletable: Bool
}

class Expenses: ObservableObject{ 
    @Published var name: String
    @Published var items: [Expense] 

    init() {
        name = "John Smith"
        items = [
            Expense(name: "Lunch", type: "Business", cost: 25.47, isDeletable: true),
            Expense(name: "Taxi", type: "Business", cost: 17.0, isDeletable: true),
            Expense(name: "Sports Tickets", type: "Personal", cost: 75.0, isDeletable: false)
        ]
    }
    
    func totalExpenses() -> Double { }      
}

class ExpenseTracker: ObservableObject {
    @Published var name: String
    @Published var expenses: Expenses
    
    init() {
        name = "My name"
        expenses = Expenses()
    }    

    func getTotalExpenses() -> Double { }
}

观看次数:

struct MainView: View {
    @ObservedObject var myTracker: ExpenseTracker
    @State var totalExpenses: Double = 0.0
    
    var body: some View {
        NavigationView {
            Form {
                Section (header: Text("Main")) {
                    HStack {
                        Text("name:")
                        Spacer()
                        TextField("", text: $myTracker.name)
                            .multilineTextAlignment(.trailing)
                            .keyboardType(.default)
                    }                         
                    NavigationLink(destination: ContentView(myExpenses: myTracker.expenses, totalExpenses: $totalExpenses),
                                   label: {
                                       Text("View Expenses")
                                   })
                }                
                Section (header: Text("Results")) {
                    }
                    HStack {
                        Text("Total Expenses")
                        Spacer()
                        Text("\(totalExpenses, specifier: "%.2f")")
                    }
                }
            }
            .navigationTitle("My Expense Tracker")
            .font(.subheadline)
        }      
        .onAppear{
            totalExpenses = myTracker.getTotalExpenses()
        }
    }
}

struct ContentView: View {
    @ObservedObject var myExpenses:Expenses
    @Binding var totalExpenses: Double
    @State var selectedExpenseItem:Expense? = nil
    
    var body: some View {
        NavigationView{
            Form {
                List {
                    ForEach(myExpenses.items) { item in
                        HStack {
                            Text("\(item.name)")
                            Spacer()
                            Button(action: {
                                self.selectedExpenseItem = item
                            } ) {
                                Text("View")
                            }
                        }
                        .deleteDisabled(item.isDeletable)
                    }
                    .onDelete(perform: removeItem)
                }
                HStack {
                    Text("Total Expenses:")
                    Spacer()
                    Text("\(myExpenses.totalExpenses(), specifier: "%.2f")")
                }
            }
            .navigationTitle("Expenses")
            .toolbar {
                Button {
                    let newExpense = Expense(name: "Enter name", type: "Expense item", cost: 10.00, isDeletable: false)
                    self.myExpenses.items.append(newExpense)
                    self.totalExpenses = myExpenses.totalExpenses()
                } label: {
                    Image(systemName: "plus")
                }
            }
            }
        .fullScreenCover(item: $selectedExpenseItem) { myItem in
            ItemDetailView(item: myItem, myExpenses: myExpenses, totalExpenses: $totalExpenses)
        }
    }
    func removeItem(at offsets: IndexSet){
        self.myExpenses.items.remove(atOffsets: offsets)
        self.totalExpenses = myExpenses.totalExpenses()
    }
}

我喜欢 . Based upon that I've decided to implement a custom Property Wrapper (following this amazing article) 的名为 NestedObservableObject 的解决方案,使该解决方案对开发人员更友好。

这允许以下列方式编写您的模型

class Submodel: ObservableObject {
  @Published var count = 0
}

class AppModel: ObservableObject {
  @NestedObservableObject var submodel: Submodel = Submodel()
}

属性 包装器实现

@propertyWrapper
struct NestedObservableObject<Value : ObservableObject> {
    
    static subscript<T: ObservableObject>(
        _enclosingInstance instance: T,
        wrapped wrappedKeyPath: ReferenceWritableKeyPath<T, Value>,
        storage storageKeyPath: ReferenceWritableKeyPath<T, Self>
    ) -> Value {
        
        get {
            if instance[keyPath: storageKeyPath].cancellable == nil, let publisher = instance.objectWillChange as? ObservableObjectPublisher   {
                instance[keyPath: storageKeyPath].cancellable =
                    instance[keyPath: storageKeyPath].storage.objectWillChange.sink { _ in
                            publisher.send()
                    }
            }
            
            return instance[keyPath: storageKeyPath].storage
         }
         set {
             
             if let cancellable = instance[keyPath: storageKeyPath].cancellable {
                 cancellable.cancel()
             }
             if let publisher = instance.objectWillChange as? ObservableObjectPublisher   {
                 instance[keyPath: storageKeyPath].cancellable =
                     newValue.objectWillChange.sink { _ in
                             publisher.send()
                     }
             }
             instance[keyPath: storageKeyPath].storage = newValue
         }
    }
    
    @available(*, unavailable,
        message: "This property wrapper can only be applied to classes"
    )
    var wrappedValue: Value {
        get { fatalError() }
        set { fatalError() }
    }
    
    private var cancellable: AnyCancellable?
    private var storage: Value

    init(wrappedValue: Value) {
        storage = wrappedValue
    }
}

我已经在 gist

上发布了代码