Swift Observation:UI を更新するための、よりシンプルで効率的なアプローチ

本記事は、KINTOテクノロジーズアドベントカレンダー2024の19日目の投稿です。🎅🎄
はじめに
こんにちは。モバイルアプリ開発グループでiOSエンジニアをしているラセル・ミアです。今日は、iOS 17で導入された新しい@Observable
マクロを使用して、SwiftUIのUIを更新するための改善されたアプローチを紹介します。その仕組み、解決する課題、そしてなぜこれを使うべきなのかを説明します。
TL;DR
Observationを使用すると、Swiftにおけるオブザーバーデザインパターンを、堅牢で型安全かつ効率的に実装することができます。このパターンでは、Observableオブジェクトがオブザーバーのリストを保持し、特定または一般的な状態変化を通知することができます。これにより、オブジェクト同士を直接結びつけることなく、複数のオブザーバー間で暗黙的に更新を分配できるという利点があります。
Appleドキュメントより
簡単に言えば、Observation
はビューをデータの変更に応答させるための新しく簡単な方法です。
Observationを使用しない場合の課題
Observation
を使う前に、従来の方法でUIを更新する手法と、それに関連する課題を説明します。
簡単な例から見てみましょう。
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
ObservableObject
プロトコルに準拠し、状態の監視を可能にしています。- プロパティnameに
@Published
を付与することで、ビューに変更を通知します。
ParentView
@StateObject
を使用してUser
クラスのインスタンスを管理します。
ChildView
@ObservedObject
を使用してUserオブジェクトを受け取ります。
両方のビューで、let _ = print("xxx.body")
を使用して、ビューの更新をデバッグログに出力します。
プロジェクトをビルドすると、初期状態では次のようなログが表示されます。
ParentView.body
ChildView.Body
この時点では問題ありません。初期状態で両方のビューが描画されます。しかし、SetNameボタンを押すと、次のようなログが出力されます。
ParentView.body
ChildView.Body
ParentView
とChildView
の両方が再描画されますが、ParentView
はUser
のプロパティをまったく使用していなかったため、これは予想外です。さらに、ChildView
ではUserモデルに依存しない定数プロパティを使用しているだけですが、それも再描画されています。このように、ChildView
が静的なTextを返すだけの場合でも、User
モデルへの参照を保持しているため、User
モデルが変更されると再描画されてしまいます。これはパフォーマンス上の重大な課題を浮き彫りにしています。ここで、Observation
フレームワークが私たちを救うために登場します。
こんにちは、@Observable!
@Observable
マクロは、WWDC 2023で ObservableObject
とその@Published
プロパティの代わりとして紹介されました。このマクロはプロパティを明示的にPublishedとマークする必要をなくし、SwiftUIビューが変更に応じて自動的に再描画されるようにします。
ObservableObject
から@Observable
マクロに移行するには、観測できるようにしたいクラスに新しいSwiftマクロ@Observable
を付けるだけです。また、すべてのプロパティから@Published
属性を削除してください。
@Observable
class User {
var name = ""
let age = 20
func setName() {
name = "KINTO Technologies"
}
}
注意点: Structは
@Observable
マクロをサポートしていません。
以上です!これで、UIはnameプロパティが変更されたときにのみ更新されるようになります。この動作を確認するには、以下のようにビューを修正します。
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)")
}
}
}
修正後、カスタムのobservableモデルは@State
を使用して宣言できます。この変更により、@ObservedObject
、 ObservableObject
、@Published
、@EnvironmentObject
が不要になります。
@Bindable:Observableオブジェクトの変更可能なプロパティに対してバインディングを作成するためのプロパティラッパー型です。Appleドキュメントより
コードを実行すると、初期レンダリング時に以下の出力が表示されます。
ParentView.body
ChildView.Body
しかし、setName
ボタンを押しても、コンソールには何も表示されません。これは、ParentView
がUserのプロパティを使用していないため、更新する必要がないからです。同様に、ChildView
にも適用されます。
ParentView
にText(user.name)
を追加してプロジェクトをビルドします。その後、setName
ボタンを押すと、以下の出力が得られます。
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) // <--- 追加部分
ChildView(user: user)
}
}
}
// 出力
ParentView.body
// ChildViewで年齢を変更するコードを追加します。
@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)")
}
}
}
// 出力
ChildView.Body
この結果から、ビューが不要な再描画を行わず、正しく更新されていることが確認できます。これはパフォーマンスにおいて大きな改善を示しています。
@Observableの仕組み
@Observableの仕組みは一見すると魔法のように思えるかもしれません。モデルに@Observable
マクロを付与しただけで、SwiftUIビューが問題なく更新されるようになります。しかし、その背後ではいくつかの重要な処理が行われています。ObservableObject
プロトコルから Observation.Observable
プロトコルへ移行しました。さらに、@Published
プロパティラッパーの代わりに@ObservationTracked
マクロを使用し、 name
とage
は @ObservationTracked
に関連付けられています。このマクロを展開することで、その実装内容を確認することができます。以下は展開されたコードです。
@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 {}
デフォルトでは、オブジェクトは観測オブジェクトにアクセス可能な、観測可能な型のすべてのプロパティを観測します。ただし、特定のプロパティが観測されないようにしたい場合は、そのプロパティに @ObservationIgnored
マクロを付与することで制御できます。
@Observable
class User {
var name = ""
@ObservationIgnored var age = 20
func setName() {
name = "KINTO Technologies"
}
}
これにより、ageプロパティの変更は追跡されなくなり、それに伴うUIの更新も発生しなくなります。
パフォーマンス分析
以下は、Instrumentsツールを使用して記録されたビュー更新回数のレポートです。
Observation未使用時
Observation使用時
記録結果からの考察
Observation未使用時(最初のイメージ)
このInstrumentsセッションでは、SwiftUIアプリの最適化されていないパフォーマンスが記録されています。
- 表示されたメトリクス:ビューの更新、プロパティの変更、タイミングの概要が表示されます。
- ビューの再描画回数:初期レンダリングに加え、
Set Name
ボタンを3回押した際の再描画がそれぞれ発生し、合計で 9回の再描画が行われました。 - パフォーマンス:
- 合計再描画時間:377.71 マイクロ秒
- 平均再描画時間:41.97マイクロ秒/回
Observation使用時(2番目のイメージ)
このセッションでは、Observationフレームワークを使用した最適化されたレンダリングが記録されています。
- 表示されたメトリクス:最初のセッションと同じメトリクスが表示されますが、効率の向上が反映されています。
- ビューの再描画回数:ボタンを複数回タップしても、初期レンダリングと状態変化時の再描画が1回のみの 3 回の再描画で構成されます。
- パフォーマンス:
- 合計再描画時間:235.58 マイクロ秒
- 平均再描画時間:78.53 マイクロ秒/回
定量的な比較
- ビューの再描画回数の削減:
- Observation未使用時:9回再描画。
- Observation使用時:3回の再描画(66.67% 削減)
- 合計再描画時間の短縮:
- Observation未使用時:377.71 マイクロ秒
- Observation使用時:235.58 マイクロ秒 (37.65% 削減)
- 再描画の効率性:
- Observation未使用時:より頻繁で、平均41.97 マイクロ秒
- Observation使用時:再描画回数は少ないが最適化されており、平均78.53 マイクロ秒
この比較から、再描画回数が少なくなった結果としてインスタンスごとの平均再描画時間がやや増加しているものの、Observation
フレームワークが再描画回数の削減と全体的なレンダリングパフォーマンスの向上に大きく貢献していることが分かります。
まとめ
この記事では、iOS 17で導入された新しい @Observable
マクロによるSwiftUIの改善点について解説しました。重要なポイントを簡単にまとめると、次のようになります。
以前のアプローチの課題
従来のObservableObject
と@Published
プロパティを使用する方法では、不要な再描画が発生し、特に一部のビューが変更されたデータに依存しない場合に、パフォーマンスの問題が生じていました。
@Observable の紹介
この新しいマクロは状態監視プロセスを簡素化し、@Published
、@ObservableObject
、@EnvironmentObject
の必要性を排除します。クラスに@Observable
を付与し、ビューで @Bindable
を使用することで、データの変更を自動的に追跡し、必要な場合にのみビューの更新をトリガーできます。
パフォーマンスの向上
@Observable
を使用することで、関連するデータが変更された場合にのみビューが更新され、不要な再描画が削減されるため、パフォーマンスが向上します。さらに、@ObservationIgnored
マクロを活用することで、どのプロパティがUI更新をトリガーするかを開発者が細かく制御できるようになります。
メリット
- 的確な更新によるパフォーマンスの向上。
- ObservableObjectと@Publishedが不要になることでコードが簡潔に。
- 状態変更を型安全に管理可能。
- ビューの更新をトリガーするプロパティを細かく制御できる柔軟性。
@Observable
を使用することで、SwiftUIでのUI更新の管理がより簡単かつ効率的になり、エラーが減少します。その結果、開発者にとっては作業効率が向上し、エンドユーザーにとってはスムーズな体験が提供されます。
以上で本日の内容は終了です。楽しいコーディングを!
参照-
関連記事 | Related Posts
We are hiring!
【iOSエンジニア】モバイルアプリ開発G/大阪
モバイルアプリ開発GについてKINTOテクノロジーズにおける、モバイルアプリ開発のスペシャリストが集まっているグループです。KINTOやmy routeなどのサービスを開発・運用しているグループと協調しながら品質の高いモバイルアプリを開発し、サービスの発展に貢献する事を目標としています。
【iOSエンジニア】モバイルアプリ開発G/東京
モバイルアプリ開発GについてKINTOテクノロジーズにおける、モバイルアプリ開発のスペシャリストが集まっているグループです。KINTOやmy routeなどのサービスを開発・運用しているグループと協調しながら品質の高いモバイルアプリを開発し、サービスの発展に貢献する事を目標としています。