SwiftUI @EnvironmentObject でアプリ全体に状態を共有する

親から子、子から孫へとプロパティを順番に渡していくと、中間のビューが使いもしないデータをただ中継するだけの「バケツリレー」が発生する。ビュー階層が深くなるほどこの問題は深刻になり、コードの可読性と保守性が低下していく。@EnvironmentObject はこのバケツリレーを解消し、任意の階層のビューから共有データに直接アクセスできる仕組みだ。

基本的な使い方

@EnvironmentObject を使うには 3 つのステップが必要になる。まず ObservableObject に準拠したクラスを用意し、次に .environmentObject() モディファイアでビュー階層に注入し、最後に使いたいビューで @EnvironmentObject として受け取る。

class UserSettings: ObservableObject {
    @Published var userName = "ゲスト"
    @Published var isDarkMode = false
}

このクラスをアプリのルートで注入する。

@main
struct MyApp: App {
    @StateObject private var settings = UserSettings()

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

.environmentObject(settings) を付けた時点で、ContentView 以下のすべてのビュー階層から UserSettings にアクセスできるようになる。

子ビューでの受け取り

注入されたオブジェクトは @EnvironmentObject プロパティラッパーで受け取る。型情報をもとに自動的にマッチングされるため、引数として渡す必要がない。

struct ProfileView: View {
    @EnvironmentObject var settings: UserSettings

    var body: some View {
        VStack {
            Text("こんにちは、\(settings.userName)さん")
            Toggle("ダークモード", isOn: $settings.isDarkMode)
        }
    }
}

ProfileView は親から settings を受け取っていないにもかかわらず、環境から自動的に取得して使えている。中間のビューにはまったく手を加える必要がない。

バケツリレーとの比較

@EnvironmentObject を使わない場合と使う場合で、コードの構造がどう変わるかを比較してみよう。

バケツリレー(@ObservedObject)

RootView → TabView → SettingsView → ProfileView と順番にオブジェクトを渡す必要がある。中間の TabView や SettingsView は使わないのに引数を受け取って子に渡す。

@EnvironmentObject

RootView で一度注入すれば、ProfileView が直接アクセスできる。中間のビューは一切変更不要。

複数の EnvironmentObject を使う

異なる型の ObservableObject であれば、複数同時に環境へ注入できる。型によって区別されるため、同じ型を 2 つ注入することはできない点に注意が必要だ。

class AuthManager: ObservableObject {
    @Published var isLoggedIn = false
}

class CartManager: ObservableObject {
    @Published var items: [String] = []
}

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

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

子ビューではそれぞれ別々に受け取ることができる。

struct CheckoutView: View {
    @EnvironmentObject var auth: AuthManager
    @EnvironmentObject var cart: CartManager

    var body: some View {
        if auth.isLoggedIn {
            Text("カート内: \(cart.items.count)点")
        } else {
            Text("ログインしてください")
        }
    }
}

注入し忘れるとクラッシュする

@EnvironmentObject の最大の注意点は、注入を忘れるとランタイムクラッシュが発生することだ。コンパイル時にはエラーが出ないため、テストやプレビューで気づかないまま本番に持ち込んでしまうリスクがある。

クラッシュの原因

.environmentObject() を付け忘れたビュー階層で @EnvironmentObject にアクセスすると、該当する型が見つからず fatalError が発生する。

対策

アプリのルートで確実に注入する設計にし、プレビューでも .environmentObject() を付ける習慣をつける。NavigationLink の遷移先にも環境は引き継がれるため、通常はルートでの注入で十分だ。

プレビューでは以下のように記述する。

#Preview {
    ProfileView()
        .environmentObject(UserSettings())
}

@EnvironmentObject と @ObservedObject の使い分け

どちらも ObservableObject を監視するプロパティラッパーだが、用途が異なる。

// 直接渡す: 1 対 1 の親子関係が明確な場合
struct ParentView: View {
    @StateObject private var model = SomeModel()

    var body: some View {
        ChildView(model: model)
    }
}

// 環境経由: 多くのビューで共有する場合
struct ParentView: View {
    @StateObject private var model = SomeModel()

    var body: some View {
        ChildView()
            .environmentObject(model)
    }
}

少数のビューだけが使うデータなら @ObservedObject で直接渡すほうが依存関係が明確になる。一方、認証情報やテーマ設定のようにアプリ全体で参照されるデータは @EnvironmentObject が適している。

iOS 17 以降の代替

iOS 17 では @Observable マクロとともに @Environment プロパティラッパーが拡張され、@EnvironmentObject の役割を @Environment が担えるようになった。新しい API では型安全性が向上し、注入忘れもコンパイル時に検出しやすくなっている。ただし、既存のコードベースや iOS 16 以前のサポートが必要な場合は @EnvironmentObject が引き続き重要な選択肢となる。