SwiftUI follows the Single Source of Truth principle, where only modifying the state to which a View is subscribed can change the view tree and trigger a re-value of the body, which in turn refreshes the UI.
When first released, SwiftUI provided property wrappers such as @State
, @ObservedObject
, and @EnvironmentObject
for state management.
In iOS 14, Apple added @StateObject
, which completes the state management of SwiftUI by complementing the case where the View holds an instance of a reference type.
ObservableObject
plays the role of a Model type when subscribing to a reference type, but it has a serious problem in that it can’t provide subscription at property granularity. In SwiftUI’s View, subscription to an ObservableObject
is based on the entire instance. Whenever any of the @Published
properties on an ObservableObject
change, it triggers the objectWillChange publisher for the entire instance to issue a change, which in turn causes all Views subscribed to the object to be re-valued. In complex SwiftUI applications, this can lead to serious performance issues and hinder scalability. Therefore, users need to carefully design their data models to avoid massive performance degradation.
At WWDC 23, Apple introduced the new Observation framework, designed to address state management confusion and performance issues on SwiftUI. The framework works in a seemingly magical way, without even needing to be declared specifically, to enable property granularity subscriptions in a View, thus avoiding unnecessary refreshes. This post will dive into the principles behind it to help you:
- Understand the essence of the Observation framework and its implementation mechanism.
- Compare its advantages with previous solutions
- Introduce a way to forward-compatible Observation to iOS 14
- Explore some of the tradeoffs and considerations when dealing with SwiftUI state management.
By reading this article, you’ll have a clearer understanding of the new Observation framework in SwiftUI, the benefits it brings to developers, and the ability to make informed choices in real-world applications.
Let’s take a look at what Observation does.
How the Observation framework works
Observation is very simple to use, you just need to prefix the model class declaration with the @Observable
tag, and it’s easy to use it in the View: as soon as the stored or computed attributes of the model class instance change, the body
of the View
is automatically re-valued and the UI is refreshed.
|
|
While in most cases we prefer to use struct to represent data models, @Observable
can only be used on class types. This is because for mutable internal state, it only makes sense to monitor state on stable instances of reference types.
At first glance, @Observable
does seem like a bit of magic: we don’t need to declare any relationship between chat and ContentView, we just access the alreadyRead
property in View.body
and the subscription is done automatically. For more on the specific use of @Observable
in SwiftUI and the migration from ObservableObject
to @Observable
, WWDC 23’s Discover Observation in SwiftUI session provides a detailed explanation. We recommend that you watch the related video for an in-depth look at how to use and the benefits of this new feature.
Observable macro, macro expansion
@Observable
, although it looks somewhat similar to other property wrappers, is actually a macro introduced in Swift 5.9. To understand what it does behind the scenes, we can expand this macro:
|
|
The @Observable
macro does three main things:
- Adds
@ObservationTracked
to all storage properties,@ObservationTracked
is also a macro that expands further and converts the original storage property to a computed property. Also, for each converted storage property, the@Observable
macro adds a new storage property with an underscore. - Adds content related to
ObservationRegistrar
, including an instance of_$observationRegistrar
, and two helper methodsaccess
andwithMutation
. These methods accept theKeyPath
of theChat
and forward this information to the relevant methods of the registrar. - Make
Chat
follow theObservation.Observable
protocol. This protocol now has no required methods, it is only used as a compilation aid.
The @ObservationTracked
macro can be expanded further. In the case of message
, for example, it expands as follows:
init(initialValue)
is a new feature added specifically in Swift 5.9 called Init Accessors, which adds a third access method,init
, in addition to getter and setter, to computed properties. Since the macro cannot override the existing implementation of theChat
initialisation method, it provides a way forChat.init
to access the computed property, allowing us to call this init declaration of the computed property in the initialisation method to initialise the newly generated behind-the-scenes storage property_message
.@ObservationTracked
convertsmessage
to a computed property and adds getters and setters to it.By calling theaccess
andwithMutation
methods mentioned earlier,@ObservationTracked
associates the reading and writing of properties with the registrar, enabling the monitoring and tracking of properties.
As a result, we can get a rough picture of how the Observation framework works in SwiftUI as follows: in the body
of a View
, when a property on the instance is accessed via a getter, the Observation Registrar records the access and registers a refresh method for the current View
; when the value of a property is changed via a setter, the Registrar finds the corresponding refresh method in the record and executes it, which triggers the view to be re-valued and refreshed.
This mechanism allows SwiftUI to accurately track changes to each property, avoiding unnecessary refreshes and improving application performance and responsiveness.
ObservationRegistrar and withObservationTracking
As you may have noticed, the access
method in ObservationRegistrar
has the following signature.
In this method, we can get the instance of the model type itself and the KeyPath
involved in the access. However, with this information alone, we can’t get information about the caller (i.e. the View
), and it’s impossible to do a refresh when a property changes. There must be something missing in the middle.
There is a global function in the Observation framework, withObservationTracking
.
It accepts two closures: the variables of the Observable
instance accessed in the first apply
closure will be observed; any changes to those properties will trigger one and only one call to the onChange
closure. Example:
|
|
There are a few points worth noting in the above example:
- Since in
apply
we only accessed thealreadyRead
property,onChange
was not triggered when settingchat.message
. This property is not added to the access tracking. - When we set
chat.alreadyRead = true
,onChange
is called. However, thealreadyRead
fetched will still befalse
. TheonChange
will happen when the property’swillSet
. In other words, we can’t get the new value in this closure. - Changing the value of
alreadyRead
again does not triggeronChange
again. The related observations are removed the first time they are triggered.
withObservationTracking
plays an important bridging role, linking the two in SwiftUI’s View.body
observation of the model property.
Noting the fact that the observation is triggered only once, and assuming that there is a renderUI method in SwiftUI to re-value the body, we can simplify the whole process by thinking of it as a recursive call.
|
|
Of course, in reality, in onChange
, SwiftUI just marks the view involved as dirty and uniformly redraws it at the next main runloop. Here we simplify the process.
Implementation details
Aside from the SwiftUI-related bits, the good news is that we don’t need to make any guesses about the implementation of the Observation framework, as it’s open-sourced as part of the Swift project, and you can find all of the framework’s source code. The implementation of the framework is very clean, straightforward, and clever. Although the whole is very similar to our assumptions, there are some noteworthy details in the implementation.
Observation tracking
withObservationTracking
is a global function that provides a generic apply
closure. The global function itself has no reference to a specific registrar, so to associate onChange
with a registrar, it is necessary to use a global variable to temporarily hold the association between the registrar (or rather, the keypath held within it) and the onChange
closure.
In the Observation framework implementation, this is achieved by storing the access list as a local value in the thread via a custom _ThreadLocal
structure. Multiple different withObservationTracking
calls can track properties on multiple different Observable
objects at the same time, with each tracking corresponding to a registrar; however, all tracks share the same access list.
You can think of the access list as a dictionary, where the ObjectIdentifier
of the object is the key, and the value contains the registrar of the object and the KeyPath to which it was accessed, and with this information we can eventually find onChange
and execute the code we want.
|
|
The above code is only schematic and has been simplified and partially modified for ease of understanding.
Thread-safe
Assignments made through the setter in the Observable
property are fetched in the global access list and the registrar through the registrar’s withMutation
method to the onChange
method that observes the corresponding property keypath on the object. When establishing the observation relationship (i.e., calling withObservationTracking
), the internal implementation of the Observation framework uses a mutually exclusive lock to ensure thread safety. Therefore, we can safely use withObservationTracking
in any thread without worrying about data races.
There is no additional thread processing for the call to observations when the observation is triggered. onChange
will be called on the thread where the first observed property setting occurs. So if we want to do some thread-safe processing in onChange
, we need to be aware of the thread on which the call occurs. In SwiftUI, this is most likely not a problem, as the re-calculation of View.body
is “aggregated” into the main thread. However, if we are using withObservationTracking
outside of SwiftUI and want to refresh the UI in onChange
, then it is a good idea to make some judgement calls to the current thread to be on the safe side.
Observation timing
The current implementation of the Observation framework chooses to call onChange
on the value willSet
in a `once-only’ manner for all observed changes. This leads us to wonder if Observation could do the following.
- Call it on
didSet
, notwillSet
. - Maintain the state of the observer and call it every time the
Observable
property changes.
In the current implementation, the Id
used for tracking observations has the following definition:
The current implementation already considers the didSet
case and has a corresponding implementation, but the interface for adding observations to didSet
is not exposed. Currently, Observation works primarily with SwiftUI, so willSet
is the first to be considered. In the future, it is believed that didSet
and the .full
pattern that notifies before and after setting a property can be easily implemented if needed.
For the second point, the Observation framework does not provide an option for this, nor does it have a code equivalent. However, since each registered observation closure is managed using its own Id, it should be possible to provide the option to allow users to perform long-term observations.
Weigh the pros and cons
Backward compatibility and technical debt
Observation requires a deployment target of iOS 17, which is difficult for most apps to achieve in the short term. So developers are faced with a huge dilemma: there is a better, more efficient way of doing things, but it’s frustrating to have to wait two or three years before they can use it, and every line of code written in the meantime in the traditional way will become technical debt to be paid off in the future.
On a technical level, it is not difficult to back-port the Observation framework so that it can run on previous versions of the system. I have also tried and proof-of-concept in this repo and conducted the same tests as the official implementation, back porting all the APIs of Observation to iOS 14. With the contents of this repository, we can use the framework in the exact same way as we imported ObservationBP to alleviate the technical debt problem:
|
|
When you have a chance to upgrade the minimum version to iOS 17 in the future, you can simply replace import ObservationBP
with import Observation
to seamlessly switch to the official Apple version.
The truth is that we don’t have much reason to use the Observation framework on its own; it’s always used in conjunction with SwiftUI. Indeed, we could provide a layer of wrapping to allow our SwiftUI code to take advantage of this back-porting implementation as well:
|
|
As we mentioned above, in the onChange
of withObservationTracking
, we need a way to rebuild the access list.
Here we access the token
in body
to make onChange
trigger body
again, which will re-call content()
for a value to establish the new observation relationship.
To use it, simply wrap the View
with the observation requirement to ObservationView
:
Under current conditions, it’s not possible to make use of Observation as transparently and seamlessly as SwiftUI 5.0, which is probably why Apple chose to include the Observation framework as part of the Swift 5.9 standard library rather than as a separate package. The new version of SwiftUI, which is bound to the new system, relies on this framework, so the choice was made to bind the framework to the new version of the system as well.
Different ways to observe
So far, we’ve had a number of observations in iOS development, but can Observation replace them?
Comparing KVO
KVO is a common means of observation, and there are patterns in a lot of UIKit code that use KVO for observation. KVO requires that the property being observed has the dynamic
tag, which is easy to satisfy for Objective-C based properties in UIKit. However, for the type of model that drives the view, adding dynamic
to every property is difficult and introduces additional overhead.
The Observation framework solves this part of the problem, adding a setter and getter to a property is much lighter than converting the whole property to dynamic
, and developers are certainly more than happy to use Observation, especially with the help of Swift macros. however, Observation currently only supports single subscription and willSet
callbacks. willSet` callback, which is obviously a poor substitute for KVO in situations where long-term observation is required.
We look forward to seeing more options supported by Observation so we can further evaluate the possibility of using it as an alternative to KVO.
vs Combine
With the Observation framework, there is no reason to continue using ObservableObject
in Combine, so its SwiftUI counterparts @ObservedObject
, @StateObject
and @EnvironmentObject
are theoretically no longer needed. are theoretically no longer needed. With SwiftUI moving away from Combine, the Observation framework will be able to completely replace Combine’s work in binding state and view after iOS 17.
But Combine has use cases in many other ways, and its strength is in merging multiple event streams and morphing them. This is not in the same track as what the Observation framework is trying to do. When deciding which framework to use, we should still pick the right tool for the job, based on our needs.
Performance
Using @Observable
for property granularity naturally reduces the number of View.body
re-calls compared to the traditional ObservableObject
model type that looks at the instance as a whole, because accesses to properties on the instance will always be a subset of accesses to the instance itself. Since in @Observable
, accesses to the instance alone do not trigger a re-call, some of the performance “optimisations” that were once possible, such as trying to split the View’s model at a fine granularity, may no longer be optimal.
As an example, when using ObservableObject
, if we have the following Model type:
We used to be more inclined to do that:
|
|
This way, only the ContentView
and AgeView
need to be refreshed when person.age
changes.
However, after using @Observable
:
It is more efficient to pass person
directly down the line:
|
|
In this small example, only the PersonAgeView
needs to be refreshed when person.age
changes. When these kinds of optimisations add up, the performance gains in a large-scale app can be significant.
In contrast to the original approach, however, the @Observable
-driven View
has to rebuild the access list and observation relationships each time it is re-valued. If a property is observed by too many Views
, then this rebuild time will increase dramatically. How much of an impact this will have is subject to further evaluation and community feedback.
Summary
- Starting with iOS 17, using the Observation framework and the
@Observable
macro will be the best way to manage state in SwiftUI. Not only do they provide clean syntax, but they also offer performance improvements. - The Observation framework can be used on its own, with macros to rewrite setters and getters for properties, and with an access tracking list for a single
willSet
observation. However, due to the limited options currently exposed by the Observation framework, its use case is mainly within SwiftUI, with relatively few use cases outside of SwiftUI. - Although only
willSet
is currently supported,didSet
andfull
support has been implemented, just without exposing the interface. So it would not be surprising to see Observation support the timing of other property settings at some point in the future. - There is no technical difficulty in back porting the Observation framework to earlier versions, but the difficulty for developers to provide transparent SwiftUI wrappers makes it challenging to apply it to older versions of SwiftUI. Also, given that SwiftUI is the primary user of the framework and is tied to a system version, the Observation framework was designed to feature a system version tie-in as well.
- Using a new way of writing the framework brings new performance optimisation practices, and a deeper understanding of the principles of Observation will help us write better performing SwiftUI apps.