How to use Observation to actually observe changes to a property

First off all I would like to thank the team for implementing the Observation framework. I really like the premise of it:

Observation provides a robust, type-safe, and performant implementation of the observer design pattern in Swift

Having said that I am having trouble actually using the framework for its intended purpose, probably because I don't understand how it should be done and the documentation is short. Help me understand :)

Lets say I have a source of truth

@Observable
final class SourceOfTruth {
    var data: String = ""
}

How do I actually observe the changes of data? Let's say I would like to run some code each time data changes (and I would like to set that code outside of this class). I tried using the withObservationTracking function but it only fires once. How do I continuously observe changes to an observable property?

Thank you.

Well, the lack of multiple firing + lack of didSet pushes you to the following recursive dance (outside of SwiftUI)...

Below is a sample use site we have where we want to run our test suite after a command line application successfully have logged in, note the recursive call to logIn in the onChange is needed to re-register the tracking if the firing didn't match the end condition - the asyncAfter is needed here as the onChange fundamentally is a willSet notification, and we wanted a didSet semantic so need to wait with the recursive call until the value actually is updated):

    @MainActor
    private func logIn(applicationState: OrdoApplication) {
        if applicationState.loginState == .loggedIn {
            runTests(applicationState: applicationState)
        } else {
            withObservationTracking {
                _ = applicationState.loginState
            } onChange: {
                DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
                    self.logIn(applicationState: applicationState)
                }
            }
        }
    }

Hopefully Observation will grow support for additional use cases out-of-the-box in future updates, as it has tons of potential to be used for many other cases and we could see significant use for it outside of just plain SwiftUI.

Why dispatch async after 0.1 delay? I removed it and all seems to be working alright:

import Observation

@Observable class SourceOfTruth {
    var data: Int = 0
}

let sourceOfTruth = SourceOfTruth()

func test() {
    withObservationTracking {
        _ = sourceOfTruth.data
    } onChange: {
        print("changed: ", sourceOfTruth.data)
        test()
    }
}

func foo() {
    Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { _ in
        sourceOfTruth.data += 1
    }
    test()
}

foo()
1 Like

Thank you for the example. Although it works it .... doesn't look very nice :) Ideally I would expect something like:

let sourceOfTruth = SourceOfTruth()
let observation = withContinousObservation(of: sourceOfTruth.data) { data in
   // do something when data changes
}

or

let sourceOfTruth = SourceOfTruth()
let dataPublisher = $sourceOfTruth.data.publisher()

Maybe someone figured out a way to somehow wrap the property of an Observable in a Publisher?

1 Like

It works, but you get the previous value (willSet semantics, but we want the new value didSet semantics).

Slightly simplified your test gives following output:

changed:  0
changed:  1
changed:  2
changed:  3

But we want the first update to be "1", thus the delay....

Further reduced test
import Observation
import Foundation

@Observable class SourceOfTruth {
    var data: Int = 0
}

let sourceOfTruth = SourceOfTruth()

func test() {
    withObservationTracking {
        _ = sourceOfTruth.data
    } onChange: {
        print("changed: ", sourceOfTruth.data)
        test()
    }
}

test()

while true {
    sourceOfTruth.data += 1
    sleep(1)
}
1 Like

I agree about the desire for continuous observations, also it would be very nice to have trailing edge didSet semantics as an option - hoping to see it in the future! Just wanted to share how we worked around the lack of those two.

1 Like

Oops, you are right...

Are we going to have didSet option with Observable or is it already set in stone as is?

I believe the long term plan is to develop this more for use cases like the one you described. I didn’t follow the development of this very closely, but from what I remember, the pitch originally included for example the ability to get a stream of changes from an object. But those were removed due to some complications, and reserved for later pitches and development, I think they were in a rush to get at least something committed for SwiftUI purposes,

Speaking of which, I did some spelunking into the swift repo today, I found that there actually is a public function that allows you to specify didSet callbacks.

Notice however that this function is annotated with @_spi(SwiftUI)
That means it’s technically public, but isn’t really meant for general usage.

