Video #253: Observation: The Present
Episode: Video #253 Date: Oct 16, 2023 Access: Members Only 🔒 URL: https://www.pointfree.co/episodes/ep253-observation-the-present

Description
The @Observable macro is here and we will see how it improves on nearly every aspect of the old tools in SwiftUI. We will also take a peek behind the curtain to not only get comfortable with the code the macro expands to, but also the actual open source code that powers the framework.
Video
Cloudflare Stream video ID: a172d5f7788d81b65232932b8cf360be Local file: video_253_observation-the-present.mp4 *(download with --video 253)*
References
- Discussions
- Swift Observation: Access Tracking, Calling Observers
- Swift Talk
- Swift Observation: Calling Observers
- 0253-observation-pt2
- Brandon Williams
- Stephen Celis
- Mastodon
- GitHub
- CC BY-NC-SA 4.0
- source code
- MIT License
Transcript
— 0:05
So, that seems great, but this is starting to get really hairy. Not only do we have to be precise with our application of the @Published property wrapper to make sure each individual feature observes only the state it cares about, but we also need to be very precise in subscribing to child state changes that should cause the parent feature to re-render.
— 0:23
In practice this is incredibly difficult to do perfectly, and so you are more likely to just throw you hands up in the air and decide to subscribe to all state changes of the child feature even though you know it is not efficient to do so. And at the end of the day, SwiftUI simply does not have a good answer for this kind of problem when nesting observable objects. And this goes for holding onto observable objects directly in another observable object, as we have done with the AppModel , but also when holding onto other observable objects in an optional, or in an array, or a dictionary, or any other kind of data structure. Brandon
— 0:56
So, that was a quick tour of the past observation tools that SwiftUI gave us prior to iOS 17 and Swift 5.9. They were powerful, but they were also very easy to use incorrectly, either leading you to observe too much state causing inefficient views, or observe too little state causing glitchy views.
— 1:16
And these are exactly the problems that led to the creation of the new Observation framework in Swift, which is available in Swift 5.9 and iOS 17. It is a pure Swift framework, having nothing to do with any of Apple’s proprietary platforms, but of course its #1 use case is SwiftUI.
— 1:33
It allows one to implement features in the naive way that we started with, and it will “just work”. You get to use plain, mostly un-adorned reference types and do not have to explicitly mark the fields that need to be observed. You get to hold onto the model in your view as a simple let or @State property. And somehow, magically, the view will properly observe the changes inside the class. And it even works fantastically with nested classes.
— 1:59
It’s pretty incredible to see, so let’s check it out. @Observable
— 2:04
I want to keep around the CounterModel as it is now so that we can refer to it later, so let’s copy-and-paste it and the CounterView and preview to a new file named CounterView_ObservableObject.swift.
— 2:21
And let’s rename CounterModel to CounterModel_ObservableObject , and CounterView to CounterView_ObservableObject .
— 2:40
And then back in the regular CounterModel and CounterView we can start making some changes. Let’s start removing its various ornamentations. First we can drop the ObservableObject protocol conformance: class CounterModel { … }
— 2:50
And we can drop all usages of the @Published property wrapper: class CounterModel { var count = 0 var secondsElapsed = 0 private var timerTask: Task<Void, Error>? … }
— 2:59
Now this type is a regular, pure Swift class. There’s no weird SwiftUI code leaking into it, and this is how we wanted to build it from the very beginning when we approached things in a naive way, but we were forced to add all of those adornments.
— 3:18
But, there is one thing you must do in order for observation to work, which is annotate the class with the @Observable macro: @Observable class CounterModel { … }
— 3:44
Macros are a big new features added to Swift, and we’ve only briefly discussed them on Point-Free. Recently we did a fun series on testing macros , and in those episodes we got some basic exposure to the mechanics of macros.
— 3:57
We encourage everyone to watch those episodes, but all you need to know for now is that we are annotating a basic, vanilla Swift class with this @Observable macro, and no other change needs to be made to this class. That’s all it takes to have observation magically work.
— 4:16
Next we get to update the view to go back to holding onto the model in the very naive way we tried to earlier: struct CounterView: View { let model: CounterModel … } If the view requires that the model be provided from higher up the chain, then it should hold onto it as a simple let .
— 4:39
Or, if the view wants to own the lifetime of the model, and hence not require the parent domain to pass it along, then we can use the @State property wrapper: struct CounterView: View { @State var model = CounterModel() … }
— 4:47
Or if the view wants to own the lifetime of the model, but still have the parent domain to pass the initial value along, then we can drop the default: struct CounterView: View { @State var model: CounterModel … }
— 5:06
But for now let’s go back to the let property: struct CounterView: View { let model: CounterModel … }
— 5:16
And we also have some compilation errors in the AppModel , but let’s not worry about the root tab view for right now and just comment out that code: // init() { // self.tab1.$count.sink { [weak self] _ in // self?.objectWillChange.send() // } // .store(in: &self.cancellables) // … // }
— 5:35
Now everything is compiling, and almost as if by magic, the counter feature works exactly as it did before. The increment and decrement buttons work, as well as the start and stop timer buttons.
— 6:14
It may be subtle, but this is actually incredible. We just rebuilt the basic counter feature model and view in the same naive way that we tried earlier in this series, which did not previously work. The only difference is that we slapped on this weird @Observable annotation, and now suddenly it works. We don’t have to explicitly say which fields are observed by the view, and we didn’t even have to worry about the fact that we are using a computed property that accesses a private piece of state under the hood. Somehow the view just figures it out
— 6:50
And it will skip view re-computations if state changes in the model that is not used in the view. For example, if comment out the text view that shows the secondsElapsed : // Text("Seconds elapsed: \(self.model.secondsElapsed)")
— 7:09
…then turning on the timer, waiting a few seconds, and turning it off shows that the view did not re-compute for each tick of the timer: ObservableCounterView: @dependencies changed. secondsElapsed 1 secondsElapsed 2 secondsElapsed 3 ObservableCounterView: @dependencies changed.
— 7:19
So, already that is incredible, but it gets better.
— 7:23
The @Observable macro is capable of being even smarter than @State is capable of. Remember that a moment ago we saw that if a view uses a piece of state for a little while, and then stops using that state, it will not automatically unsubscribe from those state changes. Any change to that state will cause the view to re-render, even though the view is no longer using it.
— 8:14
Well, turns out the @Observable macro can stop listening for those state updates. To explore this we will add some state to CounterModel to determine whether or not the elapsedSeconds data is being displayed: var isDisplayingSecondsElapsed = true
— 8:32
And in the view we will use this boolean to determine whether or not to show the text view: if self.model.isDisplayingSecondsElapsed { Text("Seconds elapsed: \(self.model.secondsElapsed)") }
— 8:41
We also want a toggle to turn the boolean on and off, which we need to do through a binding: Toggle(isOn: self.$model.isDisplayingSecondsElapsed) { Text("Observe seconds elapsed") }
— 8:45
But the new, iOS 17 way of getting bindings out of an observable model is through this new @Bindable property wrapper: struct ObservableCounterView: View { @Bindable var model: CounterModel … }
— 9:12
If we were using @State we could still derive bindings in the same way, but @Bindable allows us to derive bindings without requiring the view to own its own @State .
— 9:47
Now this compiles, and it works exactly as we would hope even though the @State property wrapper wasn’t capable of achieving this.
— 9:53
We can start the timer and see that the counter view is re-rendering with each tick of the timer. But as soon as we toggle isDisplayingSecondsElapsed off, the counter view stops rendering. The view has detected that it is no longer using that state, and so it knows it does not need to listen for any of its changes.
— 10:10
So that is also pretty incredible! But things get even better.
— 10:15
Remember that previously we noticed that nesting observable objects led us to a situation where it was easy to access state that was not actually being observed. In particular, we tried accessing the count value from the first tab in order to show a badge, but it didn’t actually work until we contorted ourselves in weird ways to invalidate the parent domain when the child domain changed.
— 11:08
Well, the @Observable macro massively improves upon this. It basically works just as we would hope, but without jumping through hoops.
— 11:15
Let’s copy-and-paste the AppModel and AppView to another file named AppView_ObservableObject.swift.
— 11:27
And rename those types to append a _ObservableObject suffix.
— 12:14
And now go back to the original AppModel and remove all references to @Published and ObservableObject . We can even hold onto the tabs as simple let s since people shouldn’t be allowed to wholesale replace those properties: class AppModel { let tab1 = CounterModel() let tab2 = CounterModel() let tab3 = CounterModel() }
— 12:31
And we don’t even need to mark this class as @Observable since everything is a let . There’s literally nothing to observe directly in the class, only in the child models. Now, there wouldn’t be anything wrong with marking this as @Observable , and you may want to get in the habit of doing it just so that you don’t forget. But technically it’s not even necessary.
— 13:05
Then in the view we will hold onto the AppModel as a simple let : struct AppView: View { let model: AppModel var body: some View { let _ = Self._printChanges() TabView { NavigationStack { CounterView(model: self.model.tab1) .navigationTitle(Text("Counter 1")) } .badge(self.model.tab1.count) .tabItem { Text("Counter 1") } NavigationStack { CounterView(model: self.model.tab2) .navigationTitle(Text("Counter 2")) } .tabItem { Text("Counter 2") } NavigationStack { CounterView(model: self.model.tab3) .navigationTitle(Text("Counter 3")) } .tabItem { Text("Counter 3") } } } }
— 13:14
And we will see that this behaves exactly how we would hope, except without any extra work from us. Incrementing and decrementing in the first tab does cause the root app view to re-render in order to show the badge, but that’s it. We can start a timer in the first tab, or even change state in the 2nd or 3rd tab, and none of that causes the app view to re-render again. It is only when the count changes in the first tab because that is the only state that is used in the view.
— 14:36
To push this to its limits, let’s add a badge to every tab that uses the count from the respective child feature: … .badge(self.model.tab2.count) … .badge(self.model.tab3.count)
— 15:02
If we run this in the preview we will see that counting up in each tab causes the respective badge to be displayed, but we can start a timer in every tab and see that none of those state changes causes the app view to re-render. Pretty amazing.
— 15:24
That’s all we have to do and we can be guaranteed that state will be observed in the correct way. Whereas previously, we would have needed to subscribe to the published property of the count in each child feature in order to explicitly tell the app view to re-render. We had to deal with cancellables, retain cycles, and the Combine framework at large. withObservationTracking
— 16:06
So, it seems that the Observable tool in Swift 5.9 has improved upon nearly every problem we witnessed with ObservableObject s. We don’t have to do nitty-gritty bookkeeping to figure out what state is observed in the view, nested models just work out of the box with no extra work, and even conditionally observing state works. That’s something even the @State property wrapper can’t do. Stephen
— 16:30
And the @Observable macro is certainly the most powerful and refined tool the new Observation framework gives us, but there is another tool. It gives us access to a function that allows us to directly observe changes in an object. SwiftUI uses this tool under the hood to hook up an observable model to a view, but theoretically we could use this tool for other purposes.
— 16:49
So, let’s take a look.
— 16:52
Observable objects from the pre-iOS 17 days offer more tools beyond simply allowing you to integrate a model with a view so that the view re-renders when the model updates. In particular, the @Published property wrapper gives you the power to observe changes to one single field via Combine and react to it.
— 17:06
To do this you use the projected value of the @Published field: init() { self.$count }
— 17:22
This is a publisher that will emit the value when it changes: init() { self.$count.sink { count in } }
— 17:27
However, there is a subtlety here that eventually gets everyone . The publisher emits in the willSet of the change to count , but it emits the update value. This means that self.count at this moment is still the previous count, and so count and self.count will typically not be equal: init() { self.$count.sink { count in count != self.count } }
— 17:55
They will only be equal if the count property was mutated but with the same value that the model currently holds.
— 18:01
But, that subtlety aside, this is a pretty powerful tool. Since secretly under the hood every @Published property is a full blown Combine publisher, you get to use the full arsenal of Combine operators to compose all this data into all types of fun and powerful ways. And in fact this is quite common to do in real world SwiftUI applications.
— 18:20
So, the question is, what is the equivalent tool using the new Observation module in Swift 5.9? We’ve already seen that it massively simplifies the way SwiftUI views observe state used inside the body property, so hopefully it allows us also observe state easily in our domain logic.
— 18:35
Well, unfortunately that’s not really the case. There is only one public tool the Observation module exposes to us that allows us to hook into observation outside of a SwiftUI context. Strangely it’s a global free function called withObservationTracking , and it takes 2 trailing closures. Let’s play around with this in the init of our @Observable class we defined earlier: init() { withObservationTracking { <#code#> } onChange: { <#code#> } }
— 19:06
The first closure is invoked synchronously and immediately upon calling withObservationTracking . This means it is invoked before we even get to the line after withObservationTracking : withObservationTracking { print("First") } onChange: { <#code#> } print("Second")
— 19:23
Running this in the preview shows that “First” is indeed printed before “Second”. First Second
— 19:32
If you access a field of the class inside the first trailing closure, the Observation framework will automatically register you as being interested when that field changes sometime later. And when it detects the field changes, it will invoke the second trailing closure.
— 19:46
That second trailing closure is called at some later time, and it is not handed any arguments. It is just a fire-and-forget closure, and you can do whatever you want in there to respond to the observation.
— 19:57
And, well, that’s basically it. This is a very, very crude observation instrument. In fact, it really only seems handy for SwiftUI and not much else. We can only hope that more powerful observation tools will be coming sometime later. We will be going deep into the ins and outs of the tool a bit later in the episode, but let’s quickly just how to use it.
— 20:16
Let’s just do the most basic thing possible, which is print when the count changes: init() { withObservationTracking { _ = self.count } onChange: { print("Count changed to", self.count) } }
— 20:36
Seems simple enough. Well, let’s run it in the preview, and increment the count. We will see the following printed to the console: Count changed to 0
— 20:47
Well, that’s already a little weird. The count went up to 1, yet the logs say it changed to 0. Also if we increment again, the view re-rendered but we don’t get another log.
— 21:01
Well, this is due to two quirks of this function that aren’t exactly clear from its name alone. First, the onChange closure is actually called in the willSet of the count changing, and hence accessing self.count will return the previous value. This is similar to self.$count.sink , but at least there the sink closure was handed the freshest value. There is no way to get the freshest value here except to wait a bit of time, like perhaps spinning up an unstructured task: withObservationTracking { _ = self.count } onChange: { Task { print("Count changed to", self.count) } }
— 21:42
Now when we run the preview it does print out “Count changed to 1”.
— 21:47
However, it is still only printing a single time.
— 21:50
And the reason this is still printing only a single time instead of every time the count changes is that withObservationTracking listens for changes only one single time. Once it observes something has changed, it stops listening. And that’s by design.
— 22:02
So, you have to contort yourselves a bit to re-observe the count when a change is detected: @Sendable func observe() { withObservationTracking { _ = self.count } onChange: { Task { print("Count changed to", self.count) } observe() } }
— 22:31
And then kick off the first observation in the initializer: init() { … observe() }
— 22:36
It’s a little bizarre, and who knows how ownership and retain cycles work with this set up, but if we run the preview we will see it works. Each time we tap “Increment” we do get some logs printed to the console with the current count.
— 22:47
And, to be honest, there really isn’t much else to discuss for withObservationTracking . It is an incredibly crude tool and you probably won’t find it all that useful in practice. We really hope that Apple improves how one gets access to the individual fields that change in an observable class, but it looks like we will have to wait a bit longer for that.
— 23:06
However, for very simple kinds of observation we can still resort to far simpler tools than things like withObservationTracking or even Combine publishers. At the end of the day the count field is just a regular Swift field, and therefore you can override its didSet callback to be notified whenever the field changes: var count = 0 { didSet { print("Count changed to", self.count) } }
— 23:30
This works exactly as we expect, and requires no additional set up.
— 23:50
So, this option is always available to us, but for more complex forms of observation this can be a little too simplistic. We are going to explore this a bit more later in the episodes, so we will just leave it at that for now.
— 24:02
But before moving on, we should remark why this tool is of the strange shape that it is. Even though it is quite awkward for us to use, it turns out its the perfect shape to use inside SwiftUI.
— 24:13
We of course have no insight whatsoever into what SwiftUI is doing under the hood, but it’s possible to imagine a simplified version of how it uses the tool.
— 24:21
Imagine a very simple view that simply generically wraps another view: struct ObservedView<Content: View>: View { let content: () -> Content var body: some View { content() } } This really doesn’t do much right now. Whenever its body is re-computed it just delegates to the content closure.
— 24:50
However, what if we started observing any properties accessed in content when the body is executed: struct ObservedView<Content: View>: View { let content: () -> Content var body: some View { withObservationTracking { self.content() } onChange: { } } }
— 25:08
So, in the process of executing content that view will naturally access certain properties on some observable model, and then when any of those properties change it will cause this onChange trailing closure to be invoked. It is at that time that we would like to invalidate the view hierarchy and cause the content closure to be invoked again, thus providing the fresh view to the screen.
— 25:28
We can do that by holding onto a bit of state inside this wrapper view, and mutating it whenever the onChange closure is fired: struct ObservedView<Content: View>: View { @State var id = UUID() let content: () -> Content var body: some View { withObservationTracking { self.content() } onChange: { self.id = UUID() } } }
— 25:47
This is a very rough sketch of what SwiftUI could be doing under the hood in order to use the withObservationTracking tool to facilitate the observation magic. We simply execute the body of some view inside withObservationTracking to automatically start observing any properties accessed, and then when any changes it will invoke onChange , thus invalidating the view and causing the view to be re-computed again.
— 26:11
It’s quite simple, and in fact it’s even technically possible to backport a lot of the Observation framework’s functionality to iOS 16 and below, as long as you are willing to wrap all of your views in this ObservedView kind of wrapper. Behind the macro
— 26:23
OK, we have now gone pretty deep into the new Observation framework in Swift 5.9. We have seen that it seems to improve on nearly every aspect of ObservableObject s, except possibly when you need to observe individual fields of your class. But everything else about it is so great we could easily look past that one small deficiency. Brandon
— 26:41
But how does all of this observable machinery actually work? It seems like magic, but can we unravel its mysterious ways and get a better understanding of how it is able to pull off such an impressive feat?
— 26:55
Let’s dig into the @Observable macro and see what we can learn.
— 27:02
It all begins with the @Observable macro. The macro itself is not magical. At all. If you didn’t know already, all a macro does in Swift is expand to additional code that you do not have to write yourself, but that nonetheless is compiled into your application. That means any macro you use can technically be deleted and replaced by just copying-and-pasting all the expanded code directly into your code. That of course defeats the purpose of the macro, but it just goes to show that there is no magic whatsoever behind macros. It’s just plain Swift code.
— 27:36
In order to make a class observable many steps must be carried out with an abundance of care, and so rather than forcing you to write that code yourself, Swift provides a macro to write all the code for you. Xcode can even helpfully expand that code for you to see.
— 27:55
If you right-click on @Observable and then click “Expand macro”, Xcode will insert “shadow” code into the editor that represents all the code the macro generates and that gets compiled into your application, but that you never really have to think about on a day-to-day basis.
— 27:57
So, let’s expand the macro… @Observable class CounterModel { + @ObservationTracked var count = 0 { didSet { print("Count changed to", self.count) } } + @ObservationTracked var isDisplaySeconds = true + @ObservationTracked var secondsElapsed = 0 + @ObservationTracked var isDisplaySeconds = true + @ObservationTracked private var timerTask: Task<Void, Error>? var isTimerOn: Bool { self.timerTask != nil } func decrementButtonTapped() { self.count -= 1 } func incrementButtonTapped() { self.count += 1 } func startTimerButtonTapped() { self.timerTask?.cancel() self.timerTask = Task { while true { try await Task.sleep(for: .seconds(1)) self.secondsElapsed += 1 print("secondsElapsed", self.secondsElapsed) } } } func stopTimerButtonTapped() { self.timerTask?.cancel() self.timerTask = nil } + @ObservationIgnored private let _$observationRegistrar + = Observation.ObservationRegistrar() + + internal nonisolated func access<Member>( + keyPath: KeyPath<CounterModel , Member> + ) { + _$observationRegistrar.access(self, keyPath: keyPath) + } + + internal nonisolated func withMutation< + Member, MutationResult + >( + keyPath: KeyPath<CounterModel , Member>, + _ mutation: () throws -> MutationResult + ) rethrows -> MutationResult { + try _$observationRegistrar.withMutation(of: self, keyPath: keyPath, mutation) + } } +extension CounterModel: Observation.Observable { +}
— 28:13
First let’s look at the bottom of the class where it makes our CounterModel conform to the Observable protocol. This is a protocol in the Observation framework with no requirements, but lets us restrict certain APIs to only deal with objects that are observable.
— 28:49
Then right above that we see that the macro added a private stored property to the class, _$observationRegistrar , which is a value that keeps track of what properties are being accessed in the object and tracks when mutations are made. It does this with the access and withMutation methods added, which just call down to the access and withMutation that are defined directly on the registrar. We will see how both of these methods are used in just a moment…
— 29:23
…because not only does the @Observable macro write a whole bunch of code in our class to make it observable, but the code it writes also inserts all new macros of its own, such as the @ObservationTracked macro. And so that macro can also be expanded.
— 29:30
For example, the @ObservationTracked applied to count expands to: var count = 0 +@ObservationIgnored private var _count = 0 +{ + @storageRestrictions(initializes: _count) + init(initialValue) { + _count = initialValue + } + + get { + access(keyPath: \.count) + return _count + } + + set { + withMutation(keyPath: \.count) { + _count = newValue + } + } +} And every other field with @ObservableTracked applied expands to something similar.
— 29:35
Here we are seeing a few interesting things. First we see that the macro has inserted a new stored property in the class: @ObservationIgnored private var _count = 0
— 29:45
…and is even adorned with yet another new macro, @ObservationIgnored . This macro doesn’t expand to anything at all, it is just here to tell the @Observation macro that this new property does not need to be observed at all.
— 29:58
And the reason it does not need to be observed is because this is purely private storage for the count value and not meant to be used from the public at all. Instead, the var count that we defined ourselves in the class has secretly been turned into a computed property: var count: Int { … }
— 30:09
This is interesting. The macro has turned our stored property into a computed property along with a little private stored property. It does this so that it can provide custom get and set accessors to the computed property: var count: Int { … get { … } set { … } }
— 30:20
And in these accessors the macro sneakily informs the registrar whenever we access the count field or make a mutation to it: get { access(keyPath: \.count) return _count } set { withMutation(keyPath: \.count) { _count = newValue } }
— 30:29
So, from the outside we can access and mutate the count variable as if it was a regular stored property, but secretly we are going through a computed property that tracks everything we are doing.
— 30:44
There is one really strange thing in this computed, and that’s this part: @storageRestrictions(initializes: _count) init(initialValue) { _count = initialValue }
— 30:49
This is a brand new feature of Swift 5.9 that probably won’t be used a ton in practice, but it can be handy and it was introduced to make many macros work in a reasonable manner.
— 31:01
This new syntax is called an “init accessor”, and its a concept that lives right alongside the “get” and “set” accessors. Its purpose is to allow you to instantiate values from types by setting only the computed properties and not the stored properties.
— 31:20
For example, consider an Angle type that internally holds its data in radians, but exposes a degrees computed property that allows you to interact with an angle in degrees: struct Angle { var radians: Double var degrees: Double { get { self.radians * 180 / .pi } set { self.radians = newValue * .pi / 180 } } }
— 31:54
Then say you want to add initializers to this type that allows you to create an angle with either degrees or radians: init(radians: Double) { self.radians = radians } init(degrees: Double) { self.degrees = degrees }
— 32:13
Well, unfortunately the init for degrees does not work: ‘self’ used before all stored properties are initialized Return from initializer without initializing all stored properties
— 32:16
We are trying to indirectly initialize radians by going through the degrees computed property, but Swift does not allow you to access methods or properties in a value until all of its memory has been initialized.
— 32:32
So, instead you would be forced to initialize the radians and do the degree/radian translation again: init(degrees: Double) { self.radians = degrees * .pi / 180 }
— 32:46
This now compiles, but this is also not always an easy workaround. Another option is to give a default to the stored property, which allows us to define the initializer as we’d originally wanted: var radians: Double = 0 … init(degrees: Double) { self.degrees = degrees }
— 33:07
However it’s not always appropriate to have a default here, and by providing one we might forget to re-initialize the property in a custom initializer.
— 33:17
But even initializing a stored property in terms of the computed value is also not always appropriate.
— 33:29
For example, in our custom initializer of CounterModel say we wanted to set up some defaults of the stored properties: init() { self.count = 0 … }
— 33:49
Somehow this is compiling even though count is a computed property, and that holds true even if it weren’t provided a default: var count: Int // = 0
— 34:00
This is in stark contrast with what we saw in the Angle type, and it’s a good thing! We would never want to have to write code like this: init() { self._count = 0 … } That is, we should have to go through the stored properties and not the computed properties.
— 34:31
But the problem with this is that we need to know the inner workings of the @Observable macro to even know that this should be done. We have to know that secretly our count stored property has been swapped out for a private _count stored property, and that that is the actual field to set in the initializer.
— 34:49
That of course is not a great user experience. It would be far better if users of the @Observable macro did not need to be intimately familiar with its inner workings just to write a simple initializer. And this is what init accessors are all about. They allow you to describe how to initialize a stored property from a computed property, allowing you to use that computed property in the initializer.
— 35:12
It’s a brand new accessor alongside get and set called init : var degrees: Double { init { } get { … } set { … } }
— 35:20
And you provide a single argument of the value being initialized with: init(initialValue) { }
— 35:23
And then we just have to do the math to turn degrees into radians: init(initialValue) { self.radians = initialValue * .pi / 180 }
— 35:37
But we must also tell the compiler up front what stored properties we will be initializing: @storageRestrictions(initializes: radians)
— 36:09
And with that done we can now write the degree initializer exactly like we wanted to originally: init(degrees: Double) { self.degrees = degrees }
— 36:13
So, that’s the basics of init accessors. It’s what allows us to further keep the veil of macro magic on for a little bit longer. You can write simple initializers for your types even though secretly the macro has swapped out your seemingly stored property into a computed one. And if you ever write a macro yourself that does these kinds of tricks, you are going to need to utilize init accessors. Behind the source
— 36:44
So, this is a ton of new code being added to our simple little CounterModel class, and it vastly outweighs the code we wrote ourselves. But it is all this code that unlocks all of the amazing capabilities that we explored earlier in the series. It employs a little bit of indirection in order to track which fields are accessed and when they are mutated, and it does this through the observation registrar. SwiftUI can then employ the withObservationTracking method to be informed when fields that are used in a view are mutated, and then schedule a re-render of the view.
— 37:21
So, that’s what the @Observable macro expands to and all the different parts of it, but it doesn’t really explain how it all works. There is still a black box in our code that we have no idea how it works on the inside, and that’s the ObservationRegistrar type. It seems to be the real arbiter of magic since we just delegate to it for access and mutation. How does it work?
— 37:42
Well, luckily the Observation framework is a part of the open source Swift project, and so we can actually look right at the code.
— 37:49
I have the Swift project cloned on my computer right now and the freshest commits pulled, but it isn’t super easy to browse through the files in Xcode. Right now I am only concerned with browsing the Observation framework, which exists in its own directory, so if I drop a Package.swift in there it will make it possible to open up those files in Xcode: // swift-tools-version: 5.9 import PackageDescription let package = Package( name: "Observation", platforms: [ .iOS(.v17), .macOS(.v14), ], products: [ .library( name: "Observation", targets: ["Observation"] ) ], targets: [ .target( name: "Observation" ) ] ) Now we can open the Observation framework files in Xcode. Of course we can’t compile any of it, but at least we can peruse the types.
— 38:23
We can even type ⌘⇧O to search for the ObservationRegistrar type and find its definition: public struct ObservationRegistrar: Sendable { … }
— 38:31
First surprising thing about this type is that it’s a struct. This is surprising because remember that the registrar is held as a let in the expanded macro code: @ObservationIgnored private let _$observationRegistrar = Observation.ObservationRegistrar()
— 38:43
So, if the registrar is a struct and its held onto as a let , then it means that no changes can ever be made to the registrar’s stored properties right?
— 38:53
Well, that is true if the registrar only holds onto value types, but it can also hold onto reference types, and those values can be mutated even if you hold onto the struct as a let . And that is exactly the case here.
— 39:08
If we explore the stored properties of ObservationRegistrar we will see it has only one: private var extent = Extent()
— 39:25
The Extent type is defined here: private final class Extent: @unchecked Sendable { … }
— 39:27
…and it is a class. So, we do in fact see there is a reference type lurking in the shadows of ObservationRegistrar . However, it too holds onto its property as a let , let context = Context()
— 39:35
…so there must be another reference type even deeper.
— 39:42
The Context type is a struct: internal struct Context: Sendable { … }
— 39:47
…with a single stored property: private let state = _ManagedCriticalState(State())
— 39:56
And this _ManagedCriticalState type is also a struct: internal struct _ManagedCriticalState<State> { … }
— 40:01
…and it too has a single stored property: private let buffer: ManagedBuffer<State, UnsafeRawPointer>
— 40:06
And here we finally see a crack in the value type armor. The ManagedBuffer type is a class in the standard library that wraps a mutable pointer, and so we see that there is a mutable reference type at the heart of the ObservationRegistrar type, and this is how its able to make mutations to internal state when properties are accessed and changed.
— 40:50
Speaking of which, how does that work?
— 40:53
Well, remember the ObservationRegistrar type has an access method that is called from the macro anytime you access an observed property in the class. The access method actually mutates a global dictionary that keeps track of every observable access happening inside the entire application. It sounds wild, but it’s true.
— 41:24
If we go to the access method in the ObservationRegistrar type we will find the following: public func access<Subject: Observable, Member>( _ subject: Subject, keyPath: KeyPath<Subject, Member> ) { if let trackingPtr = _ThreadLocal.value? .assumingMemoryBound( to: ObservationTracking._AccessList?.self ) { if trackingPtr.pointee == nil { trackingPtr.pointee = ObservationTracking._AccessList() } trackingPtr.pointee?.addAccess( keyPath: keyPath, context: context ) } }
— 41:26
There is something called a “thread local” being accessed, which is really just a raw pointer that has to its memory rebound to a hidden, underscored type called _AccessList . This is a global, mutable value. No ifs, ands or buts about it. The access list is being pulled out of thin air, it is not held as local state in the registrar.
— 41:54
The framework does a little work to make it safe by sticking it in a thread local, and there are even some locks to make sure it’s all thread safe. But still, it is a global, and under the hood the access list is just a dictionary: @_spi(SwiftUI) public struct _AccessList: Sendable { internal var entries = ObjectIdentifier: Entry … }
— 42:11
The dictionary maps some kind of identifier, which represents who is doing the accessing, to an entry, which represents the properties being accessed. And jumping to the Entry type we can clearly see that it holds a set of key paths: struct Entry: @unchecked Sendable { let context: ObservationRegistrar.Context var properties: Set<AnyKeyPath> }
— 42:36
One strange thing is that this _AccessList type is public, but hidden from us because it is given a @_spi(SwiftUI) attribute. This means the SwiftUI framework gets access to this type, and must need it for something, but sadly we are not given any such affordances. It’s a little bizarre to see mentions of SwiftUI in the public, open source Swift programming language repo, but sadly that is the case for these tools. It just seems like decisions weren’t made fast enough to provide these tools publicly, and so they were snuck in behind @_spi .
— 43:13
And once the global access list is procured from thin air, the access method inserts data into that dictionary so that we have a global record that something has accessed a particular field of an observable model: internal mutating func addAccess<Subject: Observable>( keyPath: PartialKeyPath<Subject>, context: ObservationRegistrar.Context ) { entries[context.id, default: Entry(context)] .insert(keyPath) }
— 43:46
So, the access method is mutating some global dictionary that maps the “who” to the “what” for access.
— 43:55
What does withMutation do? Let’s go to its implementation: public func withMutation<Subject: Observable, Member, T>( of subject: Subject, keyPath: KeyPath<Subject, Member>, _ mutation: () throws -> T ) rethrows -> T { willSet(subject, keyPath: keyPath) defer { didSet(subject, keyPath: keyPath) } return try mutation() }
— 44:02
We see that really all it does is call a willSet and didSet with the mutation happening in between.
— 44:09
So, what does willSet do? If we go to its implementation: public func willSet<Subject: Observable, Member>( _ subject: Subject, keyPath: KeyPath<Subject, Member> ) { context.willSet(subject, keyPath: keyPath) }
— 44:13
We will see that, like access , it just calls out to some private implementation using the context:
— 44:20
And jumping to that method reveals the following: internal func willSet<Subject: Observable, Member>( _ subject: Subject, keyPath: KeyPath<Subject, Member> ) { let tracking = state.withCriticalRegion { $0.willSet(keyPath: keyPath) } for action in tracking { action() } }
— 44:21
This is where things get interesting. It is doing some work to compute a tracking variable, which is an array of closures, and then it loops over those closures and invokes each one. This is where the framework notifies whoever is interested in mutations to a property that a mutation is about to be made.
— 44:47
But, how is this array of tracking closures computed? Jumping to the willSet method reveals: internal mutating func willSet( keyPath: AnyKeyPath ) -> [@Sendable () -> Void] { var trackers = @Sendable () -> Void if let ids = lookups[keyPath] { for id in ids { if let tracker = observations[id]?.willSetTracker { trackers.append(tracker) } } } return trackers }
— 44:53
…and we see that there is some kind of lookups dictionary that maps a key path to some kind of collection of IDs. Looking up that instance variable we see its a set of IDs: private var lookups = AnyKeyPath: Set<Int>
— 45:20
And then those IDs can be further used to look up something known as an Observation : private var observations = Int: Observation
— 45:30
An Observation is defined as follows: private struct Observation { private var kind: ObservationKind internal var properties: Set<AnyKeyPath> … }
— 45:37
…and the ObservationKind type holds the actual action closure that ultimately gets executed: private enum ObservationKind { case willSetTracking(@Sendable () -> Void) case didSetTracking(@Sendable () -> Void) case computed(@Sendable (Any) -> Void) case values(ValuesObserver) }
— 46:08
So we now see the two primary sides of observation. On one side when you invoke the access method it registers that someone accessed a property in a global dictionary. And then on the other side, when a mutation is made to some property, the framework loops over some collection in its state to execute action closures, which presumably notifies any interested parties that a property changed.
— 46:42
However, there is still a missing link here. The access method mutates a global dictionary, but the withMutation method did not access that global dictionary at all. It made use of something called its context , which holds onto something called state , which in turn has lookups and observations . But all of that is local state held in the types. It never accesses the global thread local.
— 47:09
In fact, we can search for _ThreadLocal in this project and we will see only a few uses of it. One is in the access method that we have already seen, another is just the definition of the type, and the third is in something called generateAccessList .
— 47:28
And if we search of uses of generateAccessList we will finally find the missing link: it is used inside withObservationTracking ! That method, which we discussed a moment ago and showed that it is a very crude tool for observing changes to state, is the glue that bonds the access and withMutation functionality of ObservationRegistrar together.
— 47:54
Interestingly the Observation framework actually comes with 4 overloads of withObservationTracking , but 3 of them are marked as @_spi(SwiftUI) , which means we don’t get access to it but for whatever reason SwiftUI needs it.
— 48:10
The only one we have access to is this one right here: public func withObservationTracking<T>( _ apply: () -> T, onChange: @autoclosure () -> @Sendable () -> Void ) -> T { let (result, accessList) = generateAccessList(apply) if let accessList { ObservationTracking ._installTracking(accessList, onChange: onChange()) } return result }
— 48:18
This code here makes it clear how withObservationTracking is the glue between access and withMutation . It performs a two-step process to first invoke apply and gather up all the properties that were accessed when apply executed, and then it starts tracking all of those properties.
— 48:41
If we jump to generateAccessList : fileprivate func generateAccessList<T>( _ apply: () -> T ) -> (T, ObservationTracking._AccessList?) { … }
— 48:45
…we will find how the framework tracks which properties were accessed when the apply closure is executed. It looks like it does some upfront work, then some deferred work, and then returns the result of apply . But right now I’m having a hard time understanding what all of this means, so I want to quickly unroll this defer statement and write it in the old fashioned way: fileprivate func generateAccessList<T>( _ apply: () -> T ) -> (T, ObservationTracking._AccessList?) { var accessList: ObservationTracking._AccessList? let result = withUnsafeMutablePointer( to: &accessList ) { ptr in let previous = _ThreadLocal.value _ThreadLocal.value = UnsafeMutableRawPointer(ptr) let result = apply() if let scoped = ptr.pointee, let previous { if var prevList = previous.assumingMemoryBound( to: ObservationTracking._AccessList?.self ).pointee { prevList.merge(scoped) previous.assumingMemoryBound( to: ObservationTracking._AccessList?.self ).pointee = prevList } else { previous.assumingMemoryBound( to: ObservationTracking._AccessList?.self ).pointee = scoped } } _ThreadLocal.value = previous return result } return (result, accessList) }
— 49:28
Now we can see that the current access list is captured from the thread local and then the thread local is cleared out. Then apply is run and the access list is captured again. Then the before and after access list are merged together, and the thread local is updated.
— 50:17
So, this shows that when withObservationTracking is invoked it first updates the thread local with all of the key paths accessed while executing apply . Next in withObservationTracking an _installTracking method is invoked with the access list: if let accessList { ObservationTracking._installTracking( accessList, onChange: onChange() ) }
— 50:37
And jumping to that method we will see the following: @_spi(SwiftUI) public static func _installTracking( _ list: _AccessList, onChange: @escaping @Sendable () -> Void ) { let tracking = ObservationTracking(list) _installTracking(tracking, willSet: { _ in onChange() tracking.cancel() }) }
— 50:39
It just calls out to another _installTracking , so let’s jump to that: @_spi(SwiftUI) public static func _installTracking( _ tracking: ObservationTracking, willSet: (@Sendable (ObservationTracking) -> Void)? = nil, didSet: (@Sendable (ObservationTracking) -> Void)? = nil ) { let values = tracking.list.entries.mapValues { switch (willSet, didSet) { case ( .some(let willSetObserver), .some(let didSetObserver) ): return Id.full($0.addWillSetObserver { willSetObserver(tracking) }, $0.addDidSetObserver { didSetObserver(tracking) }) case (.some(let willSetObserver), .none): return Id.willSet($0.addWillSetObserver { willSetObserver(tracking) }) case (.none, .some(let didSetObserver)): return Id.didSet($0.addDidSetObserver { didSetObserver(tracking) }) case (.none, .none): fatalError() } } tracking.install(values) }
— 50:45
And now we see that it iterates over the access list in order to invoke something called addWillSetObserver , which is defined as: func addWillSetObserver( _ changed: @Sendable @escaping () -> Void ) -> Int { return context.registerTracking( for: properties, willSet: changed ) }
— 50:57
That calls down to registerTracking , defined as: internal func registerTracking( for properties: Set<AnyKeyPath>, willSet observer: @Sendable @escaping () -> Void ) -> Int { state.withCriticalRegion { $0.registerTracking( for: properties, willSet: observer ) } }
— 51:02
That calls down to another register tracking, defined as: internal mutating func registerTracking( for properties: Set<AnyKeyPath>, willSet observer: @Sendable @escaping () -> Void ) -> Int { let id = generateId() observations[id] = Observation( kind: .willSetTracking(observer), properties: properties ) for keyPath in properties { lookups[keyPath, default: []].insert(id) } return id }
— 51:10
And now we finally see how the lookups and observations dictionaries are mutated.
— 51:17
And this finally ties the knot of how observation roughly works in the framework.
— 51:21
When the access method is called it mutates a global thread local to keep track of what property was accessed.
— 51:27
When withMutation is called it uses its local state of lookups and observations to notify interested parties that a property was mutated.
— 51:37
And then finally, withObservationTracking marries the two together by figuring out what properties are accessed when a closure is executed and then installing tracking in the observation registrars.
— 51:50
And theoretically it would even be possible to backport all of this code to work on iOS 16 and earlier. However, you wouldn’t be able to backport the SwiftUI support. That unfortunately requires iOS 17. Next time: Observable gotchas Stephen
— 52:00
OK, we have no gone deep into the new Observation framework. We understand the problems it’s trying to solve, and see concretely just how magical it can be to use it. But we’ve also explored its primary observation tracking tool, and even dug into the actual source code to see how it the tool roughly works.
— 52:16
The tool is pretty incredible to use, and solves so many pain points of the original tools, but it does have some gotchas and problems of its own. It is very important to understand these downsides otherwise you can easily find yourself in a situation where you are accidentally observing far too much state.
— 52:33
Let’s dig in…next time! References Swift Observation: Access Tracking, Calling Observers Chris Eidhof and Florian Kugler • Jul 7, 2023 Chris and Florian spent 2 episodes of Swift Talk building most of Swift 5.9’s Observation framework from scratch. Watch these episodes if you want an even deeper dive into the concepts behind obsevation: Swift Observation: Access Tracking Swift Observation: Calling Observers https://talk.objc.io/episodes/S01E362-swift-observation-access-tracking Downloads Sample code 0253-observation-pt2 Point-Free A hub for advanced Swift programming. Brought to you by Brandon Williams and Stephen Celis . Content Become a member The Point-Free Way Beta previews Gifts Videos Collections Free clips Blog More About Us Community Slack Mastodon Twitter BlueSky GitHub Contact Us Privacy Policy © 2026 Point-Free, Inc. All rights are reserved for the videos and transcripts on this site. All other content is licensed under CC BY-NC-SA 4.0 , and the underlying source code to run this site is licensed under the MIT License .