Inside new iOS 17 Observable macro

Sasha Myshkina
3 min readSep 23, 2023

--

By introducing a new Observable macro, iOS 17 is scratching the whole ObservableObject protocol. And therefore it allows us to eliminate the property wrappers that rely on this very protocol — @ObservedObject, @StateObject, and @EnvironmentObject.

Photo by José Martín Ramírez Carrasco on Unsplash

ObservedObject vs Observable macro

The difference between the two approaches — the ObservableObject protocol and @Observable macro — is striking.

With property wrappers that rely on this protocol, SwiftUI views would react to the change on the object’s level. But now, with the macro, they will only react to changes on the level of the concrete property.

Let’s take the protocol-based exampled from the previous article


import SwiftUI

final class ViewModel: ObservableObject {

@Published var count = 0
}

struct ContentView: View {

@ObservedObject var model: ViewModel

var body: some View {
Button(action: {
model.count += 1
}, label: {
buttonContent
})
}
...
}

…and with @Observable we can rewrite it to be even less verbose:

import SwiftUI
import Observation

@Observable final class ViewModel {

var count = 0
}

struct ContentView: View {

var model: ViewModel

var body: some View {
Button(action: {
model.count += 1
}, label: {
buttonContent
})
}
...
}

There is no need to annotate properties the view needs to observe with @Published, since they are all observed by default. However, if there is a need to ignore some properties’ changes, they should be annotated with @ObservationIgnored.

Let’s take a peek at what’s inside the macro by expanding it:

// You can expand the macro by right clicking @Observable -> Expand macro
// Alternatively, you can select the whole ViewModel, right click -> Refactor -> Inline macro

@Observable final class ViewModel {

@ObservationTracked var count = 0

// Point 1
@ObservationIgnored private let _$observationRegistrar = Observation.ObservationRegistrar()

internal nonisolated func access<Member>(
keyPath: KeyPath<ViewModel , Member>
) {
_$observationRegistrar.access(self, keyPath: keyPath)
}

internal nonisolated func withMutation<Member, T>(
keyPath: KeyPath<ViewModel , Member>,
_ mutation: () throws -> T
) rethrows -> T {
try _$observationRegistrar.withMutation(of: self, keyPath: keyPath, mutation)
}

// Point 3
@ObservationIgnored private var _count = 0
}

// Point 2
extension ViewModel: Observation.Observable {
}

By expanding the macro, we can see some new points:

Point 1. There is now a_$observationRegistrar property, which is a storage for tracking and accessing data changes. It has two nonisolated functions access and withMutatuion to register access to observed properties and allow mutations accordingly. Object’s observation registrar’s primary responsibility is to keep and notify various observers about changes of this object (ViewModel) properties. You never need to create observationRegistrar yourself using @Observable macro.

Point 2. ViewModel now conforms to Observation.Observable protocol (no particular implementation for it, it’s rather just a signal to other APIs that this type supports observation).

Point 3. There is an @ObservationTracked property count and @ObservationIgnored private property _count, and that’s the part we’re going to have a closer look at.

// Result of expanding @ObservationTracked var count = 0

{
init(initialValue) initializes (_count ) {
_count = initialValue
}

get {
access(keyPath: \.count )
return _count
}

set {
withMutation(keyPath: \.count ) {
_count = newValue
}
}
}

...

@ObservationIgnored private var _count = 0

So the stored property count is becoming a computed property, and there is another private property _count added alongside it — this is where the original value is stored.

Inside the getter, ObservationRegistrar’s access function is called to register access to a specific property (in this case, to a stored private _count property) for observation.

Inside the setter, the _count is being mutated inside the closure of the withMutation function with keyPath.

How’s SwiftUI View being notified then?

The new Observation framework has this new function withObservationTracking(_ apply:onChange:).

It takes two closures — apply and onChange. The second closure will get called on the first change that happens to the properties that were accessed in the first closure.

So, under the hood, the system likely triggers some view updates inside onChange to react to changes of properties whose values are used in SwiftUI’sView. However, having this new macro, there is no need take care of any of these!

On a personal note, I would like to add I can’t celebrate this macro more, so good it is! Besides reducing the dependency on the Combine framework, it opens the way for more optimisation and better performance. Because, for obvious reasons, property-level observation decreses the number of unecessary view updates.

Happy coding!

--

--