KINTO Tech Blog
iOS

Swift Observation: A Cleaner and More Efficient Approach to Updating the UI

Cover Image for 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 the User 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
Without Observation
Wit 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-

Facebook

関連記事 | Related Posts

We are hiring!

【iOSエンジニア】モバイルアプリ開発G/大阪

モバイルアプリ開発GについてKINTOテクノロジーズにおける、モバイルアプリ開発のスペシャリストが集まっているグループです。KINTOやmy routeなどのサービスを開発・運用しているグループと協調しながら品質の高いモバイルアプリを開発し、サービスの発展に貢献する事を目標としています。

【iOSエンジニア】モバイルアプリ開発G/東京

モバイルアプリ開発GについてKINTOテクノロジーズにおける、モバイルアプリ開発のスペシャリストが集まっているグループです。KINTOやmy routeなどのサービスを開発・運用しているグループと協調しながら品質の高いモバイルアプリを開発し、サービスの発展に貢献する事を目標としています。