DEV Community

Cover image for A Deep Dive Into Observation: A New Way to Boost SwiftUI Performance
Fatbobman( 东坡肘子 )
Fatbobman( 东坡肘子 )

Posted on • Edited on

A Deep Dive Into Observation: A New Way to Boost SwiftUI Performance

At WWDC 2023, Apple introduced a new member of the Swift standard library - the Observation framework. Its appearance is expected to alleviate the long-standing issue of unnecessary updates to SwiftUI views for developers. This article will comprehensively and thoroughly explore the Observation framework in a Q&A format, including its reasons for creation, usage methods, workings, and precautions.


Don’t miss out on the latest updates and excellent articles about Swift, SwiftUI, Core Data, and SwiftData. Subscribe to Fatbobman’s Swift Weekly and receive weekly insights and valuable content directly to your inbox.


Why create the Observation framework

Before Swift 5.9, Apple did not provide developers with a unified and efficient mechanism for observing changes to reference type properties. KVO is limited to use by NSObject subclasses, Combine cannot provide precise observation at the property level, and neither can achieve cross-platform support.

In addition, in SwiftUI, the source of truth for reference type data sources is implemented using the ObservableObject protocol based on the Combine framework. This leads to a large number of unnecessary view refreshes in SwiftUI, which affects the performance of SwiftUI applications.

To address these limitations, the Observation framework was introduced in Swift 5.9. Compared to existing KVO and Combine, it has the following advantages:

  1. It is applicable to all Swift reference types, not just NSObject subclasses, and provides cross-platform support.
  2. Provides precise observation at the property level without the need for special annotations on observable properties.
  3. Reduces unnecessary view updates in SwiftUI and improves application performance.

How to Declare an Observable Object

Using the Combine framework, we can declare an observable reference type as follows:

class Store: ObservableObject {
    @Published var firstName: String
    @Published var lastName: String
    var fullName: String {
        firstName + " " + lastName
    }

    @Published private var count: Int = 0

    init(firstName: String, lastName: String, count: Int) {
        self.firstName = firstName
        self.lastName = lastName
        self.count = count
    }
}
Enter fullscreen mode Exit fullscreen mode

When the firstName, lastName, and count of the instance change, @Published will send notifications through objectWillChange(ObjectWillChangePublisher) to inform all subscribers that the current instance is about to change.

Using the Observation framework, we will use a completely different declaration:

@Observable
class Store {
    var firstName: String = "Yang"
    var lastName: String = "Xu"
    var fullName: String {
        firstName + " " + lastName
    }

    private var count: Int = 0

        init(firstName: String, lastName: String, count: Int) {
        self.firstName = firstName
        self.lastName = lastName
        self.count = count
    }
}
Enter fullscreen mode Exit fullscreen mode
  • Add @Observalbe annotation before the class declaration, and there is no need to specify that the Store type should comply with a certain protocol.
  • There is no need to use @Published to annotate properties that can trigger notifications. Any stored property that is not specifically annotated can be observed.
  • Computed properties can be observed (in the example, fullName can also be observed).
  • For properties that do not want to be observed, they need to be annotated with @ObservationIgnored in front of them.
// count cannot be observed
@ObservationIgnored
private var count: Int = 0
Enter fullscreen mode Exit fullscreen mode
  • All properties must have literal default values, even if a custom init method is provided.

Compared to the Combine-based declaration, Observation makes the declaration of observable objects more concise and intuitive, while also providing support for observing computed properties.

What did @Observable do

Unlike other common keywords that start with @ (such as the @Published property wrapper and @available conditional compilation), @Observable here represents a macro.

Macros are a new feature added in Swift 5.9. They allow developers to manipulate and process Swift code at compile time. Developers can provide a macro definition that will execute during compilation and modify, add, or remove code from the source code.

In Xcode 15, right-click on @Observable and select "Expand Macro" to see the code generated by the @Observable macro:

https://cdn.fatbobman.com/expend-macro-demo-2_2023-06-19_08.38.08.2023-06-19%2008_38_52.gif

