EP 256 · Standalone · Nov 6, 2023 ·Members

Video #256: Observation in Practice

smart_display

Loading stream…

Video #256: Observation in Practice

Episode: Video #256 Date: Nov 6, 2023 Access: Members Only 🔒 URL: https://www.pointfree.co/episodes/ep256-observation-in-practice

Episode thumbnail

Description

We take all we’ve learned about the Observation framework and apply it to a larger, more real world application: our rewrite of Apple’s Scrumdinger demo. We’ll see what changes are easy to make, what changes are a bit trickier, and encounter a bug that you’ll want to know about.

Video

Cloudflare Stream video ID: aa76ab28d9b2dc96386be5547628c4ed Local file: video_256_observation-in-practice.mp4 *(download with --video 256)*

References

Transcript

0:05

For the last four weeks we have gone deep into the new Observation framework in Swift 5.9. We’ve seen what it takes to use the tools, we’ve looked at the actual source code of the tools to roughly understand how they work, and we’ve even discussed a variety of gotchas and things to be aware of when using the tools.

0:22

But in those episodes we didn’t really build anything real world with the new tools. The little demo app we created served as a nice testbed for understanding observability, but in the real world we come across much different problems. Stephen

0:35

So, it would be nice if we could see what it looks like to use these tools in a moderately complex, real world application. And luckily for us we actually built such an application over the course of 7 episodes where we discussed modern SwiftUI concepts. This included things like navigation, side effects, domain modeling, controlling dependencies and testing.

0:53

And to show off these techniques we re-built a fun application that Apple released as demo code a few years back called Scrumdinger. And after that series finished we even open sourced our version of the Scrumdinger so that we could show what we think goes into making a modern SwiftUI application. Brandon

1:08

However, that series was done before we had these new observation tools, so perhaps we can take that code base and update it to use observability.

1:17

Let’s take a look. SyncUps tour

1:19

I have the project for the app that we previously built open right now. It was originally called Standups, but we recently renamed it to SyncUps, and it’s a mostly direct port of Apple’s Scrumdinger application, but using some techniques that we think are better suited for complex applications.

1:37

Let’s run the app in the simulator to see what all it does. We can start by tapping the “+” button to bring up a sheet for entering the details of a new sync-up. Then when we tap “Add” the sheet is dismissed and the sync-up has been added to the root list. Then we can tap the sync-up to drill down to its details. From here we can edit the sync-up or start a new meeting inside the sync-up. When we start a meeting we are asked for speech recognition authorization, and if we grant it then the app is live transcribing our audio in the background. We also see a timer going in the background.

2:56

We can then either wait for all of the speakers to get a turn, or we can just end the meeting early. When the meeting ends we are popped back to the detail screen and a new meeting has been inserted into the screen. And further, if we force quit the app, relaunch the app, we will see that our sync-up has been persisted and even the freshly recorded meeting.

3:35

So, that’s the basics of what our SyncUps app offers us. It’s a moderately complex application involving multiple forms of navigation to features that need to communicate with each other. And those features deal with pretty complex side effects, such as timers, persistence and speech recognition. And there’s even a whole test suite, both unit tests and UI tests, but we will look at that a bit later.

4:02

We even built the application twice in two different navigation styles. We first built the application using what we like to call “tree-based” navigation. This is where you model all of the places a feature can navigate to as an optional enum.

4:17

For example, in the detail screen of a sync-up, there are 4 places one can navigate to, all of which are mutually exclusive and therefore best modeled as an enum: enum Destination { case alert(AlertState<AlertAction>) case edit(SyncUpFormModel) case meeting(Meeting) case record(RecordMeetingModel) }

4:29

Either an alert can be shown for deleting a sync-up, or we can have the “Edit” sheet presented, or a past recorded meeting can be presented, or the record meeting screen can be presented.

4:35

And then we hold onto a single piece of optional state to represent when a destination is activated: @Published var destination: Destination?

4:50

This gives us some incredible powers for understanding precisely what screen is currently being presented. Typically in SwiftUI one would have to model all of these destinations as 4 independent pieces of optional state, leading you to 16 different states of them being nil or non- nil . But, only 5 of those states are actually valid, they are either all nil or exactly one is non- nil , leaving you with 11 completely nonsensical states to fight with. That kind of uncertainty bleeds complexity into a code base, and we just don’t have to worry about that here.

5:25

So, this is called “tree-based” navigation because each feature has one of these Destination enums for their possible destinations, and they nest into a tree-like structure. For example, we can modify the entry point of the application to construct the model that powers the application in a very specific way.

5:42

