00:06 Today we'll reimplement SwiftUI's new observation mechanism in
Swift. We all know the property wrappers ObservedObject
, StateObject
, and
EnvironmentObject
. By declaring a property with one of these wrappers, we
automatically observe the object that's stored in the property. If we access an
object without using one of the property wrappers, the object doesn't get
observed. But now, thanks to the new Observable
macro, it's the other way
around: we can automatically observe objects we access in our view's body
.
00:55 We've been exploring the macro a bit, and it seems to be an
improvement over the previous model. But it's also quite magical in a way. Just
by accessing a property on an object, SwiftUI somehow creates a connection
between our view and the object. And it doesn't matter where the object lives;
it can be a global variable, it can be our model layer, etc. To get some insight
into how the Observable
macro works, we're going to write something similar
ourselves.
Observation Mechanism
01:35 The source code for this observation mechanism is available in the
open source Swift codebase. We went back and forth between trying our own stuff
and checking how the Swift team implemented Observable
, and it all comes down
to a relatively simple idea: the execution of a view's body is wrapped in a
withObservationTracking
call, which collects every key path for every object
accessed in the body, and this all gets stored in a global access list. Later,
when one of the observed objects changes, the access list is used to update the
observers.
02:31 When we think about it, it makes sense that this happens via a
global variable, because withObservationTracking
is a global function. And
this global function cannot do anything but write to a global variable.
02:56 There are two gotchas to keep in mind. In SwiftUI, only one body
is executed at a time. If there were multiple bodies executed at once, this
observation mechanism would be trickier to get right. The other thing is that an
observer will be called in the willSet
property observer, i.e. before a
property is about to change. This means we cannot actually read and use the new
value when the callback closure happens, but we can invalidate our view, which
is the only thing that SwiftUI needs to do.
Trying It Out
03:34 Before we get to our own implementation, let's try out some of the
new APIs. We write a test
function that's called when our sample view appears,
just so that we have a place to run some code. After we import the Observation
framework, we can call the withObservationTracking
function. This function
takes two closures as its parameters: apply
and onChange
. In the apply
closure, we can read from an object and have this access tracked:
import SwiftUI
import Observation
func test() {
withObservationTracking({
}, onChange: {
})
}
struct ContentView: View {
var body: some View {
VStack {
Image(systemName: "globe")
.imageScale(.large)
.foregroundColor(.tint)
Text("Hello, world!")
}
.padding()
.onAppear { test() }
}
}
04:11 Let's say we have a Person
object marked with @Observable
.
When we access the name
property of a Person
instance inside the apply
closure, this creates an observation of that property:
final class Person {
var name = "Tom"
var age = 25
}
let sample = Person()
func test() {
withObservationTracking({
sample.name
}, onChange: {
print("On Change")
})
}
04:43 The first time the instance's name
changes, the onChange
closure is executed:
struct ContentView: View {
var body: some View {
VStack {
Image(systemName: "globe")
.imageScale(.large)
.foregroundColor(.tint)
Text("Hello, world!")
Button("Change Age") { sample.age += 1 }
Button("Change Name") { sample.name += "!" }
}
.padding()
.onAppear { test() }
}
}
05:12 When we click the "Change Age" button, nothing happens because the
age
property wasn't accessed in the apply
closure. But the first time we
click "Change Name," it prints the change message to the console. When we click
it again, the message isn't printed again.
05:39 If we also access age
in the apply
closure, the callback gets
called when we change either the name or the age property. And the observation
doesn't fire for any subsequent changes after this first callback:
func test() {
withObservationTracking({
let _ = sample.name
let _ = sample.age
}, onChange: {
print("On Change")
})
}
06:01 It's the same idea if we observe multiple objects: we get a single
callback the first time one of those objects changes a property we're interested
in.
06:12 If we print out the observed values, we can see that the old value
is printed, because the observer is called before a property changes. This is
when SwiftUI invalidates our view, and the view reads the new value in the next
cycle of the run loop.
06:49 To start replicating all of this, we can create a new project with
a library target.
Tracking Access
07:21 Let's start with a simple test. The first thing we want to test is
that we can keep track of the properties we access on an Observable
object.
Further on, when we can do an end-to-end test, we'll be able to remove this test
again, but it'll take some time to get there.
07:56 We add the Person
class and the sample
instance again so that
we have something to work with. In our test, we call withObservationTracking
—
which we'll have to write — and we access the instance's name
property in the
apply
closure:
final class Person {
var name = "Tom"
var age = 25
}
let sample = Person()
final class MyObservationTests: XCTestCase {
func testAccess() throws {
withObservationTracking {
let _ = sample.name
} onChange: {
}
}
}
08:39 We want to check that this access gets registered in some global
access list. We could write our test assertion after the
withObservationTracking
call, but since access lists will be scoped per call
of the apply
function and we'll reset them later on, it's better to write the
assertion at the end of the apply
closure itself. And the things we want to
assert are that accessList
is a dictionary of objects and accessed key paths,
that it contains an entry for the sample
object, and that this entry contains
the key path for the name
property:
final class MyObservationTests: XCTestCase {
func testAccess() throws {
withObservationTracking {
let _ = sample.name
XCTAssertEqual(accessList, [ObjectIdentifier(sample): Entry(keyPaths: [\Person.name])])
} onChange: {
}
}
}
10:23 The first thing to implement is the withObservationTracking
function. The apply
closure can return a result, so we make the whole function
generic over this result type:
func withObservationTracking<T>(_ apply: () -> T, onChange: @escaping () -> ()) -> T {
let result = apply()
return result
}
11:19 We can also define the global accessList
variable, which is a
dictionary with ObjectIdentifier
keys and Entry
structs as values:
var accessList: [ObjectIdentifier: Entry] = [:]
11:39 The Entry
struct contains a set of key paths, and it's
Equatable
so that we can compare values in our test:
struct Entry: Equatable {
var keyPaths: Set<AnyKeyPath> = []
}
Registrar
12:24 Now that we've defined the above types, the test compiles, but it
obviously fails because we aren't doing anything to track access to the
properties on Person
. To do this, we prefix the names of the stored properties
with underscores, and we create a computed property for each so that we can call
a registrar on the object whenever a property is read:
final class Person {
var name: String {
get {
_registrar.access(self, \.name)
return _name
}
set {
fatalError()
}
}
var age: Int {
get {
_registrar.access(self, \.age)
return _age
}
set {
fatalError()
}
}
var _name = "Tom"
var _age = 25
var _registrar = Registrar()
}
13:27 The computed properties and the _registrar
property will be
automatically generated by our Observable
macro later on.
13:38 Registrar
is a class with an access
method that takes any
object and a key path for that object:
final class Registrar {
func access<Source: AnyObject, Target>(_ obj: Source, _ keyPath: KeyPath<Source, Target>) {
}
}
14:07 In the original Observable
code, Registrar
is actually not a
class but a struct. But there's still a reference type, wrapped in a thread-safe
box, inside that struct. We're not going to talk about thread safety in this
series, so we can take a shortcut and write Registrar
directly as a reference
type.
14:28 In the access
method, we add the passed-in key path to the
access list's Entry
for the given object:
final class Registrar {
func access<Source: AnyObject, Target>(_ obj: Source, _ keyPath: KeyPath<Source, Target>) {
accessList[ObjectIdentifier(obj), default: Entry()].keyPaths.insert(keyPath)
}
}
15:48 Our test now passes, which means we're correctly tracking the
properties we access on the Person
object. But that's just one part of the
observation mechanism; we also have to call the onChange
closure when one of
the observed properties is about to change.
Observation Callbacks
16:49 Let's write another test. We again call
withObservationTracking
, reading a property on the sample
object in the
apply
closure. In onChange
, we want to track that the closure gets called,
so we create a local integer variable, and we increment its value with each
call:
final class MyObservationTests: XCTestCase {
func testObservation() throws {
var numberOfCalls = 0
withObservationTracking {
_ = sample.name
} onChange: {
numberOfCalls += 1
}
}
}
17:31 We assert that the number of callbacks is zero at first. After
changing the age
property, the number should still be zero, because we only
observe the name
property. Then, we write to name
, and we assert that the
number of callbacks changes to one. We also assert that the number doesn't keep
incrementing with subsequent changes, because the observation should only be
called once:
final class MyObservationTests: XCTestCase {
func testObservation() throws {
var numberOfCalls = 0
withObservationTracking {
_ = sample.name
} onChange: {
numberOfCalls += 1
}
XCTAssertEqual(numberOfCalls, 0)
sample.age += 1
XCTAssertEqual(numberOfCalls, 0)
sample.name.append("!")
XCTAssertEqual(numberOfCalls, 1)
sample.name.append("!")
XCTAssertEqual(numberOfCalls, 1)
}
}
18:14 To get the behavior described in this test, we need to implement
the setter parts of our computed properties. In each setter, we first call a
willSet
method on the registrar, and then we update the underscored backing
property:
final class Person {
var name: String {
get {
_registrar.access(self, \.name)
return _name
}
set {
_registrar.willSet(self, \.name)
_name = newValue
}
}
var age: Int {
get {
_registrar.access(self, \.age)
return _age
}
set {
_registrar.willSet(self, \.age)
_age = newValue
}
}
var _name = "Tom"
var _age = 25
var _registrar = Registrar()
}
19:11 When willSet
is called on the registrar, we need to retrieve
and call the observers of the given property. This means we need to keep track
of a list of observers per key path, so we add a dictionary that has key paths
as its keys and arrays of closures as its values:
final class Registrar {
typealias Observer = () -> ()
var observers: [AnyKeyPath: [Observer]] = [:]
}
20:54 In willSet
, we can now pull the observers for the given key
path out of the dictionary and call them:
final class Registrar {
typealias Observer = () -> ()
var observers: [AnyKeyPath: [Observer]] = [:]
func willSet<Source: AnyObject, Target>(_ obj: Source, _ keyPath: KeyPath<Source, Target>) {
guard let obs = observers[keyPath] else { return }
for ob in obs {
ob()
}
}
}
21:14 If we run the test, it still fails because we never insert
anything into the observers
dictionary. After calling apply
inside the
withObservationTracking
tracking, the access list is filled, and we need to
connect the accessed properties with the onChange
callback. But let's save
that for next time.