【问题标题】:How to tell SwiftUI views to bind to nested ObservableObjects如何告诉 SwiftUI 视图绑定到嵌套的 ObservableObjects
【发布时间】:2020-02-12 19:41:12
【问题描述】:

我有一个 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 来解决此问题,但我希望尽可能避免这样做。

【问题讨论】:

  • 我也在这样设计我的应用程序。在过去的应用程序开发中,我通常有一个全局 App 对象。有没有人认为这种将超级“App”类作为环境变量的设计将成为标准做法?我也在考虑使用多个 EnvironmentObject,但这很难维护。

标签: ios swift swiftui combine


【解决方案1】:

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

代码结构:

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()
    }
}

【讨论】:

    【解决方案2】:

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

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

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

    【讨论】:

      【解决方案3】:

      请参阅以下帖子以获取解决方案:[arthurhammer.de/2020/03/combine-optional-flatmap][1]。这是在与 $ 出版商联合解决问题。

      假设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 { $0.$annotation }
              .replaceError(with: LCPointAnnotation.emptyAnnotation)
              .assign(to: \.annotationFromFoto, on: self)
              .store(in: &cancellables)
          
          }
       }
      

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

      注意 flatMap 上面的 $annotation,它是一个发布者!

       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)
          }
      

      【讨论】:

        【解决方案4】:

        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 内部的引擎进行相关跟踪。这样您就不必处理簿记了,您的视图也可能会变得相当简单。

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

        【讨论】:

        【解决方案5】:

        @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/

        【讨论】:

        【解决方案6】:

        我是这样做的:

        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.

        【讨论】:

          【解决方案7】:

          嵌套的ObservableObject 模型还不能工作。

          但是,您可以通过手动订阅每个模型来使其工作。 The answer gave a simple example of this.

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

          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()
                }
            }
          }
          

          【讨论】:

            【解决方案8】:

            我最近在我的博客上写了这篇文章:Nested Observable Objects。如果您真的想要 ObservableObjects 的层次结构,该解决方案的要点是创建您自己的顶级 Combine Subject 以符合 ObservableObject protocol,然后将您想要触发更新的任何逻辑封装到命令式代码中更新该主题。

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

            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
                }
            }
            

            然后您可以将顶级类(在本例中为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 类上的属性value 时将更新发送到本地发布者。您可以扩展它以使用 CombineLatest 从多个属性或主题的任意数量的变体发布流。

            但这不是一个“随便做”的解决方案,因为这种模式的逻辑结论是在您扩展视图层次结构之后,您最终会得到订阅的视图的大量样本那个将失效和重绘的发布者,可能会导致过度、全面的重绘和相对较差的更新性能。我建议你看看你是否可以将你的视图重构为特定于一个类,并将它与那个类匹配,以保持 SwiftUI 视图失效的“爆炸半径”最小化。

            【讨论】:

            • 最后(以及博文中)的建议绝对是金句。我正在陷入链接objectWillChange 调用的兔子洞,但我只需要重构一个视图即可获取@ObservedObject...谢谢@heckj :)
            【解决方案9】:

            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()
                    }
                } 
            }
            

            【讨论】:

              【解决方案10】:

              嵌套模型在 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()
                      }
                  } 
              }
              

              基本上您的AppModel 会从SubModel 捕获事件并将其进一步发送到View

              编辑:

              如果您不需要 SubModel 上课,那么您也可以尝试这样的事情:

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

              【讨论】:

              • 谢谢,这很有帮助!当你说“嵌套模型在 SwiftUI 中还不能工作”时,你确定它们是计划好的吗?
              • 我不确定,但我认为它应该可以工作,我也在我的项目中使用了类似的东西,所以如果我能找到更好的方法,我会进行编辑
              • @SorinLica Submodel 应该是 ObservableObject 类型吗?
              • 我想补充一点,AnyCancellable 类型是在组合框架中定义的。我猜 99% 的人都知道这一点,我不得不用谷歌搜索...
              • 在我的情况下,我有一个带有活动更改的 ObservableObject 列表,如果我会在嵌套对象的更改上下沉,当我只需要刷新一行时,这将触发重新加载整个列表。所以我会冻结
              【解决方案11】:

              我有一个我认为比订阅子(视图)模型更优雅的解决方案。这很奇怪,我没有解释它为什么起作用。

              解决方案

              定义一个继承自ObservableObject的基类,并定义一个简单调用objectWillChange.send()的方法notifyWillChange()。然后任何派生类将覆盖notifyWillChange() 并调用父类的notifyWillChange() 方法。 需要在方法中包装objectWillChange.send(),否则对@Published 属性的更改不会导致任何Views 更新。它可能与如何检测@Published 更改有关。我相信 SwiftUI/Combine 在后台使用反射......

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

              • count 包含在一个方法调用中,该方法调用在计数器递增之前调用notifyWillChange()。这是传播更改所必需的。
              • AppModel 包含另外一个@Published 属性title,用于导航栏的标题。这表明@Published 同时适用于父对象和子对象(在下面的示例中,模型初始化后 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)
                      }
                  }
              }
              

              【讨论】:

                【解决方案12】:

                所有三个 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 提供的组合解决方案。

                【讨论】:

                  【解决方案13】:

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

                  【讨论】:

                  • 你能澄清一下你目前使用的 xcode 版本吗?我目前有 Xcode 11.0 并遇到此问题。我在升级到 11.1 时遇到了麻烦,它不会像 80% 那样完成。
                  猜你喜欢
                  • 2020-02-14
                  • 2018-01-31
                  • 2019-12-19
                  • 1970-01-01
                  • 1970-01-01
                  • 1970-01-01
                  • 1970-01-01
                  • 2014-07-31
                  • 2010-12-09
                  相关资源
                  最近更新 更多