【问题标题】:How to trigger automatic SwiftUI Updates with @ObservedObject using MVVMHow to trigger automatic SwiftUI Updates with @ObservedObject using MVVM
【发布时间】:2022-12-28 06:19:54
【问题描述】:

I have a question regarding the combination of SwiftUI and MVVM.

Before we start, I have read some posts discussing whether the combination of SwiftUI and MVVM is necessary. But I don't want to discuss this here, as it has been covered elsewhere. I just want to know if it is possible and, if yes, how. :)

So here comes the code. I tried to add the ViewModel Layer in between the updated Object class that contains a number that should be updated when a button is pressed. The problem is that as soon as I put the ViewModel Layer in between, the UI does not automatically update when the button is pressed.

View:


struct ContentView: View {
    
    @ObservedObject var viewModel = ViewModel()
    @ObservedObject var numberStorage = NumberStorage()
    
    var body: some View {
        VStack {
//            Text("\(viewModel.getNumberObject().number)")
//                .padding()
//            Button("IncreaseNumber") {
//                viewModel.increaseNumber()
//            }
            Text("\(numberStorage.getNumberObject().number)")
                .padding()
            Button("IncreaseNumber") {
                numberStorage.increaseNumber()
            }
        }
    }
}

struct ContentView_Previews: PreviewProvider {
    static var previews: some View {
        ContentView()
    }
}

ViewModel:


class ViewModel: ObservableObject {
    
    @Published var number: NumberStorage
    
    init() {
        self.number = NumberStorage()
    }
    
    func increaseNumber() {
        self.number.increaseNumber()
    }
    
    func getNumberObject() -> NumberObject {
        self.number.getNumberObject()
    }
    
} 

Model:


class NumberStorage:ObservableObject {
    @Published var numberObject: NumberObject
    
    init() {
        numberObject = NumberObject()
    }
    
    public func getNumberObject() -> NumberObject {
        return self.numberObject
    }
    
    public func increaseNumber() {
        self.numberObject.number+=1
    }
}

