2
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

watnow Advent Calendar 2024

Day 22

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

Last updated at Posted at 2024-12-21

はじめに

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

本記事は自分が春頃から始めたSwiftに関するものです。
iOSアプリをMVVMアーキテクチャで作成してきたのですが、
ObservableObjectを利用して実装してきました。
そのためObservableをあまり利用した事がありませんでした。

そこで今回はSwift5.9から登場した、
Observableマクロを利用した実装の仕方やその説明について記事を書いていきます!
間違い等あればお手柔らかにご指摘していただければ助かります🙇‍♂️

対比早見表

ObservableObjectObservableはリアクティブな状態管理を提供する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マクロに書き換える手順

  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
  1. 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つの例で実装しています。

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

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

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

このコードの非効率な点:

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

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

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

SwiftUI Todo List Optimization.png

Observableによる実装例:

ポイントとなる変更点:

  • データモデルのItemclassに変更して@Observableを付与しItemのプロパティの変化を監視
  • TaskItemViewに渡すプロパティは@Bindableを使用してBindingする

@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

見込めるメリット:

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

「結局、差分更新ってアプリのどういう状況で使えるの?」という方に!

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

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

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

最後に

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

参考文献

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

2
3
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?