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
@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
@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:
@Observable macro does three main things:
@ObservationTrackedto all storage properties,
@ObservationTrackedis also a macro that expands further and converts the original storage property to a computed property. Also, for each converted storage property, the
@Observablemacro adds a new storage property with an underscore.
- Adds content related to
ObservationRegistrar, including an instance of
_$observationRegistrar, and two helper methods
withMutation. These methods accept the
Chatand forward this information to the relevant methods of the registrar.
Observation.Observableprotocol. This protocol now has no required methods, it is only used as a compilation aid.
@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 the
Chatinitialisation method, it provides a way for
Chat.initto 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
messageto a computed property and adds getters and setters to it.By calling the
withMutationmethods mentioned earlier,
@ObservationTrackedassociates 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,
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
applywe only accessed the
onChangewas not triggered when setting
chat.message. This property is not added to the access tracking.
- When we set
chat.alreadyRead = true,
onChangeis called. However, the
alreadyReadfetched will still be
onChangewill happen when the property’s
willSet. In other words, we can’t get the new value in this closure.
- Changing the value of
alreadyReadagain does not trigger
onChangeagain. 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.
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.
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
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.
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.
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
- Maintain the state of the observer and call it every time the
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
withObservationTracking, we need a way to rebuild the access list.
Here we access the
body to make
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
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?
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.
With the Observation framework, there is no reason to continue using
ObservableObject in Combine, so its SwiftUI counterparts
@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.
@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
AgeView need to be refreshed when
However, after using
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
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.
- Starting with iOS 17, using the Observation framework and the
@Observablemacro 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
willSetobservation. 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
willSetis currently supported,
fullsupport 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.