はじめに
@EnvironmentObject
= SwiftUIで“上流から下流へ”共有したいオブジェクトを、依存注入(DI)的に配布するためのプロパティラッパー。
画面間での状態共有や、複数ビューからアクセスするアプリケーション層のモデルを、配線コード最小で扱える便利ツール。
本記事では、
- 最小サンプル
- 所有とライフサイクル
- よくある落とし穴
- スケールする設計
- Observation(@Observable)との関係
- SwiftDataとの役割分担
- テスト/プレビュー
- パフォーマンスTips
一気通貫で解説して完全理解を目指そうという試み。
前提バージョン
- Swift 5.9+ / iOS 17+ / Xcode 15+
- 可能な限り最新APIで記述。過去APIは補足で触れます。
@EnvironmentObject
とは
役割:ルートから子孫ビューへの依存注入(DI)
- 親(多くは
App
/ルートView)で注入したObservableObject
を、子孫View全体で参照できる。 - 参照先の
@Published
(あるいはカスタムobjectWillChange
)の更新が、購読中のビューへ自動伝播される。
@Environment
との違い(値型キー vs. 参照型モデル)
@Environment
はEnvironmentKeyにより提供される値型/小さな設定値向け。@EnvironmentObject
は参照型の状態モデル向け。ビューはオブジェクトを所有しない(=寿命管理しない)。
他ラッパーとの比較
ラッパー | 所有者 | 典型用途 | 伝播範囲 | 注意点 |
---|---|---|---|---|
@State | 自分のView | 局所的な値型状態 | 自分のView内 | 小さく保つ |
@Binding | なし(親所有を参照) | 親子間の一点配線 | 親⇄子 | 双方向だがローカル |
@ObservedObject | なし(外部所有を参照) | 親から直接渡された参照型 | 受け取ったView | 所有しない=寿命注意 |
@StateObject | 自分のView | 参照型の所有/初期化 | 自分のView内 | 再生成を避けるルートで |
@EnvironmentObject | なし(上流が所有) | アプリ全体/大きめの共有状態 | 子孫View全体 | 注入忘れでクラッシュ |
@Environment | なし(フレームワーク/拡張が提供) | 設定値/依存(dismiss 等) | 子孫View全体 | 値型で小さく |
最小サンプルで理解する
1) モデル定義
import SwiftUI
final class CounterModel: ObservableObject {
@Published var count = 0
func increment() { count += 1 }
}
2) ルートで注入
@main
struct CounterApp: App {
@StateObject private var counter = CounterModel()
var body: some Scene {
WindowGroup {
RootView()
.environmentObject(counter) // ここで注入
}
}
}
3) 子ビューで参照
struct RootView: View {
var body: some View {
VStack(spacing: 16) {
CounterView()
OtherView()
}
.padding()
}
}
struct CounterView: View {
@EnvironmentObject private var counter: CounterModel
var body: some View {
VStack(spacing: 8) {
Text("Count: \(counter.count)")
Button("+1") { counter.increment() }
}
}
}
struct OtherView: View {
@EnvironmentObject private var counter: CounterModel
var body: some View {
Text("他のビューでも: \(counter.count)")
}
}
ライフサイクル図(ざっくり)
- 所有者:
@StateObject
を持つルート(App
/Scene直下のView)。 - 消費者:
@EnvironmentObject
で参照する子孫View。 - 更新の流れ:
counter.increment()
→@Published
発火→依存するViewが再描画。
正しい所有とライフサイクル
項目 | 内容 | ポイント/対策 |
所有の原則 | @EnvironmentObject は所有しない。@StateObject が所有したものを配るだけ。 | 所有は1ヶ所に集約。環境は参照のみ。 |
所有の推奨位置(アプリ全体で共有) | App / Scene 直下で@StateObject 化 → .environmentObject で下流へ配布。 | アプリ共通のセッション/設定などに適用。 |
所有の推奨位置(特定フロー限定) | フローのルートViewで@StateObject 化し、配下のみ.environmentObject 。 | フロー終了とともに破棄され寿命管理が明確。 |
再生成の罠 | ルートViewが条件分岐で再生成されると@StateObject も作り直される。 | ルートを安定させる/一段親に引き上げて保持。 |
ありがちなクラッシュと対処
“No ObservableObject of type XXX found”
原因:.environmentObject
の注入忘れ/スコープ外/型不一致。
チェックリスト
- ルートで
.environmentObject(model)
しているか? - その
model
はObservableObject
を実装しているか? - 型が一致しているか?(別モジュール/名前空間違い)
- ナビゲーション遷移で別ツリーを作っていないか?(新しいScene/Windowなど)
- プレビューで注入しているか?(忘れがち)
プレビューでの注入忘れ
#Preview {
RootView()
.environmentObject(CounterModel())
}
型の食い違い
FeatureA.CounterModel
と App.CounterModel
は別型。import/モジュール名を確認。
ナビゲーションでの未注入
WindowGroup
やWindowScene
を分ける/独立ウィンドウを開くと、別の環境になる。必要なら各Sceneに対して.environmentObject
を付与する。
スケールする設計パターン
アンチパターン:1巨大モデル
すべての状態を1つのAppModel
に詰めると、関心ごとが混在し、不要な再描画や結合の強化を招く。
推奨:機能別に小さな環境オブジェクト
- 例:
AuthModel
/ProfileModel
/CartModel
/SettingsModel
… - ビューは必要なモデルだけを
@EnvironmentObject
で受け取る。
循環参照の回避
モデル間の参照は一方向に。どうしても相互が必要なら、疎結合なプロトコル経由や、イベント/Actionクロージャにする。
モジュール分割
フィーチャーモジュールにEnvironmentObject
のプロトコル型を公開し、実装はアプリ側で注入。依存逆転でテスト容易性を確保。
API使い分け早見表
- 局所状態 →
@State
- 親子一点配線 →
@Binding
- 外部所有の参照型を1画面で使う →
@ObservedObject
- 参照型の所有/初期化 →
@StateObject
- 多階層へ横断的に共有 →
@EnvironmentObject
- 小さな設定値/依存(
dismiss
/modelContext
など) →@Environment
アンチパターン例:
- 単に“どこからでもアクセスしたい”で
@EnvironmentObject
を乱用する。 - View内部で
@StateObject
と@EnvironmentObject
の二重所有(片方に統一)。
Observation(Swift 5.9+)との関係
Swift 5.9では@Observable
マクロが導入された。現状、@EnvironmentObject
は**ObservableObject
準拠の型を想定してるが、@Observable
モデルを薄いラッパーで包む**ことで共存できる。
@Observableの最小例
import Observation
@Observable
final class Counter { // @Published不要、プロパティ監視を自動生成
var count = 0
func increment() { count += 1 }
}
@EnvironmentObject
に載せるブリッジ例
// Observableベースのドメインモデル
@Observable
final class CounterCore {
var count = 0
func increment() { count += 1 }
}
// SwiftUIの環境に載せるための薄いラッパー
final class CounterAdapter: ObservableObject {
@Published private(set) var count: Int = 0
private let core: CounterCore
init(core: CounterCore) {
self.core = core
}
func increment() {
core.increment()
count = core.count
}
}
@main
struct AppEntry: App {
@StateObject private var counter = CounterAdapter(core: .init())
var body: some Scene {
WindowGroup { RootView().environmentObject(counter) }
}
}
既存ObservableObject
ベースのコードベースでは、段階的移行としてラッパー/アダプターを採用すると安全。
EnvironmentKey
で値型DI
設定値や小さな依存は、独自EnvironmentKeyを定義して@Environment
で注入すると軽量
private struct APIBaseURLKey: EnvironmentKey {
static let defaultValue = URL(string: "https://example.com")!
}
extension EnvironmentValues {
var apiBaseURL: URL {
get { self[APIBaseURLKey.self] }
set { self[APIBaseURLKey.self] = newValue }
}
}
struct ContentView: View {
@Environment(\.apiBaseURL) private var baseURL
var body: some View { Text("Base: \(baseURL.absoluteString)") }
}
ナビゲーションと環境
NavigationStack
/sheet
/popover
/fullScreenCover
で表示されるビューにも、同じViewツリー上であれば環境は伝播する。- 別Scene/Windowを開く場合は、そのScene側でも
.environmentObject
を付ける必要がある。 @Environment(\.dismiss)
等のビルトイン環境値と併用して、フロー境界を意識した設計にする。
struct FlowRoot: View {
@EnvironmentObject private var counter: CounterModel
@State private var showSheet = false
var body: some View {
NavigationStack {
VStack(spacing: 12) {
Text("Count: \(counter.count)")
Button("次へ") { showSheet = true }
}
.sheet(isPresented: $showSheet) {
DetailView() // 同じ環境を参照できる
.environmentObject(counter) // 明示的に渡すのもOK
}
}
}
}
SwiftData/データ層との接続
- データ永続はSwiftData(
ModelContext
)、UI共有状態は@EnvironmentObject
と役割分担する。 - リポジトリ/ユースケース層はプロトコル+具体実装で用意し、環境で注入するとテスト換装しやすい。
import SwiftData
protocol TodoRepository {
func add(title: String) async throws
func load() async throws -> [Todo]
}
final class SwiftDataTodoRepository: TodoRepository {
let context: ModelContext
init(context: ModelContext) { self.context = context }
func add(title: String) async throws { context.insert(Todo(title: title)) }
func load() async throws -> [Todo] { try context.fetch(FetchDescriptor<Todo>()) }
}
final class TodoModel: ObservableObject {
@Published private(set) var items: [Todo] = []
private let repo: TodoRepository
init(repo: TodoRepository) { self.repo = repo }
@MainActor
func refresh() async {
do { items = try await repo.load() } catch { /* エラー処理 */ }
}
}
struct TodoRootView: View {
@Environment(\.modelContext) private var context
@StateObject private var model: TodoModel
init() {
// Repositoryを差し替え可能に
_model = StateObject(wrappedValue: TodoModel(repo: SwiftDataTodoRepository(context: .init(
// 実際はApp側でModelContainerを注入
ModelContainer(for: Todo.self)
))) )
}
var body: some View {
List(model.items) { item in Text(item.title) }
.task { await model.refresh() }
.environmentObject(model) // 下層にも共有
}
}
データストア(ModelContext
)をそのままEnvironmentObject
にせず、Repository→UIモデルに段階分離すると、責務の混在と無駄な再描画を防げる。
テストとプレビュー戦略
#Preview
での注入テンプレート
#Preview("カウンター") {
RootView()
.environmentObject({
let m = CounterModel()
m.count = 42
return m
}())
}
UIテスト/単体テスト
- プロトコル化したリポジトリをフェイク/スタブに入れ替えて、
App
起動時に環境で注入。 - 重要画面は最小依存で起動できるようにしておく(プレビュー/テストが楽)。
パフォーマンスの実務TIPS
- 更新の局所化:大きいViewで
@EnvironmentObject
を直接参照せず、小さなサブViewに分割。 - 派生プロパティは必要なときに計算。重い計算はキャッシュや
Task
で非同期化。 EquatableView
や.id()
で無駄な再描画抑制を検討。- モデル分割:高速に変わる状態(タイマー等)を他と分離。
よくある質問(FAQ)
Q1. 何個まで入れてよい?
A. 制限はないが、ビューが依存する数は最小限に。モジュール境界に合わせて分割する。
Q2. @Environment
や@Binding
で十分なケースは?
A. 小さな設定値は@Environment
、親子一点配線は@Binding
が軽量で安全な設計。
Q3. 画面またぎで共有したいがライフサイクルが不安
A. 共有範囲のルートで@StateObject
所有→配下に.environmentObject
。フローが終わればルートごと破棄される。
まとめ
@EnvironmentObject
は所有せず参照するDI機構。- 所有はルートで
@StateObject
、共有は.environmentObject
、消費は@EnvironmentObject
。 - スケールでは機能単位に分割、データ層と責務分離。
- Observation/SwiftDataとも段階的に共存可能。
チェックリスト:
- ルートで
.environmentObject
した? - 型は一致している?
- プレビューで注入した?
- 共有範囲は過不足ない?
付録:スニペット集(コピペ可)
最小雛形
@main
struct MyApp: App {
@StateObject private var appModel = AppModel()
var body: some Scene {
WindowGroup { RootView().environmentObject(appModel) }
}
}
開発中アサーション(注入漏れ検出)
struct RequiresModelView: View {
@EnvironmentObject private var model: AppModel
var body: some View {
assert(modelIsInjected, "AppModel not injected")
return Text("OK")
}
private var modelIsInjected: Bool { true } // 実務ではOptional化やガードで検出
}
独自EnvironmentKeyの雛形
private struct DateProviderKey: EnvironmentKey {
static let defaultValue: () -> Date = Date.init
}
extension EnvironmentValues {
var now: () -> Date {
get { self[DateProviderKey.self] }
set { self[DateProviderKey.self] = newValue }
}
}
参考リンク
- SwiftUI:
environmentObject(_:)
/@EnvironmentObject
– Apple公式ドキュメント: EnvironmentObject | Apple Developer Documentation Apple Developer+15Apple Developer+15Apple Developer+15
–environmentObject(_:)
モディファイアの解説: environmentObject(_:) | Apple Developer Documentation Apple Developer - SwiftUI:
@StateObject
/@ObservedObject
/@Binding
/@Environment
–@StateObject
: StateObject | Apple Developer Documentation Apple Developer+15Apple Developer+15Stack Overflow+15
–@ObservedObject
: ObservedObject | Apple Developer Documentation Apple Developer
– 全体をまとめた公式ページ(概説): Managing model data in your app | Apple Developer Apple Developer+14Apple Developer+14Reddit+14 - Observation:
@Observable
(Swift 5.9+)
– Apple公式:@Observable
マクロについて Stack Overflow+14fatbobman.com+14Swift Forums+14Apple Developer+1
– 移行ガイド(ObservableObject
→@Observable
): Migrating from the Observable Object protocol to the Observable macro | Apple Developer Apple Developer - SwiftData:
ModelContext
/ModelContainer
–ModelContext
: ModelContext | Apple Developer Documentation Apple Developer+9Apple Developer+9wwdcnotes.com+9
–ModelContainer
: ModelContainer | Apple Developer Documentation Reddit+15Apple Developer+15Apple Developer+15 - SwiftUI Previews:
#Preview
– Apple公式:Previews in Xcode
ドキュメント Reddit+11Stack Overflow+11fatbobman.com+11avanderlee.com+6Apple Developer+6Wikipedia+6
–#Preview
マクロの使用解説: Preview(_:body:) | Apple Developer Documentation Apple Developer - WWDCセッション(状態管理/データフロー関連)
– WWDC 2020 の基本セッション: 「Data Essentials in SwiftUI」 Medium+1SwiftyPlace+3Apple Developer+3fatbobman.com+3