struct NumberObject: Identifiable {
    let id = UUID()
    var number = 0
} ```

Looking forward to your feedback!

【问题讨论】:

  • This is expected behavior, sink is a possibility but it just makes a mess of the code.
  • Your ViewModel should really publish a "View State", which is a value type, say a struct, possibly composed out of fields which are all primitive types. A view should be able to directly render the View State without the need to transform the values. Publishing an ObservableObject is not appropriate as a View State. In your example, your View Model might publish an array of Strings (or just one String), where a String represents a number, and where the view model is responsible to format the numbers into strings.

标签: swift mvvm swiftui viewmodel observedobject


【解决方案1】:

I think your code is breaking MVVM, as you're exposing to the view a storage model. In MVVM, your ViewModel should hold only two things:

  1. Values that your view should display. These values should be automatically updated using abindingsystem (in your case, Combine)
  2. Events that the view may produce (in your case, a button tap) Having that in mind, your ViewModel shouldwrap,adaptandencapsulateyour model. We don't want model changes to affect the view. This is a clean approach that does that: View:
    
    struct ContentView: View {
        
        @StateObject // When the view creates the object, it must be a state object, or else it'll be recreated every time the view is recreated
        private var viewModel = ViewModel()
        
        var body: some View {
            VStack {
                Text("(viewModel.currentNumber)") // We don't want to use functions here, as that will create a new object , as SwiftUI needs the same reference in order to keep track of changes
                    .padding()
                Button("IncreaseNumber") {
                    viewModel.increaseNumber()
                }
            }
        }
    }
    
    struct ContentView_Previews: PreviewProvider {
        static var previews: some View {
            ContentView()
        }
    }
    

    ViewModel:

    
    class ViewModel: ObservableObject {
        
        @Published
        private(set) var currentNumber: Int = 0 // Private set indicates this should only be mutated by the viewmodel
        private let numberStorage = NumberStorage()
        
        init() {
            numberStorage.currentNumber
                .map { $0.number }
            .assign(to: &$currentNumber) // Here we're binding the current number on the storage to the published var that the view is listening to.`&$` basically assigns it to the publishers address
        }
        
        func increaseNumber() {
            self.numberStorage.increaseNumber()
        }
    }
    

    Model:

    class NumberStorage {
        private let currentNumberSubject = CurrentValueSubject<NumberObject, Never>(NumberObject())
    
        var currentNumber: AnyPublisher<NumberObject, Never> {
            currentNumberSubject.eraseToAnyPublisher()
        }
        
       func increaseNumber() {
           let currentNumber = currentNumberSubject.value.number
           currentNumberSubject.send(.init(number: currentNumber + 1))
        }
    }
    
    
    struct NumberObject: Identifiable { // I'd not use this, just send and int directly
        let id = UUID()
        var number = 0
    }
    

【讨论】:

  • It might be worth mentioning that you have to import Combine in the Model class to access CurrentValueSubject and AnyPublisher
  • I still get some errors when trying to run your code for theModel, most notably: Extraneous argument label 'currentValue:' in call Cannot infer contextual base in reference to member 'init'
  • Oh, my bad, I coded it on Stack Overflow, did't check if it compiles xD. I'm open for and edit @JonasBäumer :)
  • @JonasBäumer edited to make it compile
【解决方案2】:

It's a known problem. Nested observable objects are not supported yet in SwiftUI. I don't think you need ViewModel+Model here since ViewModel seems to be enough.

To make this work you have to trigger objectWillChange of your viewModel manually when objectWillChange of your model is triggered:

class ViewModel: ObservableObject {
    init() {
        number.objectWillChange.sink { [weak self] (_) in
            self?.objectWillChange.send()
        }.store(in: &cancellables)
    }
}

You better listen to only the object you care not the whole observable class if it is not needed.

Plus:

Since instead of injecting, you initialize your viewModel in your view, you better use StateObject instead of ObservedObject. See the reference from Apple docs: Managing model data in your app

【讨论】:

  • This seems bad, as it'll not scale for many models. I think the mindset change provided in my response might be a better solution to this problem
  • As I said in my original post above, I tried to use a simple example that only focuses on the underlying problem. Ofc, here VM + M is not needed, however for bigger projects, I can't see everything done only with VM as this will make the class very clumsy and hard to maintain. Your solution is definitely easier, but maybe @Pastre has a point. I'm still new to Swift so it's hard for me to judge. Anyways, thanks for your help! :)
【解决方案3】:

One way you could handle this is to observe the publishers in your Storage class and send the objectWillChange publisher when it changes. I have done this in personal projects by adding a class that all my view models inherit from which provides a nice interface and handles the Combine stuff like this:

Parent ViewModel

import Combine

class ViewModel: ObservableObject {
    private var cancellables: Set<AnyCancellable> = []

    func publish<T>(on publisher: Published<T>.Publisher) {
        publisher.sink { [weak self] _ in self?.objectWillChange.send() }
            .store(in: &cancellables)
    }
}

Specific ViewModel

class ContentViewModel: ViewModel {
    private let numberStorage = NumberStorage()

    var number: Int { numberStorage.numberObject.number }

    override init() {
        super.init()
        publish(on: numberStorage.$numberObject)
    }

    func increaseNumber() {
        numberStorage.increaseNumber()
    }
}

View

struct ContentView: View {
    @StateObject var viewModel = ContentViewModel()

    var body: some View {
        VStack {
            Text("(viewModel.number)")
                .padding()
            Button("IncreaseNumber") {
                viewModel.increaseNumber()
            }
        }
    }
}

Model/Storage

class NumberStorage:ObservableObject {
    @Published var numberObject: NumberObject

    init() {
        numberObject = NumberObject()
    }

    public func increaseNumber() {
        self.numberObject.number += 1
    }
}

struct NumberObject: Identifiable {
    let id = UUID()
    var number = 0
}

This results in the view re-rendering any time Storage.numberObject changes.

【讨论】:

    猜你喜欢
    • 2022-12-27
    • 2022-12-27
    • 2022-12-02
    • 2022-12-02
    • 2022-12-02
    • 2022-12-28
    • 2022-12-26
    • 2022-12-28
    • 2022-12-02
    相关资源
    最近更新 更多