はじめに
watnowアドベントカレンダーの22日目を務めますカブです!
今年もあと少しとなってしまいましたね
副代表を務めてもうすぐ1年となり時間の速さに驚いています😱
本記事は自分が春頃から始めたSwiftに関するものです。
iOSアプリをMVVMアーキテクチャで作成してきたのですが、
ObservableObject
を利用して実装してきました。
そのためObservable
をあまり利用した事がありませんでした。
そこで今回はSwift5.9から登場した、
Observable
マクロを利用した実装の仕方やその説明について記事を書いていきます!
間違い等あればお手柔らかにご指摘していただければ助かります🙇♂️
対比早見表
ObservableObject
とObservable
はリアクティブな状態管理を提供するSwiftUIの機能となっており、どちらもViewModelとして活用されるシーンが見られます。
その違いを抑えるためにも、まずは表を見て対比してみてください。
項目 | ObservableObject | Observableマクロ |
---|---|---|
導入年 | 2019年(WWDC 2019) | 2023年(WWDC 2023) |
使用可能なiOSバージョン | iOS 13以降 | iOS 17以降 |
定義 | プロトコル | マクロ |
監視方式 | 監視したいプロパティに@Published を付与するオプトイン方式 |
監視から外したいプロパティに@ObservationIgnored を付与するオプトアウト方式 |
実装方法 | クラスが ObservableObject プロトコルを準拠して@Published を使用してプロパティを監視 |
クラスに @Observable を付与するだけで監視可能 |
子Viewの差分更新 | 不可能 | 可能 |
対応しているiOSバージョンが少ないという点以外では、
Observable
マクロはObservableObject
の上位互換となっています!(オプトアウト方式の方がオプトイン方式よりも監視漏れが発生しにくいため優れていると考えています。)
Swift Concurrency がiOS 15からiOS 13でにバックポートしたように
Observableでも期待したいところですが公式では見送られています。
そのため公式としての利用はiOS17以降でのみの条件付きとなってしまっています。
https://forums.swift.org/t/backporting-swift-5-9s-observability/65821/16
しかしPoint-Freeチームが開発したPerceptionライブラリを利用する事で
iOS13以降でもObservableが利用可能となるようです!
最新版のリリースが2024年11月28日となっていて(2024年12月17日時点)
継続的にメンテナンスは行われていそうでした!
こちらの紹介は本記事では省略させていただきます🙏
https://github.com/pointfreeco/swift-perception
https://www.pointfree.co/blog/posts/129-perception-a-back-port-of-observable
Observable
マクロに書き換える手順
- ViewModelは
@Observable
により各プロパティの@Published
が不要になります
プロパティが多数あれば宣言が省略できて楽になりますね。
- class SampleViewModel: ObservableObject {
- @Published var events: [Event] = []
- @Published var isLoading: Bool = false
+ @Observable
+ class SampleViewModel {
+ var events: [Event] = []
+ var isLoading: Bool = false
- View側では
@StateObject
を@State
で置き換えます
struct SampleView: View {
- @StateObject var viewModel = SampleViewModel()
+ @State var viewModel = SampleViewModel()
Observable
マクロが効力を発揮するようなユースケース
ここからはパフォーマンスの向上などメリットが見込める場合の解説をしていきます👊
なおここからViewModelはVMと呼称します!
@Binding
で特定の子Viewのみ差分更新をしたい場合
リスト形式になっているViewの中で特定の要素だけの状態を更新したい場合はよくあると思います。
そんな時にすべての要素の更新を避け、差分だけをObservable
を使って更新することができます。
今回は例としてPokeAPIを使ってポケモン図鑑を作成しました!
ObservableObject
と@Observable
の2つの例で実装しています。
2つのサンプルアプリは以下の要件を満たしています。
- ポケモン一覧表示するViewをもち、お気に入り登録することができる
- VMのプロパティを子Viewで
@Binding
として値を受け取る - VMのインスタンスの変化を
print
する - UI画像みたいな感じ
ObservableObject
を利用したポケモン図鑑の実装
一部省略した簡略版となっているため完全版はGitHubのリポジトリを参照してください。
struct PokemonListItem: View {
@Binding var isFavorite: Bool
let name: String
let id: Int
var body: some View {
HStack {
AsyncImage(url: URL(string: "https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/other/official-artwork/\(id).png")) { image in
image
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: 50, height: 50)
} placeholder: {
ProgressView()
.frame(width: 50, height: 50)
}
VStack(alignment: .leading) {
Text(name.capitalized)
.font(.headline)
Text("No.\(id)")
.font(.subheadline)
.foregroundColor(.gray)
}
Spacer()
Button(action: {
isFavorite.toggle()
}) {
Image(systemName: isFavorite ? "heart.fill" : "heart")
.foregroundColor(isFavorite ? .red : .gray)
}
}
.padding(.vertical, 4)
}
}
struct PokemonBookView: View {
@StateObject private var viewModel = PokemonViewModel()
var body: some View {
let _ = Self._printChanges()
NavigationStack {
List {
ForEach(viewModel.pokemons.indices, id: \.self) { index in
PokemonListItem(isFavorite: $viewModel.pokemons[index].isFavorite, name: viewModel.pokemons[index].name, id: viewModel.pokemons[index].id)
}
}
.navigationTitle("ポケモン図鑑")
.overlay {
if viewModel.isLoading {
ProgressView()
}
}
.alert("エラー", isPresented: .constant(viewModel.errorMessage != nil)) {
Button("OK") {
viewModel.errorMessage = nil
}
} message: {
Text(viewModel.errorMessage ?? "")
}
}
.task {
viewModel.fetchPokemons()
}
}
}
class PokemonViewModel: ObservableObject {
@Published var pokemons: [Pokemon] = []
@Published var isLoading = false
@Published var errorMessage: String?
//以下省略
}
struct Pokemon: Codable, Identifiable {
let id: Int
let name: String
let url: String
var isFavorite: Bool
//以下省略
}
実行結果:
このアプリのハートボタンを3回押した際には以下のようにprintが実行されています。
このコードの非効率な点:
このコードはハートボタンを押すと以下の流れで更新が発生します。
- ハートのチェックをつけて変更
- 特定の子View(PokemonListItem)が変更を通知
- 親View(PokemonBookView)が更新
- 全ての子View(PokemonListItem)が更新
つまり、最終的に全ての子Viewが更新(再描画)されてしまい非効率となっています。
Observableによる実装例:
ポイントとなる変更点:
- データモデルの
Item
もclass
に変更して@Observable
を付与しItem
のプロパティの変化を監視 -
TaskItemView
に渡すプロパティは@Bindable
を使用してBindingする
@Bindable
は@Observable
のオブジェクトを@Binding
に変更してくれます!
以下が先ほどのコードをObservableで置き換えていったコードになります。
struct PokemonListItem: View {
@Binding var isFavorite: Bool
let name: String
let id: Int
var body: some View {
HStack {
AsyncImage(url: URL(string: "https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/other/official-artwork/\(id).png")) { image in
image
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: 50, height: 50)
} placeholder: {
ProgressView()
.frame(width: 50, height: 50)
}
VStack(alignment: .leading) {
Text(name.capitalized)
.font(.headline)
Text("No.\(id)")
.font(.subheadline)
.foregroundColor(.gray)
}
Spacer()
Button(action: {
isFavorite.toggle()
}) {
Image(systemName: isFavorite ? "heart.fill" : "heart")
.foregroundColor(isFavorite ? .red : .gray)
}
}
.padding(.vertical, 4)
}
}
struct PokemonBookView: View {
@State private var viewModel = PokemonViewModel()
var body: some View {
let _ = Self._printChanges()
NavigationStack {
List(viewModel.pokemons) { item in
@Bindable var sample = item
PokemonListItem(isFavorite: $sample.isFavorite, name: item.name, id: item.id)
}
.navigationTitle("ポケモン図鑑")
.overlay {
if viewModel.isLoading {
ProgressView()
}
}
.alert("エラー", isPresented: .constant(viewModel.errorMessage != nil)) {
Button("OK") {
viewModel.errorMessage = nil
}
} message: {
Text(viewModel.errorMessage ?? "")
}
}
.task {
viewModel.fetchPokemons()
}
}
}
@Observable
class PokemonViewModel {
var pokemons: [Pokemon] = []
var isLoading = false
var errorMessage: String?
//以下省略
}
@Observable
class Pokemon: Codable, Identifiable {
let id: Int
let name: String
let url: String
var isFavorite: Bool
//以下省略
}
実行結果:
このアプリのハートボタンを3回押してもViewModelのインスタンス自体の変更は行われませんでした!
見込めるメリット:
全ての子Viewで行われていた更新が変化のあった特定の子Viewのみで行われ、他の要素への無駄な更新を抑える事ができます。
「結局、差分更新ってアプリのどういう状況で使えるの?」という方に!
使えるシーンの想像のために他にも具体的な例を紹介!
- インスタなどのお気に入りボタンが並ぶコンテンツ一覧
- アマゾンなどのECサイトアプリの「+/-」ボタンやカウンターの行が並ぶリスト
- 通知設定画面のトグルスイッチのリスト
など思いつく状況は多いため@Observable
を活用するシーンも多いこと間違いなしです!
最後に
使い方をあまり押さえていなかったObservable
に関する知識が増え筆者も勉強になりました!
またこの記事は参考文献の書籍を参考に書いております!
ここまで読んでいただきありがとうございました🙌
参考文献
佐藤 剛士. 『Swift 5.9からのデータ監視 Observationフレームワーク入門 (技術の泉シリーズ)』. インプレス NextPublishing, 2024年.