This episode is freely available thanks to the support of our subscribers

Subscribers get exclusive access to new and all previous subscriber-only episodes, video downloads, and 30% discount for team members. Become a Subscriber

We begin reimplementing Swift's new observation mechanism by tracking access to observable objects.

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.

Resources

  • Sample Code

    Written in Swift 5.9

  • Episode Video

    Become a subscriber to download episode videos.

In Collection

57 Episodes · 20h06min

See All Collections

Episode Details

Recent Episodes

See All

Unlock Full Access

Subscribe to Swift Talk

  • Watch All Episodes

    A new episode every week

  • icon-benefit-download Created with Sketch.

    Download Episodes

    Take Swift Talk with you when you're offline

  • Support Us

    With your help we can keep producing new episodes