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?
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):
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.
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.
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 '@_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
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…).
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).
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:
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.
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).
I think the idea of withObservationTracking is if you are supposed to read the property within it and if there is a change which you don't read again then you are no longer interested in changes thus there should be no more change tracking. The example in Apple's docs is not great because it doesn't demonstrate it is possible to return something from withObservationTracking.
Below is an object that maintains a sorted order of the a parent model's objects. I used withObservationTracking to update if either the sort order local to this object is changed or if the particular model object property I'm sorting by is changed or even the model itself. It doesn't matter that tracking only happens once, because if anything I'm tracking changes, the cached data will be invalidated and when it is read again the tracking will be re-configured.
@Observable
class Counter: Identifiable {
var count = 0
}
@Observable
class Model {
var counters: [Counter] = [.init(), .init(), .init()]
}
@Observable
class SortedModel {
var _model: Model!
var model: Model {
get {
_model
}
set {
_model = newValue
_counters = nil
}
}
var ascending = false
var _counters: [Counter]?
var counters: [Counter] {
if _counters == nil {
_counters = withObservationTracking {
// tracks self.ascending and the count of all model.counters.
let sort = SortDescriptor(\Counter.count, order: ascending ? .forward : .reverse)
return model.counters.sorted(using: sort)
} onChange: { [weak self] in
self?._counters = nil
}
}
return _counters!
}
}