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.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
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
    }
  }
}

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:

Observable macro

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
@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:

  1. 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.
  2. Adds content related to ObservationRegistrar, including an instance of _$observationRegistrar, and two helper methods access and withMutation. These methods accept the KeyPath of the Chat and forward this information to the relevant methods of the registrar.
  3. Make Chat follow the Observation.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:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
var message: String
{
  init(initialValue) initializes (_message) {
    _message = initialValue
  }

  get {
    access(keyPath: \.message)
      return _message
    }

  set {
    withMutation(keyPath: \.message) {
      _message = newValue
    }
  }
}
  1. 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 Chat initialisation method, it provides a way for Chat.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.
  2. @ObservationTracked converts message to a computed property and adds getters and setters to it.By calling the access and withMutation 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

ObservationRegistrar and withObservationTracking

As you may have noticed, the access method in ObservationRegistrar has the following signature.

1
2
3
4
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, 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.

1
2
3
4
func withObservationTracking<T>(
  _ apply: () -> T,
  onChange: @autoclosure () -> () -> Void
) -> T

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:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
let chat = Chat(message: "Sample message", alreadyRead: false)
withObservationTracking {
  let _ = chat.alreadyRead
} onChange: {
  print("On Changed: \(chat.message) | \(chat.alreadyRead)")
}

chat.message = "Some text"
// No outputs

chat.alreadyRead = true
// Print: On Changed: Some text | false

chat.alreadyRead = false
// No outputs

There are a few points worth noting in the above example:

  1. Since in apply we only accessed the alreadyRead property, onChange was not triggered when setting chat.message. This property is not added to the access tracking.
  2. When we set chat.alreadyRead = true, onChange is called. However, the alreadyRead fetched will still be false. The onChange will happen when the property’s willSet. In other words, we can’t get the new value in this closure.
  3. Changing the value of alreadyRead again does not trigger onChange 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.

withObservationTracking

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.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
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, 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.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
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 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.

  1. Call it on didSet, not willSet.
  2. 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:

1
2
3
4
5
enum Id {
  case willSet(Int)
  case didSet(Int)
  case full(Int, Int)
}

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:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
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!")
}

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:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
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
    }
  }
}

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:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
var body: some View {
  ObservationView {
    VStack {
      Text(person.name)
      Text("\(person.age)")
        HStack {
          Button("+") { person.age += 1 }
          Button("-") { person.age -= 1 }
        }
    }
    .padding()
  }
}

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:

1
2
3
4
5
6
7
8
9
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 be more inclined to do that:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
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)")
  }
}

This way, only the ContentView and AgeView need to be refreshed when person.age changes.

However, after using @Observable:

1
2
3
4
5
6
7
8
9
@Observable final class Person {
  var name: String
  var age: Int

  init(name: String, age: Int) {
    self.name = name
    self.age = age
  }
}

It is more efficient to pass person directly down the line:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
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 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

  1. 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.
  2. 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.
  3. Although only willSet is currently supported, didSet and full 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.
  4. 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.
  5. 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.