如何告诉 SwiftUI 视图绑定到嵌套的 ObservableObjects

Posted

技术标签:

【中文标题】如何告诉 SwiftUI 视图绑定到嵌套的 ObservableObjects【英文标题】:How to tell SwiftUI views to bind to nested 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,但这很难维护。 【参考方案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/

【讨论】:

这个页面的答案是黄金。谢谢你。它不仅解释了问题,而且比整个传递 objectWillChange 上游地狱更优雅,如前所述,这将导致许多不必要的 UI 更新。 rhonabwy.com/2021/02/13/nested-observable-objects-in-swiftui 这可能是“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/

【讨论】:

这个页面的答案是黄金。谢谢你。它不仅解释了问题,而且比整个传递 objectWillChange 上游地狱更优雅,如前所述,这将导致许多不必要的 UI 更新。 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% 那样完成。

以上是关于如何告诉 SwiftUI 视图绑定到嵌套的 ObservableObjects的主要内容,如果未能解决你的问题,请参考以下文章

如何在 SwiftUI 的视图控制器中实现数据绑定?

如何将 SwiftUI 视图绑定到数组中元素的属性

SwiftUI如何让绑定到同一个状态的多个TextField呈现出不同输入行为

SwiftUI如何让绑定到同一个状态的多个TextField呈现出不同输入行为

SwiftUI 绑定到父视图重新渲染子视图

在视图外触发时如何绑定 SwiftUI.Alert 的呈现?