1
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

SwiftUI における ViewModel の初期化とライフサイクル管理

Posted at

@ObservedObject@StateObject の使い分けと依存性注入の考え方 〜

はじめに

SwiftUI では、View は値型(struct)として実装され、頻繁に再生成される仕組みになっています。そのため、状態管理や副作用のあるオブジェクト(ViewModel など)のライフサイクル管理には注意が必要です。
この記事では、ビュー内部で ViewModel を初期化する場合外部から ViewModel(や依存性)を注入する場合 の違いについて、実際のコード例とともに解説します。


1. ViewModel の初期化方法

1.1. ビュー内部での初期化(@StateObject を利用)

ビュー自身が ViewModel のインスタンスを作成し、所有権を持つパターンです。

メリット:

  • ビューと ViewModel のライフサイクルが同期するので、ビューが再生成されても状態が保持される。

注意点:

  • SwiftUI の View は頻繁に再生成されるため、単純に let viewModel = ExampleViewModel() としてしまうと毎回新たなインスタンスが生成され、状態がリセットされる可能性があります。
    → この問題を解決するのが @StateObject です。

コード例

struct ExampleView: View {
    @StateObject private var viewModel: ExampleViewModel

    // カスタムイニシャライザで依存性(パラメータ)を渡しながら ViewModel を生成
    init(parameter: SomeType) {
        _viewModel = StateObject(wrappedValue: ExampleViewModel(parameter: parameter))
    }

    var body: some View {
        Text(viewModel.title)
    }
}

1.2. 外部から ViewModel を注入する(@ObservedObject を利用)

外部で生成した ViewModel を渡して、ビューはその状態の変化を監視するパターンです。

メリット

  • 依存性注入によりテストがしやすく、モックを使った置き換えも容易になります。
  • 複数のビュー間で同じインスタンスを共有しやすい。

注意点

  • ビューはあくまで ViewModel の変更を監視するだけで、所有権は持たないため、@ObservedObject を使います。

コード例

struct ExampleView: View {
    @ObservedObject var viewModel: ExampleViewModel

    var body: some View {
        Text(viewModel.title)
    }
}

// 利用側で ExampleViewModel を生成して渡す
let exampleViewModel = ExampleViewModel(parameter: someParameter)
ExampleView(viewModel: exampleViewModel)

依存性注入とライフサイクル管理

以下の文章は、SwiftUI における依存性注入の具体例と、ビュー・ViewModel、データのインスタンスのライフサイクルについてまとめたものです。


2. 依存性注入の具体例

2.1. DetailView のケース

以下のコードは、遷移元のビュー(例: ListView など)で DetailViewModel を初期化し、その依存性として ListViewModel と選択された ID を注入した上で、DetailView に渡しています。

DetailView(
    path: $path,
    detailViewModel: DetailViewModel(
        listViewModel: listViewModel,
        id: listViewModel.selectedID
    )
)

2.2. CollectionView のケース

次の例は、リポジトリを外部から渡し、内部で ViewModel を生成している例です。

struct CollectionView: View {
    @StateObject private var collectionViewModel: CollectionViewModel
    
    // リポジトリを外部から受け取り、内部で ViewModel を初期化
    init(sectionRepository: any BaseRepositoryProtocol<Section>, itemRepository: any BaseRepositoryProtocol<Item>) {
        _collectionViewModel = StateObject(wrappedValue: CollectionViewModel(sectionRepository: sectionRepository, itemRepository: itemRepository))
    }
    
    var body: some View {
        ScrollView {
            VStack(alignment: .leading, spacing: 16) {
                ForEach(collectionViewModel.sections, id: \.self) { section in
                    VStack(alignment: .leading, spacing: 8) {
                        <!-- セクション名をセクションヘッダーのように表示 -->
                        Text(section.name)
                            .font(.headline)
                            .padding(.bottom, 4)
                        
                        <!-- 各セクションのアイテムを縦にリスト表示 -->
                        ForEach(collectionViewModel.getItems(for: section), id: \.self) { item in
                            HStack {
                                Text(item.name)
                                    .padding(.vertical, 8)
                                    .padding(.leading, 12)
                                Spacer()
                            }
                            .frame(maxWidth: .infinity)
                            .background(Color(.systemGray6))
                            .cornerRadius(8)
                        }
                    }
                    .padding(.horizontal)
                }
            }
        }
        .onAppear {
            collectionViewModel.loadSections() // 画面に戻ってきたときにセクションを再取得
        }
    }
}

ポイント

  • リポジトリは外部から渡されるので、依存性注入が行われています。
  • ViewModel は内部で生成され、@StateObject によって管理されるため、ビューの再生成時にも状態が保持されます。

3. ビューと ViewModel、データのインスタンスのライフサイクルについて

以下は、あるシナリオ(例: DetailView への遷移)における、StoreDetailViewModelDetailView のライフサイクルの考え方です。

3.1. Store

初期化:
Store はアプリのエントリーポイント(例: @mainApp 構造体内やルートビュー)で初期化され、アプリ全体のライフサイクルに渡って存続します。

役割:
アプリ全体のデータを一元管理するシングルトン的な役割を果たします。

3.2. DetailViewModel

初期化:
DetailViewModel は DetailView 内で @StateObject を使って初期化されます。DetailView がナビゲーションスタックに追加されたタイミングで生成され、ビューの再描画が行われても同じインスタンスが保持されます。

破棄:
DetailView がナビゲーションスタックから取り除かれると、連動して DetailViewModel も破棄されます。

依存性:
DetailViewModel は初期化時に、上位で作成された Store(または ListViewModel)を注入され、常に最新の全体データにアクセス可能です。


3.3. DetailView

初期化:
DetailView は遷移元(例: ListView など)からの遷移により生成されます。

再生成:
SwiftUI の View は値型であり、状態変化などにより何度も再生成されますが、@StateObject により保持される DetailViewModel は再生成されず、初回生成されたものが使われ続けます。

破棄:
ユーザーが DetailView から戻ると、DetailView と関連する DetailViewModel は解放されます。

4. @ObservedObject@StateObject に関する理解のポイント

@ObservedObject

  • ビュー内で直接インスタンス化すると、ビューの再生成とともにそのインスタンスも再生成され、状態がリセットされる。
  • 本来は外部から渡されたオブジェクトを監視するために使用する。

@StateObject

  • ビューの再描画(body の再評価)は頻繁に発生しても、初回に生成されたインスタンスは保持され続ける。
  • ビュー自体が破棄されるタイミングで、初めて ViewModel のインスタンスも破棄される。

依存性注入の場合

  • 遷移元のビューから ViewModel やデータのインスタンスを注入している場合、注入されたインスタンスは親(上位)のライフサイクルに依存するため、遷移先のビューが破棄されてもインスタンス自体は保持される。

まとめ

SwiftUI の View は値型で再生成が頻繁に行われるため、状態管理には @StateObject@ObservedObject を使い分ける必要があります。

  • @StateObject
    ビューが所有する状態(ViewModel)のインスタンスを初回のみ生成し、ビューの再描画時も保持できる。

  • @ObservedObject
    外部から注入されたインスタンスの変化を監視するために使い、ビューの再生成に伴ってインスタンスが再生成されるリスクはありません。

依存性注入により、外部から必要なオブジェクト(リポジトリや Store など)を渡すことで、テストや再利用性を高める設計が可能となります。

備考

この記事は、あるチャットでの質疑応答内容をもとにまとめたものです。各種サンプルコードや考え方は、実際のプロジェクトや仕様に合わせて適宜ご調整ください

1
2
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
1
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?