@Observable
class Store {
    @ObservationTracked
    var firstName: String = "Yang" {
        get {
            access(keyPath: \.firstName)
            return _firstName
        }

        set {
            withMutation(keyPath: \.firstName) {
                _firstName = newValue
            }
        }
    }

    @ObservationTracked // This code can also be expanded here.
    var lastName: String = "Xu"
    var fullName: String {
        firstName + " " + lastName
    }

    @ObservationIgnored
    private var count: Int = 0

    init(firstName: String, lastName: String, count: Int) {
        self.firstName = firstName
        self.lastName = lastName
        self.count = count
    }

    @ObservationIgnored private let _$observationRegistrar = ObservationRegistrar()

    internal nonisolated func access<Member>(
        keyPath: KeyPath<Store, Member>
    ) {
        _$observationRegistrar.access(self, keyPath: keyPath)
    }

    internal nonisolated func withMutation<Member, T>(
        keyPath: KeyPath<Store, Member>,
        _ mutation: () throws -> T
    ) rethrows -> T {
        try _$observationRegistrar.withMutation(of: self, keyPath: keyPath, mutation)
    }

    @ObservationIgnored private var _firstName: String = "Yang"

    @ObservationIgnored private var _lastName: String = "Xu"
}

extension Store: Observable {}
Enter fullscreen mode Exit fullscreen mode

As can be seen, the Observable macro adjusts our original declaration. In the Store, an ObservationRegistrar structure is declared to maintain and manage the relationship between observable properties and observers. Stored properties are rewritten as computed properties, and the original value is saved in a version with the same name but with the _ prefix. In the get and set methods, observers are registered and notified through _$observationRegistrar. Finally, the macro adds code to make the observable object conform to the Observable protocol (similar to Sendable, it does not provide any implementation, but serves only as an identifier).

How to Use Observable Objects in Views

Declaring Observable Objects in Views

Unlike a Source of Truth that conforms to the ObservableObject protocol, we use @State in views to ensure the lifecycle of observable objects.

@Observable
class Store {
   ....
}

struct ContentView: View {
    @State var store = Store()
    var body: some View {
       ...
    }
}
Enter fullscreen mode Exit fullscreen mode

Injecting Observable Objects into the View Hierarchy Using Environment

Compared to the Source of Truth that adheres to the ObservableObject protocol, Observable Objects declared using the Observation framework have more diverse and flexible options for environment injection.

  • Injecting instances through environment
@Observable
class Store {
   ....
}

struct ObservationTest: App {
    @State var store = Store()
    var body: some Scene {
        WindowGroup {
            ContentView()
                .environment(store)
        }
    }
}

struct ContentView: View {
    @Environment(Store.self) var store // Inject through environment in view
    var body: some View {
       ...
    }
}
Enter fullscreen mode Exit fullscreen mode
  • Customize EnvironmentKey
struct StoreKey: EnvironmentKey {
    static var defaultValue = Store()
}

extension EnvironmentValues {
    var store: Store {
        get { self[StoreKey.self] }
        set { self[StoreKey.self] = newValue }
    }
}

struct ContentView: View {
    @Environment(\.store) var store // Inject through environment in view
    var body: some View {
       ...
    }
}
Enter fullscreen mode Exit fullscreen mode
  • Inject optional values
struct ObservationTest: App {
    @State var store = Store()
    var body: some Scene {
        WindowGroup {
            ContentView()
                .environment(store)
        }
    }
}

struct ContentView: View {
    @Environment(Store.self) var store:Store? // Inject through environment in view
    var body: some View {
       if let firstName = store?.firstName  {
                Text(firstName)
       }
    }
}
Enter fullscreen mode Exit fullscreen mode

Among them, both custom EnvironmentKey and injection of optional values perfectly solve the problem of Preview crashes caused by forgetting to inject. Especially EnvironmentKey gives developers the ability to provide default values.

