I'm Wei Wang from the LINE B2B App Development Team 2. Apple recently announced its new Observation framework to enhance state management in SwiftUI. In the LINE iOS app, state management and view updates based on observation are pretty common. We're curious to know if we can use the Observation framework in the LINE iOS app, and how much it can benefit us. So, we've done some research on this topic and I'd like to share some of our findings with you.
SwiftUI follows the principle of "single source of truth". The view tree can only be updated and the body re-evaluated by modifying the state subscribed by the View
. Upon its initial release, SwiftUI provided several property wrappers like @State
, @ObservedObject
, and @EnvironmentObject
for state management. In iOS 14, Apple added @StateObject
, which complements the scenario of holding reference type instances in the View
, making SwiftUI's state management more comprehensive.
When subscribing to reference types, a major issue with ObservableObject
, which acts as the model type, is that it can't provide attribute-level subscriptions. In View
, the subscription to ObservableObject
is based on the entire instance. As long as any @Published
property on the ObservableObject
changes, it triggers the objectWillChange
publisher of the entire instance to emit changes, causing all View
s subscribed to this object to re-evaluate. In complex SwiftUI apps, this can lead to serious performance issues and hinder the scalability of the program, thus requiring careful design by the user to avoid large-scale performance degradation.
At WWDC23, Apple introduced a brand-new Observation framework, hoping to solve the confusion and performance degradation issues related to state management in SwiftUI. The way this framework works seems magical, as you can achieve attribute-level subscriptions in the View
without any special syntax or declarations, thereby avoiding any unnecessary refreshes. Today, we'll take a closer look at the story behind it. This article will help you:
- Understand what exactly the Observation framework does, and how it accomplishes it.
- Understand the benefits it brings to us compared to previous solutions.
- Understand some of the trade-offs we make when dealing with SwiftUI state management today.
Let's first take a look at what Observation does.
How the Observation framework works
The use of Observation is very straightforward. You simply need to add @Observable
before the declaration of the model class, and then you can use it in the View without any further thought: when the stored properties or computed properties of the model class instance change, the body
of the View
will be re-evaluated, so your view tracks the model:
import Observation
@Observable final class Chat {
var message: String
var alreadyRead: Bool
init(message: String, alreadyRead: Bool) {
self.message = message
self.alreadyRead = alreadyRead
}
}
var chat = Chat(message: "Sample Message", alreadyRead: false)
struct ContentView: View {
var body: some View {
let _ = Self._printChanges()
Label("Message",
systemImage: chat.alreadyRead ? "envelope.open" : "envelope"
)
Button("Read") {
chat.alreadyRead = true
}
}
}
Although in many cases we tend to use
struct
to express data models,@Observable
can only be used onclass
types. This is because, for mutable internal states, it only makes sense to discuss state observation on stable instances. If the instance itself is replaced (like a struct value), the states on it also disappear.
At first glance, this seems a bit magical: we didn't declare any relationship between chat
and ContentView
. Simply by accessing alreadyRead
in View.body
, we have completed the subscription. The specific use of @Observable
in SwiftUI and the migration from ObservableObject
to @Observable
are fully explained in the WWDC Discover Observation in SwiftUI session. Here, we'll focus on the story behind it. If you haven't watched the related video yet, we recommend that you do so first.
The Observable macro and its expansion
While @Observable
might seem similar to other property wrappers, it's actually a macro, a new feature introduced in Swift 5.9. To understand what it does behind the scenes, let's expand this macro:
@Observable final class Chat {
@ObservationTracked
var message: String
@ObservationTracked
var alreadyRead: Bool
@ObservationIgnored private var _message: String
@ObservationIgnored private var _alreadyRead: Bool
@ObservationIgnored private let _$observationRegistrar = Observation.ObservationRegistrar()
internal nonisolated func access<Member>(
keyPath: KeyPath<Chat , Member>
) {
_$observationRegistrar.access(self, keyPath: keyPath)
}
internal nonisolated func withMutation<Member, T>(
keyPath: KeyPath<Chat , Member>,
_ mutation: () throws -> T
) rethrows -> T {
try _$observationRegistrar.withMutation(of: self, keyPath: keyPath, mutation)
}
}
extension Chat: Observation.Observable {
}
The @Observable
macro does three main things:
- It adds
@ObservationTracked
to all stored properties.@ObservationTracked
is also a macro that can be expanded further. We'll see later that this macro converts the original stored properties into computed properties. Additionally, for each converted stored property, the@Observable
macro adds a new stored property with an underscore prefix. - It adds content related to
ObservationRegistrar
, including an_$observationRegistrar
instance, as well as two helper methods,access
andwithMutation
. These two methods acceptKeyPath
ofChat
and forward this information to the relevant methods of the registrar. - It makes
Chat
conform to theObservation.Observable
protocol. This protocol doesn't require any methods; it only serves as a compilation aid.
@ObservationTracked
can be expanded further. If we take message
as an example, the result would be:
var message: String
{
init(initialValue) initializes (_message) {
_message = initialValue
}
get {
access(keyPath: \.message)
return _message
}
set {
withMutation(keyPath: \.message) {
_message = newValue
}
}
}
init(initialValue)
is a new syntax specifically added in Swift 5.9, known as Init Accessors. Since the macro can't rewrite the implementation of the existingChat
initializer, it provides a "backdoor" for accessing computed properties inChat.init
, allowing us to call this init declaration in the computed properties to initialize the newly generated underlying stored property_message
.@ObservationTracked
convertsmessage
into a computed property, adding a getter and setter for it. By calling the previously addedaccess
andwithMutation
, it associates property read-write operations with the registrar.
From this, we can get a rough idea of how the Observation framework works in SwiftUI: when accessing properties on an instance through a getter in View
's body
, the observation registrar will leave an "access record" for it and register a method that can refresh the View
it resides in; when modifying this value through the setter, the registrar retrieves the corresponding method from the record and call it to trigger a refresh.
ObservationRegistrar and withObservationTracking
You may have noticed that the access
method in ObservationRegistrar
has the following signature:
func access<Subject, Member>(
_ subject: Subject,
keyPath: KeyPath<Subject, Member>
) where Subject : Observable
In this method, we can get the instance of the model type itself and the KeyPath
involved in the access. However, relying solely on this, we can't get any information about the caller (in other words, the View
). There must be something hidden in between.
The key is a global function in the Observation framework, withObservationTracking
:
func withObservationTracking<T>(
_ apply: () -> T,
onChange: @autoclosure () -> () -> Void
) -> T
It takes two closures: in the first apply
closure, any variables of the Observable
instances accessed will be observed; any changes to these properties will trigger the onChange
closure once and only once.
For example, "On Changed" is only printed when alreadyRead
is changed for the first time:
let chat = Chat(message: "Sample message", alreadyRead: false)
withObservationTracking {
let _ = chat.alreadyRead
} onChange: {
print("On Changed: \(chat.message) | \(chat.alreadyRead)")
}
chat.message = "Some text"
// Nothing happens.
chat.alreadyRead = true
// print: On Changed: Some text | false
chat.alreadyRead = false
// Nothing happens.
In the above example, there are a few points worth noting:
- Since in
apply
, we only accessed thealreadyRead
property,onChange
wasn't triggered when settingchat.message
. This property wasn't added to the access tracking. - When we set
chat.alreadyRead = true
,onChange
was called. However, thealreadyRead
obtained at this time is stillfalse
.onChange
occurs duringwillSet
of the property. In other words, we can't get the new value in this closure. - Changing the value of
alreadyRead
again won't triggeronChange
again. The related observations were removed the first time it was triggered.
withObservationTracking
plays an important bridging role, linking the observation of model properties in SwiftUI's View.body
.
Considering the fact that the observation is triggered only once, assuming there's a renderUI
method in SwiftUI to re-evaluate body
, we can simplify the entire process as a recursive call:
var chat: Chat //...
func renderUI() -> some View {
withObservationTracking {
VStack {
Label("Message",
systemImage: chat.alreadyRead ? "envelope.open" : "envelope")
Button("Read") {
chat.alreadyRead = true
}
}
} onChange: {
DispatchQueue.main.async { self.renderUI() }
}
}
Of course, in reality, within
onChange
, SwiftUI only marks the involved views as dirty and then redraws them in the next main runloop. We've simplified this process here only for demonstration purposes.
Implementation details
Apart from the SwiftUI-related parts, the good news is we don't have to guess about the implementation of the Observation framework. It's open-sourced as part of the Swift project. You can find all the source code for the framework here. The framework's implementation is very concise, direct, and clever. Although it's very similar to our assumptions overall, there are some details worth noting in the specific implementation.
Tracking access
As a global function, withObservationTracking
provides a general apply
closure without any type information (it has a type () -> T
). The global function itself doesn't have a reference to a specific registrar. So, to associate onChange
with the registrar, it must rely on a global variable (the only storage space that a global function can access) to temporarily save the association between the registrar (or key path) and the onChange
closure.
In the implementation of the Observation framework, this is achieved by using a custom _ThreadLocal
struct and storing the access list in the thread storage behind it. Multiple different withObservationTracking
calls can track properties on multiple different Observable
objects, corresponding to multiple registrars. And all tracking will use the same access list. You can imagine it as a global dictionary, with the ObjectIdentifier
of the model object as the key, and the value contains the registrar and accessed KeyPath
s on this object. Through this, we can finally find the contents of the onChange
that we want to execute:
struct _AccessList {
internal var entries = [ObjectIdentifier : Entry]()
// ...
}
struct Entry {
let context: ObservationRegistrar.Context
var properties: Set<AnyKeyPath>
// ...
}
struct ObservationRegistrar {
internal struct Context {
var lookups = [AnyKeyPath : Set<Int>]()
var observations = [Int : () -> () /* content of onChange */ ]()
// ...
}
}
The above code is just illustrative. It's simplified, and partially modified for ease of understanding.
Thread safety
When establishing an observation relationship (in other words, calling withObservationTracking
), the internal implementation of the Observation framework uses a mutex to ensure thread safety. This means that we can use withObservationTracking
safely on any thread without worrying about data race issues.
During the triggering of observations (the setter side), no additional thread handling is performed for the invocation of observations. The onChange
will be executed on the thread where the first observed property is set. This means that if we want to carry out some thread-safe operations within onChange
, we need to be aware of the thread on which the call is taking place.
In SwiftUI, this isn't a problem, as the re-evaluation of View.body
will be "consolidated" and performed on the main thread. However, if we're using withObservationTracking
separately outside of SwiftUI and want to refresh the UI within onChange
, it's best to perform a check about the current thread.
Observing timing
The current implementation of the Observation framework chooses to call onChange
in a "call only once" manner when the observed value willSet
. This leads us to wonder whether Observation can do the following:
- Call at
didSet
, instead ofwillSet
. - Maintain the state of the observer and call every time the
Observable
property changes.
In the current implementation, the Id
used to track observations is defined as follows:
enum Id {
case willSet(Int)
case didSet(Int)
case full(Int, Int)
}
The current implementation has taken into account the didSet
situation and has a corresponding implementation. However, the interface to add observation for didSet
isn't exposed. Currently, Observation mainly cooperates with SwiftUI, so willSet
is the first consideration. In the future, if needed, it would be quite easy to implement didSet
and full
. The latter notifies both before and after setting the property.
Regarding the long-observation, the framework doesn't yet provide an option, nor does it have a relevant implementation. However, since each registered observation closure is managed with its own Id, it should also be feasible to provide an option for users to conduct long-term observations.
Trade-offs
Back porting & technical debt
The deployment target required by Observation is iOS 17, which is a tough goal for most apps to reach in the short term. Consequently, developers face a significant dilemma: there are clearly superior and more efficient methods, but they can only be used two or three years from now. Every line of code written in the conventional way during this time will become future technical debt. It's a rather frustrating situation.
In technical terms, it's not tough to back port the contents of the Observation framework to earlier system versions. The author has also attempted and validated this concept in this repo, back porting all the APIs of Observation to iOS 14. By importing ObservationBP
, we can use this framework in the exact same way, thereby mitigating the issue of technical debt:
import ObservationBP
@Observable fileprivate class Person {
init(name: String, age: Int) {
self.name = name
self.age = age
}
var name: String = ""
var age: Int = 0
}
let p = Person(name: "Tom", age: 12)
withObservationTracking {
_ = p.name
} onChange: {
print("Changed!")
}
In the future, when you upgrade the minimum version to iOS 17, you can simply replace import ObservationBP
with import Observation
to effortlessly switch to Apple's official version.
However, we don't really have many reasons to use the Observation framework independently; it's always used in conjunction with SwiftUI. In fact, we can provide a wrapper layer to allow our SwiftUI code to also benefit from this back-porting implementation:
import ObservationBP
public struct ObservationView<Content: View>: View {
@State private var token: Int = 0
private let content: () -> Content
public init(@ViewBuilder _ content: @escaping () -> Content) {
self.content = content
}
public var body: some View {
_ = token
return withObservationTracking {
content()
} onChange: {
token += 1
}
}
}
The concept here, as we mentioned earlier, is in the onChange
of withObservationTracking
, we need a way to rebuild the observation. Here, we access a token
state in the body
to trigger its re-evaluation again in onChange
, and the observation is recreated by calling content()
inside the apply
.
In practice, you just need to wrap the View
with observation requirements into an ObservationView
:
var body: some View {
ObservationView {
VStack {
Text(person.name)
Text("\(person.age)")
HStack {
Button("+") { person.age += 1 }
Button("-") { person.age -= 1 }
}
}
.padding()
}
}
Under the current circumstances, it's impossible for us to use Observation as transparently and seamlessly as what is done in version 5.0 of SwiftUI. This is probably why Apple chose to incorporate Observation
as part of the Swift 5.9 standard library rather than as a separate package: the new version of SwiftUI depends on this framework, so it must be bound with the new version of the system.
Old ways vs new ways
So far, we already have quite a few means of observation in iOS development. Can Observation replace them?
Comparing with key-value observing
Observing properties is a crucial method for the UI to respond to data changes in the LINE iOS app. In the extensive UIKit code of the LINE iOS app, there are several patterns using key-value observing (KVO) for observation. KVO requires the observed property to have a dynamic
keyword. This is easy to achieve for properties in UIKit based on Objective-C, which are inherently dynamic
. However, for model types that drive views, adding dynamic
to each property is not only relatively challenging but also brings additional overhead.
The Observation framework can solve part of this issue. Adding a setter and getter for a property is less burdensome than converting the entire property to dynamic, especially with the help of Swift macros. Developers would undoubtedly prefer to use Observation. However, currently, Observation only supports one-time subscription and willSet
callbacks. In situations where long-term observation is needed, this method is clearly difficult to entirely replace KVO.
We're looking forward to seeing Observation support more options. At that point, we can further evaluate the possibility of using it to replace KVO.
Comparing with Combine
After using the Observation framework, we can't find a reason to continue using the old ObservableObject
in Combine. Therefore, the corresponding @ObservedObject
, @StateObject
, and @EnvironmentObject
in SwiftUI are also not needed. With SwiftUI being completely Combine-free, the Observation framework can replace Combine's role in binding state and view after iOS 17.
However, Combine is more than that. It has many other use cases, and its strength lies in merging and transforming event streams. This is not in the same realm as what the Observation framework is designed for. When deciding which framework to use, we should still choose the appropriate tool based on the requirements.
Performance tips
Compared to the traditional ObservableObject
model type that observes the entire instance, using @Observable
for property-level observation can naturally reduce the number of times View.body
is re-evaluated (because accessing properties on an instance will always be a subset of accessing the instance itself). In @Observable
, a simple access to an instance does not trigger re-evaluation, so some former performance "optimization tricks", such as trying to split the View's model into fine-grained parts, may no longer be the best solution.
For instance, when using ObservableObject
, if our model type is:
final class Person: ObservableObject {
@Published var name: String
@Published var age: Int
init(name: String, age: Int) {
self.name = name
self.age = age
}
}
We used to prefer doing this, splitting child views containing the smallest piece of data they need:
struct ContentView: View {
@StateObject
private var person = Person(name: "Tom", age: 12)
var body: some View {
NameView(name: person.name)
AgeView(age: person.age)
}
}
struct NameView: View {
let name: String
var body: some View {
Text(name)
}
}
struct AgeView: View {
let age: Int
var body: some View {
Text("\(age)")
}
}
In this way, when person.age
changes, only ContentView
and AgeView
need to be refreshed.
However, after adopting @Observable
:
@Observable final class Person {
var name: String
var age: Int
init(name: String, age: Int) {
self.name = name
self.age = age
}
}
It would be better to just pass person
directly down to child views:
struct ContentView: View {
private var person = Person(name: "Tom", age: 12)
var body: some View {
PersonNameView(person: person)
PersonAgeView(person: person)
}
}
struct PersonNameView: View {
let person: Person
var body: some View {
Text(person.name)
}
}
struct PersonAgeView: View {
let person: Person
var body: some View {
Text("\(person.age)")
}
}
In this small example, only PersonAgeView
needs to be refreshed when person.age
changes. When such optimizations accumulate, the performance improvement they can bring in large-scale apps will be significant.
However, compared to the original method, the View
driven by @Observable
has to rebuild the access list and observation relationship after each re-evaluation. If a property is observed by too many Views
, the rebuild time will also significantly increase. The specific impact this will have needs further evaluation and feedback from the community.
Takeaways
- Starting from iOS 17, using the Observation framework and
@Observable
macro will be the best way for SwiftUI to manage state. They not only provide a concise syntax but also bring performance improvements. - The Observation framework can be used independently. It rewrites the setter and getter of properties through macros and adds a single
willSet
observation with an access tracking list. However, due to the lack of options exposed by the Observation framework, there are currently not many use cases outside of SwiftUI. - Although only
willSet
is exposed, the framework has already implemented thedidSet
andfull
callback internally. It wouldn't be a surprise if they are exposed in the future. - Technically, it's not difficult to back port the Observation framework to earlier iOS versions, but it is challenging to provide a transparent SwiftUI wrapper. Since SwiftUI is the largest user of this framework and the version of SwiftUI is bound to the system version, the Observation framework is also designed to be bound to the latest system version.
- Using the new framework syntax will bring new performance optimization practices. Understanding the principles of Observation will help us write better-performing SwiftUI apps.