That said, it is public, and since it’s obviously meant to be used by SwiftUI it will probably stick around a while. At least a year or so until something replaces it.

You can access these “hidden” SPI functions by annotating your import with the same tag. Use at your own risk.

@_spi(SwiftUI) import Observation
2 Likes

It gives this warning for me:

@_spi(SwiftUI) import Observation :warning: '@_spi' import of 'Observation' will not include any SPI symbols; 'Observation' was built from the public interface at /Applications/Xcode.app/Contents/Developer/Platforms/iPhoneSimulator.platform/Developer/SDKs/iPhoneSimulator17.0.sdk/usr/lib/swift/Observation.swiftmodule/arm64-apple-ios-simulator.swiftinterface

and the willSet+didSet version is not available.

Correct me if I am wrong, but a mere async (with no delay) will also work, right?

I believe that should work fine yes, unless there is something that I miss between mainactor annotation and the gcd main queue in terms of equivalence (would dig into it in that case - if they are not truly treated as equivalent I’d assume there could be a race…).

This is almost what you want:

func withContinousObservation<T>(of value: @escaping @autoclosure () -> T, execute: @escaping (T) -> Void) {
    withObservationTracking {
        execute(value())
    } onChange: {
        DispatchQueue.main.async {
            withContinousObservation(of: value(), execute: execute)
        }
    }
}

Ability of cancelling is missing in this implementation.

I think you’d need to mainactor annotate the function too…

Oh snap. Well so much for that idea :upside_down_face:

Well look at that, one of the original pitches had exactly what I need.

for await value in object.values(for: \.someProperty) {
     print(value)
}

Won't have time today to read the hundreds of posts that convinced the authors to arrive at the current implementation but I guess there were important reasons to do that. Hopefully something will be added in the future but it seems that currently the framework is not really that usable outside of SwiftUI.

Edit: If anyone else didn't follow the evolution and is curious why a general framework like Observation wouldn't have a way to actually observe properties, check out this thread. I am not smart enough to understand but I guess observing changes to properties that may run on different concurrency contexts is tricky (correct me if I'm wrong).

2 Likes

The summary is that the current implementation of Observable was pretty much tailor made for SwiftUI (so much so that I wonder if even withObservationTracking(_:onChange:) should have been under SPI...) Anyway, the plan is to enhance it for more general use-cases – but that hasn't happened yet.

For me, the crux of it is that asynchronous observation (as the removed values(for:) API supplied) provides very different functionality to a synchronous API (that can support fine-grained willSet/didSet events) and has the potential to introduce (and almost encourages) all sorts of subtle bugs unless handled with the utmost care.

This is because all these separate observations are carried by separate asynchronous Tasks which provide no guarantees as to the order in which they're delivered – other than that they won't be delivered immediately. Essentially you're dealing with a bunch of unordered state arriving in which it's up to you to ensure that you're not breaking invariants.

You'll even get a taste of this with the Observation technique outlined above. You can also write this as:

func withContinousObservation<T>(of value: @escaping @autoclosure () -> T, execute: @escaping (T) -> Void) {
    withObservationTracking {
        execute(value())
    } onChange: {
        Task { @MainActor in
            withContinousObservation(of: value(), execute: execute)
        }
    }
}

In many cases this will be fine, but do remember that the call to execute triggered by the observation will not occur in the current event loop cycle. Maybe this is fine for your use case, but maybe it will introduce a subtle bug where some vital flag isn't set in time to determine whether or not some other action will occur.

Or maybe, you want your change to trigger (or not trigger) an animation in SwiftUI? Well, now you're missing the animation transaction deadline (as explained in this post).

Fingers-crossed we'll get a more filled out Observation offering soon.

6 Likes

Yeah pretty much we are "stuck" with combine (or KVO) for the same-context (actor) observation for now. One saving grace is that this feature is part of Swift 5.9 and not iOS like Combine and hopefully future enhancements can be back-ported and used without bumping iOS (or whatever platform you are using it on).

1 Like