VeiwModelをObservableObjectからObservableに置き換える

## はじめに
watnowアドベントカレンダーの22日目を務めますカブです！
今年もあと少しとなってしまいましたね
副代表を務めてもうすぐ1年となり時間の速さに驚いています😱

https://qiita.com/advent-calendar/2024/watnow-calender

本記事は自分が春頃から始めた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`の上位互換となっています！（オプトアウト方式の方がオプトイン方式よりも監視漏れが発生しにくいため優れていると考えています。）

:::note info
Swift Concurrency がiOS 15からiOS 13でにバックポートしたように
Observableでも期待したいところですが**公式**では見送られています。
そのため公式としての利用はiOS17以降でのみの条件付きとなってしまっています。
https://forums.swift.org/t/backporting-swift-5-9s-observability/65821/16
:::

:::note warn
しかし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`マクロに書き換える手順

1. ViewModelは`@Observable`により各プロパティの`@Published`が不要になります

プロパティが多数あれば宣言が省略できて楽になりますね。

```SampleViewModel.swift
- class SampleViewModel: ObservableObject {
-   @Published var events: [Event] = []
-   @Published var isLoading: Bool = false

+ @Observable
+ class SampleViewModel {
+   var events: [Event] = []
+   var isLoading: Bool = false
```

2. View側では`@StateObject`を`@State`で置き換えます

```SampleView.swift
struct SampleView: View {
-   @StateObject var viewModel = SampleViewModel()
+   @State var viewModel = SampleViewModel()
```



# `Observable`マクロが効力を発揮するようなユースケース

ここからはパフォーマンスの向上などメリットが見込める場合の解説をしていきます👊

なおここからViewModelはVMと呼称します！


# `@Binding`で特定の子Viewのみ差分更新をしたい場合


リスト形式になっているViewの中で特定の要素だけの状態を更新したい場合はよくあると思います。
そんな時にすべての要素の更新を避け、差分だけを`Observable`を使って更新することができます。
今回は例としてPokeAPIを使ってポケモン図鑑を作成しました！
`ObservableObject`と`@Observable`の2つの例で実装しています。

https://github.com/kaka0913/ObservableObjectSampleApp

https://github.com/kaka0913/ObservableMacrosSampleApp





2つのサンプルアプリは以下の要件を満たしています。

- ポケモン一覧表示するViewをもち、お気に入り登録することができる
- VMのプロパティを子Viewで`@Binding`として値を受け取る
- VMのインスタンスの変化を`print`する
- UI画像みたいな感じ


<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3621103/357815cc-e6f7-83fa-6b70-3b1a7613d85a.png" alt="サンプル画像" width="300" height="200"/>

## `ObservableObject`を利用したポケモン図鑑の実装

一部省略した簡略版となっているため完全版はGitHubのリポジトリを参照してください。

```PokemonBookView.swift

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が実行されています。

![スクリーンショット 2024-12-21 23.17.42.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3621103/1876115c-0d72-bc16-0bbe-c53dcb4f7de2.png)


### このコードの非効率な点：

このコードはハートボタンを押すと以下の流れで更新が発生します。

1. ハートのチェックをつけて変更
2. 特定の子View(PokemonListItem)が変更を通知
3. 親View(PokemonBookView)が更新
4. 全ての子View(PokemonListItem)が更新

つまり、最終的に全ての子Viewが更新（再描画）されてしまい非効率となっています。


![SwiftUI Todo List Optimization.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3621103/8b65c457-9b3b-360e-030b-46594ee33d50.png)

## Observableによる実装例：

### ポイントとなる変更点：
- データモデルの`Item`も`class`に変更して`@Observable`を付与し`Item`のプロパティの変化を監視
- `TaskItemView`に渡すプロパティは`@Bindable`を使用してBindingする

:::note info　
`@Bindable`は`@Observable`のオブジェクトを`@Binding`に変更してくれます！
:::

以下が先ほどのコードをObservableで置き換えていったコードになります。

```PokemonBookView.swift

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のインスタンス自体の変更は行われませんでした！

![スクリーンショット 2024-12-21 23.16.53.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3621103/4b999ca8-25fc-0fba-b33c-e05d6cac9570.png)


### 見込めるメリット：
全ての子Viewで行われていた更新が変化のあった特定の子Viewのみで行われ、他の要素への無駄な更新を抑える事ができます。



:::note info　
「結局、差分更新ってアプリのどういう状況で使えるの？」という方に！

使えるシーンの想像のために他にも具体的な例を紹介！

- インスタなどのお気に入りボタンが並ぶコンテンツ一覧
- アマゾンなどのECサイトアプリの「+/-」ボタンやカウンターの行が並ぶリスト
- 通知設定画面のトグルスイッチのリスト

など思いつく状況は多いため`@Observable`を活用するシーンも多いこと間違いなしです！
:::


## 最後に

使い方をあまり押さえていなかった`Observable`に関する知識が増え筆者も勉強になりました！
またこの記事は参考文献の書籍を参考に書いております！
ここまで読んでいただきありがとうございました🙌


## 参考文献
佐藤 剛士. 『Swift 5.9からのデータ監視 Observationフレームワーク入門 (技術の泉シリーズ)』. インプレス NextPublishing, 2024年.