Swift Observation: A Cleaner and More Efficient Approach to Updating the UI
This article is the entry for day 19 in the KINTO Technologies Advent Calendar 2024🎅🎄
Introduction
Hello, This is Rasel Miah, an iOS Engineer from the Mobile Application Development Group. Today, I’ll introduce an improved approach to updating the UI in SwiftUI using the new @Observable
macro introduced in iOS 17. I’ll explain how it works, the problems it solves, and why we should use it.
TL;DR
Observation provides a robust, type-safe, and performant implementation of the observer design pattern in Swift. This pattern allows an observable object to maintain a list of observers and notify them of specific or general state changes. This has the advantages of not directly coupling objects together and allowing implicit distribution of updates across potential multiple observers.
From Apple Documentation
In simple terms, Observation
is a new and easier way to make a view respond to data changes.
Challenges Without Using Observation
Before diving into Observation
, let me first show you the old method of updating the UI and the challenges associated with it.
Let’s start with a simple example.
import SwiftUI
class User: ObservableObject {
@Published var name = ""
let age = 20
func setName() {
name = "KINTO Technologies"
}
}
struct ParentView: View {
@StateObject private var user = User()
var body: some View {
let _ = print("ParentView.body")
VStack {
Button("Set Name") {
user.setName()
}
ChildView(user: user)
}
}
}
struct ChildView: View {
@ObservedObject var user: User
var body: some View {
let _ = print("ChildView.body")
VStack {
Text("Age is \(user.age)")
}
}
}
User
- Conforms to the
ObservableObject
protocol to enable state observation. - Contains a
@Published
property name to notify views about changes.
ParentView
@StateObject
to manage an instance of theUser
class.
ChildView
- Accepts a User object as an
@ObservedObject
.
Both the parent and child views will use let _ = print("xxx.body")
for debugging purposes to log updates to the view.
If you build the project, you’ll see the following output in the debug log.
ParentView.body
ChildView.body
No issues so far, as this is the initial state and both views are rendered. However, if you press the setName button, you’ll see the following output.
ParentView.body
ChildView.body
Both ParentView
and ChildView
are re-drawn, which is not expected since ParentView
didn't use any properties of User
. Moreover, ChildView
relies on a constant variable that doesn’t change, yet it still gets re-drawn. Even if ChildView
only returns a static Text, it will still be re-drawn whenever the User
model changes because it holds a reference to the User
model. This highlights a significant performance issue. This is where the Observation
framework steps in to rescue us.
Hello @Observable !
The @Observable
macro was introduced at WWDC 2023 as a replacement for ObservableObject
and its @Published
properties. This macro eliminates the need for explicitly marking properties as published while still enabling SwiftUI views to automatically re-render when changes occur.
To migrate from ObservableObject
to the @Observable
macro, simply mark the class you want to make observable with the new Swift macro, @Observable
. Additionally, remove the @Published
attribute from all properties.
@Observable
class User {
var name = ""
let age = 20
func setName() {
name = "KINTO Technologies"
}
}
Note: Struct does not support the
@Observable
macro.
That’s it! Now, the UI will only update when the name property changes. To verify this behavior, modify the views as follows:
struct ParentView: View {
// BEFORE
// @StateObject private var user = User()
// AFTER
@State private var user = User()
var body: some View {
let _ = print("ParentView.body")
VStack {
Button("Set Name") {
user.setName()
}
ChildView(user: user)
}
}
}
struct ChildView: View {
// BEFORE
// @ObservedObject var user: User
// AFTER
@Bindable var user: User
var body: some View {
let _ = print("ChildView.body")
VStack {
Text("Age is \(user.age)")
}
}
}
Now, we can declare our custom observable model using @State
, eliminating the need for @ObservedObject
, ObservableObject
, @Published
, or @EnvironmentObject.
@Bindable : A property wrapper type that supports creating bindings to the mutable properties of observable objects.
From Apple Documentation
After running the code, you’ll see the following output during the initial rendering:
ParentView.body
ChildView.body
If you press the setName
button, nothing will appear in the console because ParentView
doesn’t need to update as it didn't use any properties of User. The same applies to ChildView
.
Add Text(user.name)
to the ParentView
and then build the project. After pressing the setName
button, you will see the output:
struct ParentView: View {
@State private var user = User()
var body: some View {
let _ = print("ParentView.body")
VStack {
Button("Set Name") {
user.setName()
}
Text(user.name) // <--- Added
ChildView(user: user)
}
}
}
// output
ParentView.body
// change the age from child
@Observable
class User {
var name = ""
var age = 20
func setName() {
name = "KINTO Technologies"
}
}
struct ParentView: View {
@State private var user = User()
var body: some View {
let _ = print("ParentView.body")
VStack {
Button("Set Name") {
user.setName()
}
ChildView(user: user)
}
}
}
struct ChildView: View {
@Bindable var user: User
var body: some View {
let _ = print("ChildView.body")
VStack {
Button("Change Age") {
user.age = 30
}
Text("Age is \(user.age)")
}
}
}
// output
ChildView.body
This indicates that the view is updating correctly without any unnecessary re-rendering. This represents a significant improvement in performance.
How does @Observable work?
This might seem miraculous. SwiftUI views update without any issues as we simply checked our model with the @Observable
macro. But there's more happening behind the scenes. We’ve moved from using the ObservableObject
protocol to the Observation.Observable
protocol. Additionally, our name
& age
property is now associated with the @ObservationTracked
Macro instead of the @Published
Property Wrapper. You can expand the macro to reveal its implementation. The following is the expanded code.
@Observable
class User {
@ObservationTracked
var name = ""
@ObservationTracked
var age = 20
func setName() {
name = "KINTO Technologies"
}
@ObservationIgnored private let _$observationRegistrar = Observation.ObservationRegistrar()
internal nonisolated func access<Member>(
keyPath: KeyPath<User, Member>
) {
_$observationRegistrar.access(self, keyPath: keyPath)
}
internal nonisolated func withMutation<Member, MutationResult>(
keyPath: KeyPath<User, Member>,
_ mutation: () throws -> MutationResult
) rethrows -> MutationResult {
try _$observationRegistrar.withMutation(of: self, keyPath: keyPath, mutation)
}
}
extension User: Observation.Observable {}
By default, an object can observe any property of an observable type that is accessible to the observing object. To prevent a property from being observed, simply attach the @ObservationIgnored
macro to that property.
@Observable
class User {
var name = ""
@ObservationIgnored var age = 20
func setName() {
name = "KINTO Technologies"
}
}
Now, any change to the age property won’t be tracked meaning no UI update will be happened.
Performance Analysis
Here is the view count report recorded using the Instruments tool.
Without Observation
With Observation
Insights from the Capture
Without Observation (First Image)
This Instruments session captures the unoptimized performance of a SwiftUI app:
- Metrics Displayed: View body updates, property changes, and timing summary.
- View Redraw Count: 9 redraws, including the initial rendering and one redraw for each of the three
Set Name
button taps. - Performance:
- Total Redraw Duration: 377.71 µs.
- Average Redraw Time: 41.97 µs per redraw.
With Observation (Second Image)
This session highlights the optimized rendering achieved with the Observation framework:
- Metrics Displayed: Same metrics as in the first session, now reflecting improved efficiency.
- View Redraw Count: 3 redraws, consisting of the initial rendering and only one redraw for state changes, regardless of multiple button taps.
- Performance:
- Total Redraw Duration: 235.58 µs.
- Average Redraw Time: 78.53 µs per redraw.
Quantitative Highlights
- View Redraw Count Reduction:
- Without Observation: 9 redraws.
- With Observation: 3 redraws (reduced by 66.67%).
- Total Redraw Duration Improvement:
- Without Observation: 377.71 µs.
- With Observation: 235.58 µs (reduced by 37.65%).
- Redraw Efficiency:
- Without Observation: More frequent, averaging 41.97 µs.
- With Observation: Fewer but optimized redraws, averaging 78.53 µs.
This comparison illustrates the significant impact of the Observation
framework on reducing the number of redraws and improving overall rendering performance, even though the average redraw time per instance is slightly higher due to fewer redraws.
Summary
In this post, we explored the improvements in SwiftUI with the new @Observable
macro introduced in iOS 17. Here’s a quick recap of the key points:
Challenges with Previous Approach
The old method of using ObservableObject
and @Published
properties caused unnecessary re-renders, resulting in performance issues, especially when some views did not depend on the changing data.
Introducing @Observable
This new macro simplifies the state observation process, eliminating the need for @Published
, @ObservableObject
, and @EnvironmentObject
. By marking a class with @Observable
, and using @Bindable
in views, we can automatically track changes in the data and only trigger view updates when necessary.
Performance Improvements
The use of @Observable
ensures that views are updated only when the relevant data changes, reducing unnecessary re-renders and improving performance. With the @ObservationIgnored
macro, developers have more control over which properties should or should not trigger UI updates.
Benefits
- Better performance through more targeted updates.
- A simplified codebase by removing the need for ObservableObject and @Published.
- Type-safe management of state changes.
- More control over which properties trigger view updates.
With @Observable
, managing UI updates in SwiftUI becomes easier, more efficient, and less error-prone, offering a smoother experience for both developers and end users.
That’s all for today. Happy Coding! 👨💻
Ref-
関連記事 | Related Posts
We are hiring!
【iOSエンジニア】モバイルアプリ開発G/大阪
モバイルアプリ開発GについてKINTOテクノロジーズにおける、モバイルアプリ開発のスペシャリストが集まっているグループです。KINTOやmy routeなどのサービスを開発・運用しているグループと協調しながら品質の高いモバイルアプリを開発し、サービスの発展に貢献する事を目標としています。
【iOSエンジニア】モバイルアプリ開発G/東京
モバイルアプリ開発GについてKINTOテクノロジーズにおける、モバイルアプリ開発のスペシャリストが集まっているグループです。KINTOやmy routeなどのサービスを開発・運用しているグループと協調しながら品質の高いモバイルアプリを開発し、サービスの発展に貢献する事を目標としています。