SwiftUIでアプリを育てていると、「依存性注入」と聞くだけでなぜか身構えてしまいがちです。
EnvironmentObjectだけで十分そうに見えても、アプリが少し大きくなると、あちこちで問題が出始めます。
この記事では、SwiftUIの依存性注入をEnvironmentObjectからプロトコルベースのDI(Dependency Injection)まで、私が整理した順番で解説します。
EnvironmentObjectは画面間で値を共有するためのツールです。本当に差し替えやすい構造にしたいなら、サービスをプロトコルで抽象化して注入する方法が有効でした。
依存性注入とは何ですか?
依存性注入を難しく説明する記事は多いですが、私はこう説明しています。
オブジェクトが使う道具を自分で作らず、外から受け取ることです。
たとえばViewModelがネットワークサービスを内部で直接newしてしまうと、後でテストするときに本物のサーバーへアクセスする必要があります。
でも、そのサービスを外から渡せば、テスト時には偽物のサービスを渡せばよいわけです。
これだけです。概念自体は本当にシンプルです。
まずEnvironmentObjectから始めました
SwiftUIを学ぶと、最初に出会うものの一つがEnvironmentObjectです。
親ビューから一度注入すれば、どの子ビューからでも取り出して使えるので、最初はとても便利でした。
次のようにルートで一度注入すると、子ビューからすぐに取り出して使えます。
class UserStore: ObservableObject {
@Published var name = ""
}
// ルートで注入
ContentView().environmentObject(UserStore())
// どの子ビューからでも
@EnvironmentObject var store: UserStore
ログイン状態やテーマのようにアプリ全体で共有する値には、これ以上のものはありません。
画面が増えると問題が起きました。EnvironmentObjectの注入を忘れると、アプリは実行時にそのまま落ちます。コンパイル時には何も教えてくれません。
あくまでビュー階層に結び付いたツールなので、ビュー外の純粋なロジックで使うには少し不自然でした。
そこでプロトコルDIへ移行しました
ここからが本題です。サービスを具体型ではなくプロトコルとして定義します。
以下は、実際の実装とテスト用の偽実装を同じプロトコルにまとめた例です。
protocol WeatherService {
func fetch() async -> Int
}
struct RealWeather: WeatherService {
func fetch() async -> Int { 23 }
}
struct MockWeather: WeatherService {
func fetch() async -> Int { 999 } // テスト用の固定値
}
ViewModelはRealWeatherを知りません。WeatherServiceという契約だけを知っています。
実際のアプリではRealWeatherを、テストではMockWeatherを渡せば、コードを一行も変えずに動作を切り替えられます。
この構造に変えてから、テストコードを書くのがずっと楽になりました。
EnvironmentObjectとプロトコルDIはいつ使い分けますか?
どちらか一方を選ぶのではなく、役割が異なります。実際に使ってみて決めた基準を表にまとめました。
| 区分 | EnvironmentObject | プロトコルDI |
|---|---|---|
| 主な目的 | 画面間で状態を共有 | ロジックの依存性を差し替え |
| ビュー階層への結合 | 強い(ビューに依存) | なし(ビューと無関係) |
| テスト時の差し替え | 難しい | 非常に簡単 |
| 注入がない場合 | 実行時クラッシュ | コンパイル時に防止 |
実務では、グローバルな状態はEnvironmentObjectで、差し替えが必要なサービスはプロトコルDIで分けて使う組み合わせが最も無難でした。
まとめ
規模が小さいうちはEnvironmentObjectで十分ですが、テストを真剣に考えるなら、サービスをプロトコルで包む習慣を早めに身につけることをおすすめします。後の自分がきっと感謝します。