We can start by seeing what destinations the sync-ups list can navigate to: SyncUpsList( model: SyncUpsListModel( destination: <#SyncUpsListModel.Destination?#> ) )

5:51

We can simply type . and ESC and let Xcode autocomplete all of the possibilities. So, let’s choose to navigate to the sync-up detail screen: SyncUpsList( model: SyncUpsListModel( destination: .detail( SyncUpDetailModel( destination: <#SyncUpDetailModel.Destination?#>, syncUp: .mock ) ) ) )

6:14

Then we can again choose a destination to navigate to from the detail screen, and so let’s choose the record screen: SyncUpsList( model: SyncUpsListModel( destination: .detail( SyncUpDetailModel( destination: .record( RecordMeetingModel( destination: <#RecordMeetingModel.Destination?#>, syncUp: .mock ) ), syncUp: .mock ) ) ) )

6:41

And then we can again choose a destination to navigate to, and so let’s show an alert for asking the user if they want to end the meeting early: SyncUpsList( model: SyncUpsListModel( destination: .detail( SyncUpDetailModel( destination: .record( RecordMeetingModel( destination: .alert( .endMeeting(isDiscardable: true) ), syncUp: <#SyncUp#> ) ), syncUp: <#SyncUp#> ) ) ) )

7:03

And now we can very clearly see the tree-like structure here. Each feature acts as a node in the tree, and each case of the Destination enum acts as a branch. Deciding where you want to deep link into your application is a simple matter of traversing down to a node of the tree by constructing a deeply nested enum value. It’s pretty incredible!

7:32

And we can launch the application to see that we are automatically deep linked into the record screen with an alert already presented. If we decide to end the meeting early and discard the meeting, we will be popped back to the detail screen. And then we can hit back again to go all the way back to the root screen.

7:48

So, that’s all very cool, but then a few weeks after releasing the final “Modern SwiftUI” episode we held a live stream where we live refactored the tree-based application to instead use stack-based navigation. This is where you model the navigation destinations of your root feature as a flat array instead of a deeply nested enum.

8:18

We can switch to the stack-based target and run it in the simulator to see that it behaves basically the same. But now at the root there is a NavigationStack view, which was introduced in iOS 16, to power drilling down to features.

8:57

And we can deep link into a specific state of the app by altering the entry point so it builds up an array of features that we want to push on the stack. The equivalent of navigating to the detail screen and then the record screen and then the alert looks like this in a stack-based application: AppView( model: AppModel( path: [ .detail( SyncUpDetailModel(syncUp: .mock) ), .record( RecordMeetingModel( destination: .alert( .endMeeting(isDiscardable: true) ), syncUp: .mock ) ) ], syncUpsList: SyncUpsListModel() ) ) Notice that the alert is still, shown in a tree-style where there is an optional enum powering its presentation. This is because stack-based navigation only makes sense when used with an actual NavigationStack SwiftUI view, whereas tree-based navigation can be used for many types of navigation, such as sheets, popovers, alerts and more.

10:02

Running this in the simulator we will see that while we’ve drilled down to the record screen, the alert is not immediately shown, and this is due to some unfortunate bugs in stack-based navigation when showing an alert at the same time.

10:38

So, this all looks good, but there is also a full unit test suite that exercises many subtle edge cases of our application’s logic, as well as a few UI tests to exercise broad behavior in an actual simulator. There’s a test suite for both the tree-based and stack-based applications, but let’s quickly run the suite for the tree-based app…

11:26

The whole suite passes, and this will serve as a great sounding board for us as we refactor our application. With each major change we make to the app we will run the test suite and make sure everything is copacetic. Observable SyncUps

11:48

So, this is how we personally like to build modern, vanilla SwiftUI applications. It has an emphasis on controlling dependencies, concisely modeling the domains for navigation, and keeping as much of the state and logic as possible in the observable objects rather than the view so that we get deep-linking for free. Stephen

12:06

Let’s now see what it takes to convert this application to use Swift 5.9’s new Observation framework. We don’t have to be intimately familiar with how the application is currently built to do this work. Hopefully it’s just a matter of deleting some unneeded code and maybe adapting a few small parts.

12:22

Let’s start with the tree-based version of the app, and further let’s start with a leaf feature of the app. That is, one that does not further navigate to other features. One such feature is the SyncUpForm :

12:34

This is the feature that allows you to enter the details of a sync-up, such as its title, duration, theme and attendees. It’s used to both add a brand new sync-up as well as edit an existing sync-up.

12:47

Let’s start by converting the SyncUpFormModel to use the new @Observable macro. This means we can delete the ObservableObject conformance and stop using the @Published property wrapper: @Observable class SyncUpFormModel { var focus: Field? var syncUp: SyncUp … }

13:05

Now we immediately get a compilation error on the line where we provide a UUID dependency to the feature: @Dependency(\.uuid) var uuid Property wrapper cannot be applied to a computed property

13:12

Unfortunately Swift macros do not play so nicely with property wrappers.

13:16

The reason for this is that the compiler magic that powers property wrappers is run after all compiler plugins have run, and hence after all macros are expanded. This means that when @ObservationTracked is applied to a property wrapper field like this: @ObservationTracked @Dependency(\.uuid) var uuid

13:40

…it is secretly expanded to code like this: @ObservationIgnored private var _uuid: UUIDGenerator @Dependency(\.uuid) var uuid: UUIDGenerator { get { … } set { … } }

13:46

…where now the property wrapper has been applied to a computed property, rather than the secret, underscored stored property. And property wrappers cannot be applied to computed properties, ever, and so that is why this doesn’t work.

14:00

Now, you may wonder what would happen if the macro tried to be clever for property wrappers by moving any of them from the computed property over to the stored property: @ObservationIgnored @Dependency(\.uuid) private var _uuid: UUIDGenerator var uuid: UUIDGenerator { get { … } set { … } }

14:10

…however, even that would not work. Now the property wrapper is being applied to an underscored field, not the original field. And so you would need to be intimately familiar with the inner workings of the @Observable macro in order to know that your property wrapper has now been applied to _uuid , and hence you if you ever need to access the wrapper’s projected value with dollar syntax, you would need to do so like this: self.$_uuid

14:37

…and that is very strange. And that’s not to even mention that this underscored field is private, and hence not even accessible outside the declaring scope of the type.

15:00

A huge part of writing concise macros is to make their magic as invisible as possible to the user. Ideally, users of the macro should not need to know about these underscored properties, but the veil must be lifted in order to use property wrappers.

15:12

However, we don’t actually need to observe changes to the uuid dependency. In fact, observing it doesn’t really even make sense. It’s just a static thing. So, we can tell the @Observable macro to ignore that property by applying the @ObservationIgnored macro: @ObservationIgnored @Dependency(\.uuid) var uuid

15:32

Now the SyncUpFormModel compiles just fine, and hopefully that’s all we have to do. That would be pretty amazing because it would mean we get to use a simple, mostly vanilla Swift class, yet a SwiftUI view can observe all the changes on the inside.

15:48

The only other compilation error we have in this file is where we hold onto the model in the view: @ObservedObject var model: SyncUpFormModel Generic struct ‘ObservedObject’ requires that ‘SyncUpFormModel’ conform to ’ObservableObject’

15:57

This isn’t compiling because the @ObservedObject property wrapper requires that the class conform to the ObservableObject protocol, which we ditched a moment ago. And this is totally fine, we just should not be using the @ObservedObject property wrapper.

16:02

Instead, maybe we can just hold onto the model as a simple let : let model: SyncUpFormModel

16:06

Well, that fixes one error, but causes another: TextField("Title", text: self.$model.syncUp.title) Value of type ‘SyncUpFormView’ has no member ’$model’

16:12

By holding onto a simple let we are not allowing ourselves to deriving bindings to the model for UI components such as text fields.

16:20

We can fix this by using the @Bindable property wrapper: @Bindable var model: SyncUpFormModel

16:26

Now we can derive bindings from the model by using the $model syntax, and in fact the entire application is now building. So, we have successfully converted our first feature to the new @Observable macro.

16:33

We can run the app in the simulator to see that the form feature still works as it did before. We are able to add new sync-ups and edit existing sync-ups. But even better, let’s run the test suite to make sure it still passes. We have a very extensive test suite, with both unit tests that exercise just the observable model layer of the application, as well as a UI tests that actually boot up the app and play a sequence of user actions to assert what happens.

17:09

If we run tests we will have to wait a bit of time… but eventually the entire suite passes! This gives me quite a bit of confidence that the app still works as it did previously.

17:30

There’s another leaf feature with no further navigation destinations, and so hopefully it will also be straightforward to convert. It’s the RecordMeeting feature.

17:40

Let’s drop the ObservableObject conformance, annotate the class with the @Observable macro, drop all of the @Published property wrappers, and then add @ObservableIngored to all of our dependencies: @MainActor @Observable class RecordMeetingModel { var destination: Destination? var isDismissed = false var secondsElapsed = 0 var speakerIndex = 0 let syncUp: SyncUp private var transcript = "" @ObservationIgnored @Dependency(\.continuousClock) var clock @ObservationIgnored @Dependency(\.soundEffectClient) var soundEffectClient @ObservationIgnored @Dependency(\.speechClient) var speechClient … }

17:58

And just like that this class is compiling and is now powered by the new Observation framework.

18:01

There is just one compiler error where we are using the @ObservedObject property wrapper. We can’t make this a simple let because this view does need bindings, so we will use the @Bindable property wrapper: @Bindable var model: RecordMeetingModel

18:08

And just like that we have converted yet another feature to the new Observation framework.

18:13

And if we run the application in the simulator we will see that it seems to work exactly as it did before, and if we run the full test suite everything passes! Converting non-leaf features

18:53

This is seeming quite easy!

18:55

So far all we’ve had to do is remove some of the old adornments such as @Published , @ObservedObject and ObservableObject , and replace them with the @Observable macro and the @Bindable property wrapper. Brandon

19:11

Well, unfortunately it’s not always going to be this easy. Let’s take a more complicated example: the SyncUpDetail feature. This is our first non-leaf feature because it is capable of navigating to other features, and it also needs to communicate back to the parent. So, let’s see what happens as we naively convert it over to the new @Observable macro.

19:33

We can start with the easy part by updating the SyncUpDetailModel class to use the macro: @MainActor @Observable class SyncUpDetailModel { var destination: Destination? { didSet { self.bind() } } var isDismissed = false var syncUp: SyncUp @ObservationIgnored @Dependency(\.continuousClock) var clock @ObservationIgnored @Dependency(\.date.now) var now @ObservationIgnored @Dependency(\.openSettings) var openSettings @ObservationIgnored @Dependency(\.speechClient.authorizationStatus) var authorizationStatus @ObservationIgnored @Dependency(\.uuid) var uuid … }

20:10

And then update the view to use the @Bindable property wrapper since we need to be able to derive bindings to the model: @Bindable var model: SyncUpDetailModel

20:26

But even after those changes we still have a compilation error: self.destinationCancellable = syncUpDetailModel.$syncUp Value of type ‘SyncUpDetailModel’ has no member ’$syncUp’

20:30

This error is in the parent feature, SyncUpsListModel , where it wants to perform a little bit of glue integration between it and the SyncUpDetailModel . It does this by listening to changes to the syncUp value in the detail model, and when it sees a change it plays it back to its own syncUp value.

20:51

This was quite easy to do previously because the @Published property wrapper immediately gave us access to a Combine publisher that we could subscribe to. However, as we pointed out in our most recent series, the new Observation framework provides no such tool. Hopefully we will get something in the future, but there’s nothing right now.

21:14

So, what we need to do is use the withObservationTracking tool so that we can observe any changes to the syncUp property inside the detail feature, and when we detect it we play it back to the sync-ups list model. And as we’ve seen before, using this tool can be a little awkward because you need to set up a separate function to invoke withObservationTracking , and then you need to tie the loop by having the function recursively call itself when a change is detected so that it can start listening for the next change. It’s strange, but at least we can keep it all local to the scope where we were previously doing the Combine work: // self.destinationCancellable = syncUpDetailModel.$syncUp // .sink { [weak self] syncUp in // self?.syncUps[id: syncUp.id] = syncUp // } func observe() { withObservationTracking { _ = syncUpDetailModel.syncUp } onChange: { [weak syncUpDetailModel] in Task { @MainActor [weak syncUpDetailModel] in guard let syncUpDetailModel else { return } self.syncUps[ id: syncUpDetailModel.syncUp.id ] = syncUpDetailModel.syncUp observe(syncUpDetailModel: syncUpDetailModel) } } } observe()

23:16

This seems really intense, and we probably would not really want to maintain code like this. Even just the presence of the unstructured task has me really worried that this is going to complicate writing tests and afraid that we have maybe introduced a small race condition here.

23:36

So, instead of this, we are just going to have the SyncUpDetailModel expose another callback closure to let the parent know when its sync-up has changed. We will follow the current pattern, which is to introduce a closure and it will default to an “unimplemented” closure: var onSyncUpUpdated: (SyncUp) -> Void = unimplemented( "SyncUpDetailModel.onSyncUpUpdated" )

24:17

We discussed this pattern in detail in our “Modern SwiftUI” series, but in a nutshell this provides a default to this closure so that if it is ever invoked without the parent overriding it, which would be really bad, then a runtime warning will be raised as well as a test failure when tests are running. This gives us a nice balance between safety and ergonomics for facilitating communication between parent and child domains.

24:45

And then we will tap into the didSet of the syncUps property to notify the parent domain: var syncUp: SyncUp { didSet { self.onSyncUpUpdated(self.syncUp) } }

24:56

And the parent domain can start listening for these changes: syncUpDetailModel.onSyncUpUpdated = { [weak self] syncUp in self?.syncUps[id: syncUp.id] = syncUp }

25:20

That’s quite a bit simpler, and this is probably the style you will want to reach for in general instead of using withObservationTracking . It’s just too awkward of a tool to use in every day code.

25:32

But with that done everything is compiling, and should behave exactly as it did before. But let’s quickly run the full test suite to see if there is anything we are missing.

25:53

And it looks like we do have a few failures. However, they are only failures in our unit tests, not our UI tests. Those tests passed with flying colors because nothing we did broke any of our logic.

26:07

Had we forgotten to invoke onSyncUpUpdated from the didSet like this: var syncUp: SyncUp { didSet { // self.onSyncUpUpdated(self.syncUp) } }

26:18

…then that definitely would have introduced a bug into our application. In fact, if we run tests again we will see more failures, including ones in the UI tests.

26:53

This shows that our tests really are keeping us in check, but let’s undo that change and run the suite again so we can go back to the unit test failures we had a moment ago.

27:12

Luckily these failures are not logical errors that would cause actual bugs in our application, but rather they are failures letting us know that we have allowed the onSyncUpUpdated closures to be called without being overridden. For example, in the testEdit test: Unimplemented: SyncUpDetailModel.onSyncUpUpdated …

27:38

This is a good test failure to have. It’s letting us know there is a little bit of hidden logic bundled into our model that we are not asserting on.

28:03

We could easily silence the error by just doing this: model.onSyncUpUpdated = { _ in }

28:25

But if we wanted to strengthen this we could further assert that the onSyncUpUpdated closure is called correctly by using a test expectation: let onSyncUpUpdatedExpectation = self.expectation( description: "onSyncUpUpdated" ) defer { self.wait(for: [onSyncUpUpdatedExpectation], timeout: 0) } model.onSyncUpUpdated = { _ in onSyncUpUpdatedExpectation.fulfill() }

29:10

Now we have strengthened this test.

30:04

And we can do the same in the testRecordWithTranscript test. Now the full test suite passes, and I have a lot of confidence that things will work properly if we run the app in the simulator.

30:24

We are getting really close to a fully converted application. All that is left is SyncUpsList . Let’s start with a quick update of the SyncUpsListModel : @MainActor @Observable final class SyncUpsListModel { var destination: Destination? { didSet { self.bind() } } var syncUps: IdentifiedArrayOf<SyncUp> private var destinationCancellable: AnyCancellable? private var cancellables: Set<AnyCancellable> = [] @ObservationIgnored @Dependency(\.dataManager) var dataManager @ObservationIgnored @Dependency(\.mainQueue) var mainQueue @ObservationIgnored @Dependency(\.uuid) var uuid … }

30:54

And we’ll update the view to use a @Bindable model: @Bindable var model: SyncUpsListModel

31:07

This fixes some compiler errors, but also introduces new ones. In particular, we have this little bit of gnarly logic: self.$syncUps .dropFirst() .debounce(for: .seconds(1), scheduler: self.mainQueue) .sink { [weak self] syncUps in try? self?.dataManager.save( JSONEncoder().encode(syncUps), .syncUps ) } .store(in: &self.cancellables)

31:12

This is our persistence logic. It observes any change to the syncUps array held at the root of the application, debounces it for 1 seconds so as to not thrash the hard disk with too many writes, and then saves the current sync-ups data to disk.

31:41

We can no longer do this in this fashion because we do not have access to the $syncUps publisher that the @Published property wrapper gave us access to. // self.$syncUps // .dropFirst() // .debounce(for: .seconds(1), scheduler: self.mainQueue) // .sink { [weak self] syncUps in // try? self?.dataManager.save( // JSONEncoder().encode(syncUps), .syncUps // ) // } // .store(in: &self.cancellables)

31:49

Instead we will tap into the didSet of the syncUps property and perform the saving there: var syncUps: IdentifiedArrayOf<SyncUp> { didSet { try? self.dataManager.save( JSONEncoder().encode(syncUps), .syncUps ) } }

32:28

This will technically work, but we’ve also lost the debouncing logic.

32:34

All debouncing means is that when the syncUps property is changed, if 1 second hasn’t passed since the last time it was changed, then we want to skip saving, and instead wait another second before saving. And that continues on and on and on.

33:01

We can capture this by managing some additional private state, in particular a Task that represents the debounced save operation: private var saveDebouncedTask: Task<Void, Error>? And then when the didSet is called we can cancel any inflight saveDebouncedTask , start up a new one, sleep for a second, and then finally perform the save: private var saveDebouncedTask: Task<Void, Error>? var syncUps: IdentifiedArrayOf<SyncUp> { didSet { self.saveDebouncedTask?.cancel() self.saveDebouncedTask = Task { try await Task.sleep(for: .seconds(1)) try self.dataManager.save( JSONEncoder().encode(syncUps), .syncUps ) } } }

34:00

However, if you’ve been following Point-Free for any amount of time you will know that it is not a good idea to reach out to Task.sleep directly like this. It makes it difficult to test because you will have to wait for real world time to pass in order to assert on how your feature behaves.

34:15

And because of that we will go ahead and be proactive by injecting a dependency on a clock into our feature: @ObservationIgnored @Dependency(\.continuousClock) var clock

34:40

…so that we can use it in the debouncing logic: self.saveDebouncedTask = Task { try await self.clock.sleep(for: .seconds(1)) try self.dataManager.save( JSONEncoder().encode(syncUps), .syncUps ) } And now we no longer need the mainQueue dependency: // @ObservationIgnored // @Dependency(\.mainQueue) var mainQueue

34:44

I would now hope that this all just works as it did before.

34:46

However, before running in the simulator, let’s run the test suite and see what it tells us. If we run the full suite, we immediately get a failure complaining about a dependency being used that was not explicitly overridden. This is a great test failure to have because it prevents us from accidentally using live dependencies in tests, where it is almost never a good idea to do so.

35:16

The dependency in question is the \.continuousClock that we just added to the feature, so no big surprise there. We can simply swap out immediate schedulers for immediate clocks, and test schedulers for test clocks: $0.clock = ImmediateClock() … let clock = TestClock() … $0.clock = clock

36:35

With that done if we run the entire suite we will see that the unit tests run immediately and pass, but eventually we start to see some test failures come through. And it looks like pretty much every UI test is failing. In fact, if we look at what is going on in the simulator while the test is running we will see that once the app launches, nothing seems to be happening.

37:00

Well, this seems worrisome. Let’s launch the simulator ourselves so that we can see what is going on. When we do that we will see that no buttons seem to work at all. The “+” button in the top-left doesn’t work, nor does tapping on one of the previously created sync-ups. Over observation bugs

37:17

So this seems odd. We’ve converted this feature over to the new @Observable macro just as we did all the other ones, yet somehow we’ve broken the app. Stephen

37:25

Well, this is an extremely subtle problem, and it actually took us a very long time to figure out what was going on, but there is a huge gotcha with the new observable model machinery. It turns out we have accidentally introduced some over-observation of state in the root view of the application. That is causing a whole new model to be created and passed to the SyncUpsListView , which then completely resets all of the state.

37:48

It’s a gnarly bug, so let’s take a close look.

37:52

Let’s expand the macro on SyncUpsListModel and put a breakpoint in the get accessor of the destination property.

38:15

When we run the application we will see it immediately catch, which is expected because the SyncUpsList view needs to check that property to see if it should present a sheet, show an alert, or drill down to the detail screen.

38:28

However, what is not so expected is that the SyncUpsApp is in the stack trace: #0 0x0000000104cdfe64 in SyncUpsListModel.destination.getter at /var/folders/fv/yl8wwylx1k50zjydyhttq5p80000gn/T/swift-generated-sources/@__swiftmacro_18SyncUps_TreeBased0A9ListModelC11destination18ObservationTrackedfMa_.swift:7 #1 0x0000000104cdfb5c in SyncUpsListModel.bind() at SyncUps-TreeBased/SyncUps/SyncUpsList.swift:103 #2 0x0000000104ce2bb8 in $defer #1 () in SyncUpsListModel.init(destination:) at SyncUps-TreeBased/SyncUps/SyncUpsList.swift:51 #3 0x0000000104ce2a00 in SyncUpsListModel.init(destination:) at SyncUps-TreeBased/SyncUps/SyncUpsList.swift:62 #4 0x0000000104ce24f0 in SyncUpsListModel.__allocating_init(destination:) () #5 0x0000000104cd0624 in closure #1 in SyncUpsApp.body.getter at SyncUps-TreeBased/SyncUps/SyncUpsApp.swift:15 #6 0x000000010b5dcb08 in WindowGroup.init(content:) () #7 0x0000000104cd02f8 in SyncUpsApp.body.getter at SyncUps-TreeBased/SyncUps/SyncUpsApp.swift:7

38:35

If we follow the stack trace backwards we will see that the destination property is accessed due to the bind method in the model, which is necessary to set up the communication channel between this feature and any feature it presents. Going back more we see that bind is called from the init , which is done so that if we deep link into a child feature being presented we will set up that communication channel.

38:53

And then going back further we land in the SyncUpsApp . That is not great actually. Thanks to our deep dive into observation from previous episodes, we now know that the mere act of touching the destination property automatically subscribes the view to any changes of that property. That means when the destination is mutated, the SyncUpsApp re-computes its body, causing a whole new SyncUpsListModel to be created, thus reseting state back to the defaults. This would definitely explain why we do not navigate anywhere when we tap on something. The state is mutated for a brief moment, but then overwritten with a fresh model with all data reset back to defaults.

39:33

And we can confirm this by putting a print statement in the init of SyncUpsListModel : init( destination: Destination? = nil ) { print("\(Self.self).init") … }

39:40

Running the app we will see that a whole new SyncUpsListModel created every single time we tap on a button in the app. That is definitely very wrong.

39:55

The problem appears to be calling the bind method synchronously in the init : defer { self.bind() }

40:00

…which causes the destination field to be accessed, and that is causing the SyncUpsApp to think it needs to subscribe to changes in the destination state when it really does not.

40:10

There are a few ways we can address this problem. We could introduce a thread hop to calling the bind method so that the SyncUpsApp doesn’t see us access the destination property, and hence does not subscribe to its changes: defer { DispatchQueue.main.async { self.bind() } } That seems really hack-y, but sadly it does get the job done.

40:31

And of course we would not want to keep this live DispatchQueue in our model code because that will make it annoying to test. Really we should add back a dispatch queue dependency: @Dependency(\.mainQueue) var mainQueue …and ignore it: @ObservationIgnoreed @Dependency(\.mainQueue) var mainQueue …and then use that dependency: defer { self.mainQueue.async { self.bind() } }

40:40

That really seems like a pain.

40:42

Alternatively we could also set up a kind of “on appear” endpoint in the model and do this work: func onAppear() { self.bind() }

40:58

…but if we call that from the scene’s onAppear then it will actually be called every time the view appears. Not just the first time, which is what we want since this is only needed for the first time the model is initialized. So really we should do more work in the view to make sure that this onAppear method is only ever called a single time.

41:21

Again, that is seeming like a huge pain.

41:23

Well, there is another alternative, and it’s in some sense simpler but also still a bit strange and, in our opinion, still a hack. Instead of calling bind after a runloop tick…

41:37

…let’s instead only access the underscored version of the model’s state from within the bind method: private func bind() { switch self._destination { … } } Now, what does this mean?

41:42

Well, let’s expand the macro for our model again to remind ourselves what the underscored properties are for…

41:52

We will find that _destination is defined as the following: ObservationIgnored private var _destination: Destination?

41:57

Recall that the @Observable macro does a bit of sleight of hand magic behind the scenes by swapping out our model’s properties for computed properties so that it can tap into the get and set accessors, and then creates new stored properties with underscores for holding the actual data of the model.

42:12

And further, this private, underscored property is marked as @ObservationIgnored , which means we are free to access it without worry of subscribing to its changes and we are free to mutate it without worry of notify others that the property was mutated. In short: this property is completely disconnected from the observation machine.

42:38

So, that sounds exactly like what we want. We want to be able to access and mutate certain properties in the init and bind of SyncUpsListModel without accidentally subscribing to changes when we don’t mean to.

42:50

And with that one small change the app goes back to working! We can navigate to the detail of a sync-up, or bring up the sheet to add a sync-up.

43:04

So, this is a lot simpler than the tricks we were trying to do with the dispatch queue, but in our opinion it does still seem a bit hack-y. Recall that the Swift language acquired a completely new feature just so that macros could hide away these underscored properties from us. That’s what this stuff is in the expand macro code: @storageRestrictions(initializes: _destination) init(initialValue) { _destination = initialValue }

43:31

This is what allows us to use the non-underscored destination property in the initializer even though it is a computed property. Prior to Swift 5.9 that simply was not allowed, and the whole reason this feature was added to Swift was so that macros could add underscored properties to your types without you having to think about it. Ideally you shouldn’t need to know about such implementation details of the macro, but now we are seeing clear as day here that that is not always the case.

43:55

However, even with this change our app still has bugs. We could of course run the app ourselves and go through the app with a fine-toothed comb to root out the bugs, but we have a much better tool at our disposal. The test suite!

44:06

Let’s just run the test suite and see where things go wrong.

44:17

Looks like we are getting failures in the tests that exercise deleting sync-ups, editing them, and even in recording. Very strange. Let’s run the simulator and try deleting our current sync-up.

44:33

Well, sure enough it doesn’t work! What’s going on?

44:44

Well, yet again we are accidentally observing too much state, but this time it is the syncUps property instead of the destination property.

45:04

I guess we just have to be super vigilant and make sure to use the underscored attributes throughout the entire init so that we don’t accidentally observe these properties in the parent: init( destination: Destination? = nil ) { defer { self.bind() } self._destination = destination self._syncUps = [] do { self._syncUps = try JSONDecoder().decode( IdentifiedArray.self, from: self.dataManager.load(.syncUps) ) } catch is DecodingError { self._destination = .alert(.dataFailedToLoad) } catch { } }

45:19

Now, finally, when we run the app the delete seems to work, and if we run the test suite the whole suite passes.

45:45

This is starting to feel very, very tricky. The innocent act of initializing the properties of our model caused a very subtle bug to creep into our application. This kind of thing leaks uncertainty into a code base, where you never know what small, seemingly innocuous change is going to have knock-on effects in the rest of your application.

46:05

And personally, I’m starting to think the best bet is to just always use the underscored properties when initializing values in an observable model. Sure it means we have to be intimately familiar with the inner workings of the @Observable macro, but on the other hand it is way too easy to accidentally introduce over-observation in our views. Stack based refactor Brandon

46:22

And with that we have fully converted our SyncUps app to use the new @Observable macro. We were able to delete quite a bit of code along the way, and we can now be sure that our views only observe the state that is actually accessed in the view. That did lead to a gotcha in which accessing a field in the initializer of a model led a parent view to observe that state, which is definitely not what we would want. It’s a big gotcha, but luckily there’s an easy work around.

46:47

Remember that we actually built SyncUps in two different but complementary ways. First we built it using what we like to call “tree-based navigation”. This is where you express navigation destinations in a feature with enums and optionals. Then later we rebuilt the application using what we like to call “stack-based navigation”. This is where you express drill-down navigation as a flat array of data that represents the current stack of features being presented.

47:13

Let’s quickly update the stack-based application to use the @Observable macro. It should be quite straightforward after all we have learned from converting the tree-based app, so let’s do it quickly.

47:25

Let’s switch our active target to “SyncUps-StackBased”, and this target has a full test suite just like the tree-based version, so let’s quickly run that suite to make sure we are in good shape…

47:44

Everything is passing.

47:47

And just as we did with the tree-based version of the app, we will start with the leaf features. In fact, the code for the leaf features is identical to the ones in the tree-based version. This is because leaf features don’t navigate to anywhere else, and so they are completely oblivious to the concept of tree vs stack based navigation.

48:08

For example, we can copy all the code from the SyncUpForm.swift file in the tree-based target, paste it into the corresponding file in the stack-based target, and everything still compiles…

48:26

And we can even run the test suite to see that everything passes.

48:32

The other leaf feature in this application is the RecordMeeting feature. Let’s again copy-and-paste the version of this file from the tree-based app into the stack-based app and see if it magically works…

48:51

Well, we do get one compiler error.

48:54

Due to how navigation stacks work in iOS, the RecordMeetingModel object must be Hashable . The tree-based version of the app did not require this, and so we do need to bring back that functionality.

49:12

We can make the class conform to Hashable : class RecordMeetingModel: Hashable { … } And then we can add the implementations of hash(into:) and == in the only sensible way possible: nonisolated func hash(into hasher: inout Hasher) { hasher.combine(ObjectIdentifier(self)) } nonisolated static func == ( lhs: RecordMeetingModel, rhs: RecordMeetingModel ) -> Bool { lhs === rhs } …by simply using the object’s identity.

49:45

And with that the project is compiling, and even the whole test suite passes…

50:02

So, the leaf features in the stack-based app work exactly as they did over in the tree-based app.

50:15

Let’s now try a non-leaf feature. Let’s start with the SyncUpDetail feature. It’s quite similar to the one we did in the tree-based version of the app, but it has enough differences that we might as well approach it from scratch.

50:30

We’ll start by removing all the old ornamentations and replacing it with the new @Observable macro: @MainActor @Observable class SyncUpDetailModel: Hashable { var destination: Destination? var isDismissed = false var syncUp: SyncUp @ObservationIgnored @Dependency(\.continuousClock) var clock @ObservationIgnored @Dependency(\.date.now) var now @ObservationIgnored @Dependency(\.openSettings) var openSettings @ObservationIgnored @Dependency(\.speechClient.authorizationStatus) var authorizationStatus @ObservationIgnored @Dependency(\.uuid) var uuid … }

50:46

And next we’ll update the view to use the @Bindable property wrapper: struct SyncUpDetailView: View { @Bindable var model: SyncUpDetailModel … }

50:50

The only compiler error we have is related to the fact that we no longer have a publisher we can use in the parent domain to integrate the features together: self.detailCancellable = model.$syncUp .sink { [weak self] syncUp in self?.syncUpsList.syncUps[id: syncUp.id] = syncUp }

50:59

We will handle this in the exact same way we did over in the tree-based app. We will add a new callback closure to allow the detail feature to communicate to the parent that the sync-up has been updated: var onSyncUpUpdated: (SyncUp) -> Void = unimplemented( "SyncUpDetailModel.onSyncUpUpdated" )

51:15

And whenever the syncUp field is changed in the detail feature we will invoke that closure: var syncUp: SyncUp { didSet { self.onSyncUpUpdated(self.syncUp) } }

51:22

And then in the parent we will tap into that closure to update the syncUpsList ’s data: model.onSyncUpUpdated = { [weak self] syncUp in self?.syncUpsList.syncUps[id: syncUp.id] = syncUp }

51:37

With that change the project is back to compiling, and everything should work exactly as it did before. But to be sure, let’s run the test suite…

51:45

There is only one failure, and just like last time, it is not a logical error or the sign of a bug. But rather, it is simply our test suite requiring us to be more explicit in all the things we assert on in the feature.

51:50

In particular, it is complaining to us that our feature executed the onSyncUpUpdated closure, but we didn’t assert on that behavior at all. To do that we can use a test expectation: let onSyncUpUpdatedExpectation = self.expectation( description: "onSyncUpUpdated" ) model.onSyncUpUpdated = { _ in onSyncUpUpdatedExpectation.fulfill() } defer { self.wait(for: [onSyncUpUpdatedExpectation]) }

52:42

And now this test passes, and the full test suite passes.

52:47

Let’s keep moving on. We’ll tackle the SyncUpsList feature next. We can start by trading out the old observation tools for the new ones: @MainActor @Observable final class SyncUpsListModel { var destination: Destination? var syncUps: IdentifiedArrayOf<SyncUp> private var destinationCancellable: AnyCancellable? private var cancellables: Set<AnyCancellable> = [] @ObservationIgnored @Dependency(\.dataManager) var dataManager @ObservationIgnored @Dependency(\.mainQueue) var mainQueue @ObservationIgnored @Dependency(\.uuid) var uuid … }

53:07

And using @Bindable in the view: struct SyncUpsList: View { @Bindable var model: SyncUpsListModel … }

53:11

We still have a few compiler errors, but they are exactly what we previously encountered: self.$syncUps .dropFirst() .debounce(for: .seconds(1), scheduler: self.mainQueue) .sink { [weak self] syncUps in try? self?.dataManager.save( JSONEncoder().encode(syncUps), .syncUps ) } .store(in: &self.cancellables) The root-level feature has some logic to persist the sync-ups data to disk when any change is made, and it even debounces that logic for 1 second so that we don’t accidentally thrash the disk with each small change.

53:19

Well, we can remove that logic…

53:23

…and we can re-implement this logic in the didSet of the syncUps array, just as we did in the tree-based version of the app: var syncUps: IdentifiedArrayOf<SyncUp> { didSet { } }

53:38

In fact, let’s copy-and-paste the work from the tree-based version: private var saveDebouncedTask: Task<Void, Error>? var syncUps: IdentifiedArrayOf<SyncUp> { didSet { self.saveDebouncedTask?.cancel() self.saveDebouncedTask = Task { try await self.clock.sleep(for: .seconds(1)) try self.dataManager.save( JSONEncoder().encode(syncUps), .syncUps ) } } }

53:59

…and let’s remember to bring along the clock dependency too: @ObservationIgnored @Dependency(\.continuousClock) var clock

54:09

Everything now compiles, and let’s see how the test suite does…

54:19

We do have some failures in the unit test suite, and they are the same ones we previously encountered. We just need to update some tests that were previously using dispatch queues for time-based asynchrony to now use clocks instead:

55:35

And now all of the unit tests pass, but unfortunately we do have some UI test failures due to an observation bug. Let’s run this in the simulator and see if we can reproduce it there…

56:00

I can add a sync-up but when I save it and the sheet dismissed, nothing’s there. What is going on?

56:13

Before fixing that, we did have one last feature left to convert to the @Observable macro. This feature is unique to the stack-based version of the app because it is the feature that manages the root navigation stack.

56:34

We can start by updating it to use the @Observable macro: @MainActor @Observable class AppModel { var path: [Destination] { didSet { self.bind() } } var syncUpsList: SyncUpsListModel { didSet { self.bind() } } @ObservationIgnored @Dependency(\.continuousClock) var clock @ObservationIgnored @Dependency(\.date.now) var now @ObservationIgnored @Dependency(\.uuid) var uuid … }

56:46

And the view will use the @Bindable property wrapper: struct AppView: View { @Bindable var model: AppModel … }

56:51

Everything is now compiling, but we still have that observation bug to fix.

57:00

The fix for that is to not access the observed fields from the init of any of our models, and instead only access the unobserved, underscored fields: class AppModel { … init( path: [Destination] = [], syncUpsList: SyncUpsListModel ) { self._path = path self._syncUpsList = syncUpsList self.bind() } … }

57:10

But further , we also can’t access the observed fields from any methods that are called from the initializer, such as bind : private func bind() { self._syncUpsList.onSyncUpTapped = { [weak self] syncUp in … } for destination in self._path { … } }

57:18

And further, and even trickier, we need to do the same for the SyncUpsListModel since it also gets created at the root of the application: final class SyncUpsListModel { … init( destination: Destination? = nil ) { self._destination = destination self._syncUps = [] do { self._syncUps = try JSONDecoder().decode( IdentifiedArray.self, from: self.dataManager.load(.syncUps) ) } catch is DecodingError { self._destination = .alert(.dataFailedToLoad) } catch { } } … }

57:34

And finally with that the full test suite should pass…

57:46

We’re not gonna mince words here… this gotcha is pretty gnarly. It is easy to reproduce, and almost certainly to happen in real world code bases, but it is not easy to mitigate in practice. You have to make sure to not access any observed fields in the initializers of models, but further have to make sure that any methods the init calls doesn’t access observed fields, or methods called by methods called from the init , and on and on and on. This is very difficult to do correctly, and most likely will lead to quite a few bugs. And to have any kind of confidence that our app does not have any more of these observation bugs we need to audit every single observable initializer and/or every single place an observable instance is created on a continual basis.

58:41

One way to mitigate the problem is to avoid initializing observable models in observable contexts. For example we could move the initialization of the app model out of the app scene body: @main struct SyncUpsApp: App { let model = AppModel(…) var body: some Scene { WindowGroup { AppView( model: self.model ) } } }

59:13

We have now converted two full applications from using the old, pre-iOS 17 observation tools to using the new, fancy @Observable macro. For the most part it allowed us to remove a lot of ornamentations from our code and instead write our feature’s logic using simple, vanilla Swift classes. Stephen

59:30

There were a few gotchas along the way, such as a shift in our mindset of how we integrate child features together in parent features. We can no longer lean on Combine to listen for state changes in child features, but rather need to more explicitly hook up those communication channels. Brandon

59:45

And we found a huge gotcha related to accidentally observing too much state in parent views. This even led to legitimate bugs in our app that basically broke the interface entirely. It is really important to keep this gotcha in mind when building your application so that you do not accidentally run into that subtly.

1:00:01

Stephen : But, it’s worth mentioning that the main reason we were able to diagnose this gotcha so quickly, fix it, and verify the fix is thanks to our complete test suite. Both the unit tests and integration tests. The unit tests provide a nice baseline of verification that can catch simple mistakes quickly. The entire unit test suite runs in a tiny fraction of a second, whereas the integration suite takes nearly a minute. But, the integration suite can catch lots of problems that the unit test suite cannot. Brandon

1:00:28

At the end of the day it is a good idea to have as much test coverage as possible in your project before you start converting your models over to the @Observable macro. This will help you catch any potential problems when making such a large, sweeping change to your codebase. Stephen

1:00:43

Well, that does it for this episode. We just wanted a quick, short episode to show how the new Observation framework works in practice. And now that we are all have a deep understanding of how the new Observable tools work, we are nearly ready to start adding observation to the Composable Architecture. But before we can do that we have one more topic we need to discuss.

1:01:04

Until next time! References Collection: Modern SwiftUI Brandon Williams & Stephen Celis • Nov 28, 2022 The original series in which we build the application refactored in this episode. Note What does it take to build a vanilla SwiftUI application with best, modern practices? We rebuild Apple’s Scrumdinger code sample, a decently complex application that tackles real world problems, in a way that can be tested, modularized, and uses all of Swift’s powerful domain modeling tools. https://www.pointfree.co/collections/swiftui/modern-swiftui Downloads Sample code 0256-observation-in-practice 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 .