Perhaps some people may feel confused why the injection method of observable objects declared using the Observation framework is similar to that of value types, while reference types that comply with the ObservableObject protocol require the use of methods that indicate Object to inject (StateObject, EnvironmentObject). Won't this cause confusion?

It can be expected that in the development of iOS 17+ applications, the scenarios where observable objects declared through the Observation framework and observable objects that comply with the ObservableObject protocol appear simultaneously will become less and less. Therefore, soon, reference types and value types will be highly unified in their injection forms (there will almost never be a scenario where environmentObject or StateObject are used).

Passing Observable Objects in Views

struct ContentView: View {
    @State var store = Store()
    var body: some body {
        SubView(store: store)
    }
}

struct SubView:View {
    let store:Store
    var body: some body {
       ....
    }
}
Enter fullscreen mode Exit fullscreen mode

Both let and var can be used.

Creating a Binding Type

The Binding type provides SwiftUI with the ability to implement two-way data binding. Using the Observation framework, we can create the corresponding Binding type for a property in the following ways.

Method One:

struct ContentView: View {
    @State var store = Store()
    var body: some body {
        SubView(store: store)
    }
}

struct SubView:View {
    @Bindable var store:Store
    var body: some body {
       TextField("",text:$store.name)
    }
}
Enter fullscreen mode Exit fullscreen mode

Method Two:

struct SubView:View {
    var store:Store
    var body: some body {
       @Bindable var store = store
       TextField("",text:$store.name)
    }
}
Enter fullscreen mode Exit fullscreen mode

Method Three:

struct SubView:View {
    var store:Store
    var name:Binding<String>{
        .init(get: { store.name }, set: { store.name = $0 })
    }
    var body: some body {
       TextField("",text:name)
    }
}
Enter fullscreen mode Exit fullscreen mode

Does the Observation framework support older versions of SwiftUI?

Not supported.

How to observe observable objects

The Observation framework provides a global function called withObservationTracking. With this function, developers can track whether the properties of an observable object have changed.

Function signature:

func withObservationTracking<T>(
    _ apply: () -> T,
    onChange: @autoclosure () -> () -> Void
) -> T
Enter fullscreen mode Exit fullscreen mode

Test 1:

@Observable
class Store {
    var a = 10
    var b = 20
    var c = 20
}

let sum = withObservationTracking {
    store.a + store.b
} onChange: {
    print("Store Changed a:\(store.a) b:\(store.b) c:\(store.c)")
}

store.c = 100

// No output

store.b = 100

// Output
// Store Changed a:10 b:20 c:100

store.a = 100

// No output
Enter fullscreen mode Exit fullscreen mode

Test 2:

withObservationTracking {
   print(store)
   DispatchQueue.main.asyncAfter(deadline: .now() + 0.3){
      store.a = 100
   }
} onChange: {
    print("Store Changed")
}

store.b = 100

// No output

store.a = 100

// No output
Enter fullscreen mode Exit fullscreen mode

In the official documentation for withObservationTracking provided by Apple, the function is explained as follows:

  • apply: A closure that contains properties to track
  • onChange: The closure invoked when the value of a property changes
  • Returns: The value that the apply closure returns if it has a return value; otherwise, there is no return value

However, the description is too simple, and there are still some confusing points:

  • How does withObservationTracking determine which properties in the apply closure can be observed?
  • Why do some observable properties in the apply closure not trigger callbacks after being modified? (Test 2)
  • Is the observation behavior created by withObservationTracking disposable or persistent?
  • When is the onChange closure invoked? Does "when the value of a property changes" mean before or after the property is changed?

Fortunately, the Observation framework is part of the Swift 5.9 standard library. We can learn more information by examining its source code.

What is the observation principle of the Observation framework?

By reading the code, we can understand the process of creating observations with withObservationTracking. I will summarize it as follows:

