LY Corporation Tech Blog

We are promoting the technology and development culture that supports the services of LY Corporation and LY Corporation Group (LINE Plus, LINE Taiwan and LINE Vietnam).

Observation in depth

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 Views 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 on class 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:

  1. 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.
  2. It adds content related to ObservationRegistrar, including an _$observationRegistrar instance, as well as two helper methods, access and withMutation. These two methods accept KeyPath of Chat and forward this information to the relevant methods of the registrar.
  3. It makes Chat conform to the Observation.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
    }
  }
}
  1. 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 existing Chat initializer, it provides a "backdoor" for accessing computed properties in Chat.init, allowing us to call this init declaration in the computed properties to initialize the newly generated underlying stored property _message.
  2. @ObservationTracked converts message into a computed property, adding a getter and setter for it. By calling the previously added access and withMutation, 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:

  1. Since in apply, we only accessed the alreadyRead property, onChange wasn't triggered when setting chat.message. This property wasn't added to the access tracking.
  2. When we set chat.alreadyRead = true, onChange was called. However, the alreadyRead obtained at this time is still false. onChange occurs during willSet of the property. In other words, we can't get the new value in this closure.
  3. Changing the value of alreadyRead again won't trigger onChange 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 KeyPaths 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:

  1. Call at didSet, instead of willSet.
  2. 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

  1. 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.
  2. 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.
  3. Although only willSet is exposed, the framework has already implemented the didSet and full callback internally. It wouldn't be a surprise if they are exposed in the future.
  4. 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.
  5. Using the new framework syntax will bring new performance optimization practices. Understanding the principles of Observation will help us write better-performing SwiftUI apps.

References