Question about valid uses of `ObservableObject`'s synthesized `objectWillChange`

I'm seeing some strange behavior around the synthesized objectWillChange when trying to access it manually, and I'm curious if I'm seeing a bug or if there's a good explanation for why it works this way.

The following ViewModel compiles (i.e. objectWillChange is synthesized), but if I bind it to a view using @ObservedObject the view is not updated as the names cycle:

class ViewModel: ObservableObject {
    var name: String = "John" {
        willSet {
            objectWillChange.send()
        }
    }

    private let names = ["John", "Fred", "Jake"]

    init() {
        cycleNames()
    }

    func cycleNames(startAt index: Int = 0) {
        name = names[index]
        DispatchQueue.main.asyncAfter(deadline: .now() + .seconds(1)) { [weak self] in
            guard let self = self else { return }
            let next = index + 1
            if next == self.names.endIndex {
                self.cycleNames(startAt: 0)
            } else {
                self.cycleNames(startAt: next)
            }
        }
    }
}

If I manually add let objectWillChange = ObservableObjectPublisher() in ViewModel, the view updates properly as the names cycle.

To make matters more confusing, if I don't include the manual objectWillChange but do include a separate published property e.g. @Published var age: Int = 1, then the view updates properly as the names cycle.

So it looks like manually calling send() on a synthesized objectWillChange doesn't work unless there is at least one @Published property. Is this intended behavior or a bug?

The synthesized objectWillChange creates an ObservableObjectPublisher once and installs it into all the @Published properties using runtime introspection. When you access objectWillChange, it will take out the installed instance and return it to you.

Since there's no @Published properties in your class, there's no storage objectWillChange could install the publisher into, so it just creates and returns a new instance every time you access objectWillChange.

So in your case you should either make your name property @Published, or you provide a custom implementation of the objectWillChange property (trivially, by adding a stored constant to your ViewModel class)

If you're curious how it's implemented, you can refer to my attempt at replicating this behavior.

4 Likes

Seeing your implementation is really helpful! I had assumed that objectWillChange was statically synthesized with some kind of compiler magic.

So if I understand correctly, Published has space for the objectWillChange reference and the same ObservableObjectPublisher is put into every published property when objectWillChange is accessed?

Exactly.

1 Like

This feels very unintuitive, and borderline unexpected behaviour.

1 Like

It was definitely mystifying to me that objectWillUpdate existed but my view wasn’t updating, though it makes sense once you understand the implementation. I wonder if making this an assertion failure in debug builds with a helpful message could help prevent confusion.

This was discussed previously. @Philippe_Hausler asked me to file an FB on it, which I did: Is there possible to support inheriting from a generic type: class classA<T: classB>: T? - #23 by Philippe_Hausler. There has not been an update on that FB since I filed it.

It's still very likely not what you want, to have new objectWillChange every time. It's still that the synthesis is not doing a good job deciding a default behaviour.

@anandabits Funny that I independently filed mine last month as well (FB7321036). With no update either.

I agree, but I’m not sure there’s a possible default behavior that makes sense, so a crash seems appropriate to me.

Sorry for resurrecting this old thread, but it seems like this behaviour has changed recently, perhaps with Swift 5.2?

Certainly with Xcode 11.5 and Swift 5.2, not instantiating your own ObservableObjectPublisher now works even if you don't have any @Published properties.

1 Like

Is this a workaround for Extensions must not contain stored properties?