Creating observation phase

  • withObservationTracking creates an _AccessList in the current thread's _ThreadLocal.value.
  • The apply closure is executed.
  • When the observable property of an observable object is called (triggered by the apply closure), the access method is used to save the correspondence between the observable property and the callback closure in the ObservationRegistrar of the observable object instance (the callback closure here is used to call the onChange closure in withObservationTracking).
  • withObservationTracking saves the correspondence between the observable property and the onChange callback closure in _AccessList.

When the observed property is about to change

  • The observed property will call the willSet method in ObservationRegistrar and find the callback closure corresponding to the current property KeyPath.
  • By calling the closure, the onChange closure is called in the thread initiated by withObservationTracking.
  • After the onChange closure is called, the information corresponding to the _AccessList in the current thread of withObservationTracking is cleared.
  • Clear the correspondence between the properties and the callback closures related to this observation operation in ObservationRegistrar.

Conclusion

By sorting out, we can get the following conclusions:

  • Only observable properties that are read (by calling their get method) in the apply closure will be observed (which explains the issue in test 2).
  • The observation operation created by withObservationTracking is a one-time behavior. Any observable property changes will end this observation after calling the onChange function.
  • The onChange closure is called before the property value changes (in the willSet method).
  • Multiple observable properties can be observed in one observation operation. Any property value changes will end this observation.
  • The observation behavior is thread-safe. withObservationTracking can run in another thread, and the onChange closure will run in the thread initiated by withObservationTracking.
  • Only observable properties can be observed. Observable objects that only appear in the apply closure will not create observation operations (which explains test 2).

Currently, the Observation framework does not provide an API for creating continuous observation behavior. Perhaps this part of the function will be added in later versions.

How to observe property changes in SwiftUI views

Based on the workings of the Observation framework, we can speculate that SwiftUI probably creates a connection between observable properties and view updates using the following methods:

struct A:View {
   var body: some View {
       ...
   }
}

let bodyValue = withObservationTracking {
    viewA.body
} onChange: {
    PreparingToRe-evaluateTheBodyValue()
}
Enter fullscreen mode Exit fullscreen mode

As summarized in the previous text, we concluded that "only observable properties that are read within the apply closure (by calling their get method) will be observed." Therefore, we can draw the following conclusion:

Text(store.a) // Changes in store.a will trigger a re-evaluation of the body.

Button("Hi"){
    store.b = "abc" // Changes in store.b will not trigger a re-evaluation of the body.
}
Enter fullscreen mode Exit fullscreen mode

Can a class annotated with @Observable still conform to the ObservableObject protocol?

Yes, it can. However, there may be conflicts between the @Published property wrapper and the @Observable macro. To resolve this, we can use withObservationTracking.


@Observable
final class Store: ObservableObject {
    var name = ""
    var age = 0

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

