Why is objectWillChange sink not getting called?

158 views Asked by At

In an effort to maintain the "single source of truth," I have a data model that contains a User object (class, not struct), and a UserManager object that performs user-related actions at the behest of SwiftUI views. All the members of User are basic types and are published.

The UserManager holds a reference to this User object that resides in the central data model. This User object might be changed by a background process, as the result of polling a remote database (for example). However, changes to embedded objects do not trigger notifications that their parent has changed, and thus wouldn't be reflected by a UI looking at the UserManager.

Therefore I wanted the UserManager to subscribe to changes to its User object, and then alert the UI to refresh itself.

So I set up the following, being sure to store a reference to the cancellable pipeline.

class User : Codable, Equatable, ObservableObject
{
    @Published var  ID: String = ""
    @Published var  pw: String = ""
    @Published var  username:  String = ""
    @Published var  firstName: String = ""
    @Published var  lastName: String = ""
...
}

class CentralModel : ObservableObject
{
    @Published var  user: User

    init()
    {
        user = User()
    }
...
}

class UserManager : ObservableObject
{
    var model: CentralModel
    private var pubPipelines: Set<AnyCancellable> = []

    init(withModel: CentralModel)
    {
        self.model = withModel
        model.user.objectWillChange.sink(receiveValue: { newUser in
            print("The new user is \(newUser)") })
            .store(in: &pubPipelines)

        startValidationCheckTimer()
    }

    func startValidationCheckTimer()
    {
        self.validationTimer = Timer.scheduledTimer(timeInterval: 10.0, target: self, selector: #selector(self.timedValidationCheck), userInfo: nil, repeats: true)
    }

    @objc func timedValidationCheck()
    {
        Task { @MainActor in
            user.firstName = UUID().uuidString
            print("Set user's first name to \(user.firstName)")
        }
    }
...
}

In the app I create a timer that randomly changes user.firstName. The objectWillChange sink is never called. Because I'm subscribing directly to the embedded User's publisher, I wouldn't expect that the UserManager's pointer to it needs to change to trigger a publication.

So the first question is why the sink's not receiving anything. I even tried setting up a dummy User in CentralModel and subscribing to that one, but swapping it out entirely by setting it to User() (thus changing CentralModel's pointer to it). Still nothing.

Beyond that, I'm not a huge fan of this type of design, but I don't see how else to have a "single source of truth."

1

There are 1 answers

2
Scott Thompson On

I took your code and put it into a playground. Once I had fixed all the errors the compiler generated it works fine - the sink on objectWillChange is called normally.

One thing I noticed along the way was the you were expecting the objectWillChange publisher to publish a value. objectWillChange is basically AnyPublisher<Void, Never> meaning it will not publish any value (if you put a .print() operator in the pipeline ahead of the sink you will see it publishes the unit value or ()) so receiveValue doesn't provide you with the user the way your code suggests it should.

The next thing that may trip you up is objectWillChange is sent during the property's willSet handler, and the value of the property will not be set to its new value until AFTER the objectWillChange publisher has done its thing. So you can't retrieve the new firstName from the user because the property hasn't changed yet. This playground will print the previous value of the firstName field, but not the new one.

Finally @Published, @ObservableObject and related mechanisms are primarily intended for interactions between a model and a SwiftUI view that is displaying values from that model.

Based on the challenges of that system, it is my opinion that it was ever intended to be used for generic notification of model changes throughout the system. There are any of a number of other Publish/Subscribe systems you could use. In comments you mentioned the NotificationCenter and that certainly is one choice. Combine is another. The Delegate pattern is a third option. Simple closures (callbacks) is also a choice. As others noted the new macro-based @Observable is a better step toward a generic mechanism, but as mentioned in comments - it's new :-(.

At any rate, here is a playground of your code showing something close to what you are trying to do given the caveat's above:

import UIKit
import Combine


class User : ObservableObject
{
    @Published var  firstName: String = ""
}

class CentralModel : ObservableObject
{
    @Published var  user: User

    init() {
        user = User()
    }
}

class UserManager : ObservableObject
{
    var model: CentralModel
    private var pubPipelines: Set<AnyCancellable> = []
    var validationTimer: Timer?

    init(withModel: CentralModel)
    {
      self.model = withModel

      model.user.objectWillChange
        .sink { print("The user's old first name is \(self.model.user.firstName)") }
        .store(in: &pubPipelines)
    }

    func startValidationCheckTimer()
    {
        self.validationTimer = Timer.scheduledTimer(timeInterval: 1.0, target: self, selector: #selector(self.timedValidationCheck), userInfo: nil, repeats: true)
    }

    @objc func timedValidationCheck()
    {
        Task { @MainActor in
          model.user.firstName = UUID().uuidString
          print("Set user's first name to \(model.user.firstName)")
        }
    }
}


let model = CentralModel()
let manager = UserManager(withModel: model)
manager.startValidationCheckTimer()