SwiftUI @EnvironmentObject で依存注入する

ビュー階層が深くなると、親から子、子から孫へとオブジェクトをバケツリレーのように渡す必要が出てくる。@ObservedObject で1段ずつ渡していくのは冗長で、途中のビューが使いもしないプロパティを持つことになる。@EnvironmentObject はこの問題を解決するための仕組みで、ビュー階層の任意の場所から共有オブジェクトに直接アクセスできる。

バケツリレー問題

まず @EnvironmentObject なしの場合を考える。ルートビューで生成したモデルを、3階層下のビューで使いたい場合、途中のビューすべてにプロパティを持たせる必要がある。

struct RootView: View {
    @StateObject private var settings = AppSettings()

    var body: some View {
        MiddleView(settings: settings)
    }
}

struct MiddleView: View {
    @ObservedObject var settings: AppSettings

    var body: some View {
        DetailView(settings: settings)
    }
}

struct DetailView: View {
    @ObservedObject var settings: AppSettings

    var body: some View {
        Text(settings.theme)
    }
}

MiddleView は settings を自分では使わず、ただ DetailView に渡すためだけに保持している。ビュー階層がさらに深くなれば、この無駄な受け渡しは増え続ける。

@EnvironmentObject の導入

@EnvironmentObject を使えば、ビュー階層のどこからでも共有オブジェクトにアクセスできる。渡す側は .environmentObject() モディファイアを使い、受け取る側は @EnvironmentObject で宣言するだけでよい。

struct RootView: View {
    @StateObject private var settings = AppSettings()

    var body: some View {
        MiddleView()
            .environmentObject(settings)
    }
}

struct MiddleView: View {
    var body: some View {
        DetailView()
    }
}

struct DetailView: View {
    @EnvironmentObject var settings: AppSettings

    var body: some View {
        Text(settings.theme)
    }
}

MiddleView は settings に一切関与しなくなった。DetailView が @EnvironmentObject で直接 settings を受け取れるため、バケツリレーが不要になる。

バケツリレー(@ObservedObject)

すべての中間ビューにプロパティが必要。階層が深いほど冗長になり、リファクタリングのコストも増える。

環境注入(@EnvironmentObject)

注入元と利用先だけが関与する。中間ビューはオブジェクトの存在を知る必要がない。

注入の仕組み

.environmentObject() で渡されたオブジェクトは、SwiftUI の環境(Environment)に型をキーとして格納される。子孫ビューで @EnvironmentObject を宣言すると、SwiftUI が環境から同じ型のオブジェクトを探して自動的に注入する。

この仕組みは依存性注入(DI)パターンの一種であり、ビューが具体的なインスタンスの生成方法を知る必要がなくなる。型ベースのルックアップによって自動的にオブジェクトが解決される。

環境内に同じ型のオブジェクトが1つだけ存在することを前提に、型情報だけで該当オブジェクトを特定する仕組み。

このため、同じ型の ObservableObject を複数注入することはできない。もし AppSettings のインスタンスを2つ環境に入れた場合、後から入れたほうで上書きされる。

注入忘れのクラッシュ

@EnvironmentObject の最大の注意点は、注入し忘れるとランタイムでクラッシュすることだ。コンパイラはこのエラーを検出できない。

struct ContentView: View {
    var body: some View {
        DetailView()
        // .environmentObject(settings) を忘れている
    }
}

struct DetailView: View {
    @EnvironmentObject var settings: AppSettings
    // ← ここでクラッシュ
    var body: some View {
        Text(settings.theme)
    }
}

このクラッシュはビューが表示されるタイミングで発生するため、テスト時にたまたまそのビューを開かなければ気づかない場合もある。

Preview でのクラッシュ対策

Xcode Preview でも .environmentObject() を付けないとクラッシュする。Preview 用のモックオブジェクトを用意しておくと開発が楽になる。

テストでの対策

UI テストや単体テストでも環境オブジェクトの注入が必要。テスト用のスタブを作成し、一貫した状態で検証する。

実践的な使いどころ

@EnvironmentObject が効果を発揮するのは、アプリ全体で共有する状態を扱う場面だ。ユーザー設定、認証状態、テーマ設定など、多くのビューが参照するグローバルな状態に向いている。

class AuthManager: ObservableObject {
    @Published var isLoggedIn = false
    @Published var currentUser: User?

    func login(email: String, password: String) {
        // ログイン処理
    }

    func logout() {
        isLoggedIn = false
        currentUser = nil
    }
}

このような認証マネージャーを @EnvironmentObject として注入すれば、ナビゲーションバーのユーザーアイコン、設定画面のログアウトボタン、プロフィール画面の表示名など、階層の異なる複数のビューから同じ状態にアクセスできる。

@main
struct MyApp: App {
    @StateObject private var auth = AuthManager()

    var body: some Scene {
        WindowGroup {
            ContentView()
                .environmentObject(auth)
        }
    }
}

App 構造体のレベルで .environmentObject() を適用すれば、アプリ内のすべてのビューから AuthManager にアクセス可能になる。

@EnvironmentObject と @ObservedObject の選択基準

どちらを使うかは、オブジェクトの利用範囲で決まる。特定の親子関係でのみ使うなら @ObservedObject で明示的に渡すほうが依存関係が追いやすい。複数の離れたビューで共有するなら @EnvironmentObject が適切だ。

利用するビューが1〜2箇所で親子関係にある

@ObservedObject で明示的に渡す

依存関係がコードから読み取れる

利用するビューが多数、または階層が離れている

@EnvironmentObject で環境に注入

中間ビューが不要なプロパティを持たずに済む

明示的な受け渡しと暗黙的な環境注入は、それぞれトレードオフがある。@ObservedObject はコンパイル時の安全性が高く、@EnvironmentObject は柔軟性が高い。プロジェクトの規模と状態の共有範囲に応じて使い分けることが重要だ。