    private func observeProperties() {
        withObservationTracking {
            let _ = name
            let _ = age
        } onChange: { [weak self] in
            guard let self else { return }
            objectWillChange.send()
            observeProperties()
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

If necessary, you can use custom macros to complete the repetitive work of introducing all observable properties in the observeProperties method.

Can @Obervable and ObservableObject coexist in a view?

Yes. In a view, observable objects can be declared in different ways and still coexist. SwiftUI will choose the corresponding observation method based on how the observable objects are injected into the view.

For example, in the previous text, we created an observable object that satisfies two observation approaches at the same time. Depending on how it is injected, SwiftUI will adopt different update strategies.

@State var store = Store() // Decide whether to re-evaluate the body finely based on changes in properties.

@StateObject var store = Store() // Whenever the property (@Published) changes, the body will be reevaluated.
Enter fullscreen mode Exit fullscreen mode

Does Observable support nesting (where the property of one Observable is another Observable)?

Support.

Since @Published only supports value types, it is difficult to implement nested logic for observable objects that conform to the ObservableObject protocol:

class A:ObservableObject {
    @Published var b = B()
}

class B:ObserableObject {
    @Published var a = 10
}

let a = A()
a.b.a = 100 // Does not trigger view update
Enter fullscreen mode Exit fullscreen mode

I once wrote a @PublishedObject property wrapper to solve this problem. For more information, please read the article "Going Beyond @Published:Empowering Custom Property Wrappers". In principle, @PublishedObject uses the objectWillChange of the external object A (enclosing instance) to notify A's subscribers when the property of B changes. In other words, a highly coupled approach is used to achieve nesting of observable objects.

However, nesting of observable objects created through the Observation framework is much simpler. When creating an observation operation with withObservationTracking, every observable property that is read will actively create a relationship with the subscriber. It can be correctly tracked regardless of its position in the relationship chain or how it exists (such as arrays, dictionaries, etc.).

For example:

@Observabl
class A {
   var a = 1
   var b = B()
}

@Observable
class B {
   var b = 1
}

let a = A()

withObservationTracking {
   let _ = a.b.b
} onChange: {
    print("update")
}
Enter fullscreen mode Exit fullscreen mode

For the above code, both of the following two methods will invoke the onChange closure (only called once).

a.b.b = 100

// or

a.b = B()
Enter fullscreen mode Exit fullscreen mode

In the line let _ = a.b.b, observations are created for two observable properties from different objects and different levels, a.b and b.b. This is the strength of the Observation framework.

Observation: Has the performance issue of ObservableObject been resolved?

Yes, the Observation framework has improved the performance of observable objects in SwiftUI from two aspects:

  • By observing observable properties in views instead of observable objects, a lot of unnecessary view updates can be reduced.
  • Compared to the publisher-subscriber model of Combine, the callback mechanism of Observation is more efficient.

However, because the Observation framework does not yet support creating sustainable observation behaviors, views need to recreate observation operations every time they are evaluated. We need more time to evaluate whether this will cause new performance issues.

Will the Observation framework affect SwiftUI programming habits?

For me, yes.

For example, currently, developers usually use structs to build the state model of the application. After using the Observation framework, in order to implement property-level observation, we should use the Observation framework to create observable objects, and even build the state model with nested observable objects.

In addition, many of the optimization techniques we used in the views will also change. For example, when using ObservableObject, we will reduce unnecessary refresh by only introducing data that is useful for the current view.

For more optimization techniques for views, please read the article "How to Avoid Repeating SwiftUI View Updates".

class Store:ObservableObject {
    @Published var a = 1
    @Published var b = "hello"
}

struct Root:View {
    @StateObject var store = Store()
    var body: some View {
        VStack{
            A(a: store.a)
            B(b: store.b)
        }
    }
}

struct A:View {
    let a:Int    // only get a(Int)
    var body:some View {
        Text("\(store.a)")
    }
}

struct B:View { // only get b(String)
    let b:String
    var body:some View {
        Text(store.b)
    }
}
Enter fullscreen mode Exit fullscreen mode

When store.b changes, only the Root and B views will be re-evaluated.

After switching to the Observation framework, the optimization strategy mentioned above will no longer be the optimal solution. Instead, the previously discouraged method is more suitable for the new observable objects.

@Observabl
class Store {
    var a = 1
    var b = "hello"
}

struct Root:View {
    @State var store = Store()
    var body: some View {
        VStack{
            A(store: store)
            B(store: store)
        }
    }
}

struct A:View {
    let store: Store
    var body:some View {
        Text("\(store.a)")
    }
}

struct B:View {
    let store: Store
    var body:some View {
        Text(store.b)
    }
}
Enter fullscreen mode Exit fullscreen mode

Only properties that appear in the body and are read will trigger a view update. After modification, only the B view will be re-evaluated when store.b changes.

As the Observation framework is still a new thing, its API is also constantly evolving. As more and more SwiftUI applications are converted to this framework, developers will summarize more experience of use.

Conclusion

Through the discussion in this article, readers should have gained a better understanding of the Observation framework and how it can improve the performance of SwiftUI. Although the Observation framework is currently tightly integrated with SwiftUI, with the enrichment of its API, it is believed that it will appear in more and more application scenarios, not just limited to SwiftUI.

Top comments (0)