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?

@Environment(\\.keyPath)をフル活用したアーキテクチャ作り

Last updated at Posted at 2025-09-08

※この記事は iOSDC Japan 2025 に寄稿したパンフレット記事です。


はじめに

去年のiOSDCでは、私は「@Environment(\.keyPath)実践入門」というパンフレット記事を執筆しました。その記事では、@Environment(\.keyPath)の仕組み、基礎的な使い方、そしてKeyPath連結などのちょっとしたテクニックを紹介しました。文字サイズとかを工夫してなんとか8ページに収めたほどのボリュームでしたが、おかげさまでたくさんの好評をいただきました。

しかし、@Environment(\.keyPath)はまだまだ奥が深く、実際のアプリケーション開発ではもっとたくさんの使い方や裏技が存在します。そこで、今回はその続編として、@Environment(\.keyPath)の更なる高度なテクニックから、それらをフル活用したアーキテクチャ作りについて掘り下げていきたいと思います。

今回の記事で紹介するコードはすべて執筆当時最新の安定リリースであるXcode 16.4にて検証しております。また、内容は全て前回の記事を読まれた前提としています。まだ読んでいない方は、まず前回の記事を読んでからこの記事を読むことをお勧めします。

@Environment(\.keyPath)の更なる高度なテクニック

それでは、早速@Environment(\.keyPath)の更なる高度なテクニックを紹介していきます。

既存の環境変数を拡張して使う

最近のアプリ、特にそれなりの規模がある会社の場合は、アプリ内で使うテキストカラーなどをきちんとしたデザインシステムで定義していることが多いです。そして今はカラーの場合、カラースキーム、すなわちライトモードかダークモードか、を考慮する必要があります。そのため、このデザインシステムの使用にあたって、このようにViewの実装を考えていませんか?

// ColorSystem.swift
struct ColorSystem {
    static func primaryTextColor(in colorScheme: ColorScheme) -> Color {
        switch colorScheme {
        // ...
        }
    }
    // ...
}
// ContentView
struct ContentView: View {
    @Environment(\.colorScheme) var colorScheme: ColorScheme

    var body: some View {
        Text("Demo")
            .foregroundStyle(
                ColorSystem.primaryTextColor(in: colorScheme)
            )
    }
}

上記の実装では、@Environment(\.colorScheme)から現在のカラースキームを取得して、色情報を定義したColorSystemに渡し、適切な色を選択してテキストに設定しています。もちろんこの実装でもなんの問題もないですが、やはり呼び出しが無駄に長いのが気になりますよね。

ところが、今回のColorSystemは、既存の環境変数、すなわち@Environment(\.colorScheme)を使いたいだけで、別に状況に応じて違うインスタンスを入れたいわけではないです。このような場合は、実はEnvironmentValuesに対してセッターを入れる必要もないので、ゲッターだけの@Environment(\.keyPath)も定義できます。

// ColorSystem.swift
struct ColorSystem {
    var colorScheme: ColorScheme

    var primaryText: Color {
        switch colorScheme {
        // ...
        }
    }
    // ...
}

extension EnvironmentValues {
    // ここではセッターが必要ないので、`@Entry`は使う必要がありません
    var colorSystem: ColorSystem {
        // `colorScheme`はEnvironmentValuesに既に存在しているので、そのまま使えます
        .init(colorScheme: colorScheme)
    }
}

そしたら、ContentViewの実装は次のように書き換えられます。

// ContentView
struct ContentView: View {
-    @Environment(\.colorScheme) var colorScheme: ColorScheme
+    @Environment(\.colorSystem) var color: ColorSystem

    var body: some View {
        Text("Demo")
-            .foregroundStyle(
-                ColorSystem.primaryTextColor(in: colorScheme)
-            )
+            .foregroundStyle(color.primaryText)
    }
}

この実装では、@Environment(\.colorScheme)を直接参照していちいちColorSystemに渡す必要がなくなり、何よりこれでAPIの設計も工夫しやすくなったため、無駄な「Color」や「System」を省略してもむしろよりわかりやすくなったので、冗長だったコードが一気に短くなり、よりスッキリして読みやすくなりました。

WritableではないKeyPathを使っているビューのプレビューの仕方

ところが、上記のような@Entryを使わずに定義した環境変数や、前回の記事で紹介したKeyPathを連結した環境変数は、EnvironmentValuesにセッターがないため、\.environment(\.keyPath, value)で値の注入ができません。本番環境では別にそもそもそういった環境変数に外部から値を注入しないから問題ない(むしろできたら大問題)ですが、Previewではさまざまなパターンを確認したいことが多いので、値注入したいこともありますよね。

実はこの場合、直接@Environment(\.keyPath)を注入する方法もあります。例えば上のセクションの例で言うと、ContentView(color: .init(<#KeyPath<EnvironmentValues, ColorSystem>#>))のように書けば、ContentView生成時に直接colorに対してColorSystemを注入できます。以前「@Environment(\.keyPath.subPath)があるビューのプレビューの仕方」のタイトルで記事を書いて詳しく説明しましたので、今回は残念ながらページ数の都合で詳細を割愛させてください。ぜひそちらの記事を読んでみてください。

@Environment(\.keyPath)の落とし穴

もちろん、こんな強力な@Environment(\.keyPath)ですが、気をつけないと意外なところで想定外な動きをする落とし穴があります。ここからは、@Environment(\.keyPath)を使う上での注意点や落とし穴について紹介します。

@Entryのデフォルト値は何回も作られる

Xcode 16から、環境変数の宣言は@Entryで簡単にできるようになり、自力でいちいち頑張ってEnvironmentKeyを定義する必要がなくなりました。しかし、@Entryで宣言したデフォルト値は、実は保存されることなく、必要になるたびに新たに生成されてしまいます。

例えばこのように、イニシャライザーで生成時に何か出力をする型を作り、そしてEnvironmentValuesで環境変数として定義しますが、値を注入せずにデフォルト値を利用させるコードを作ってみます。

struct Demo {
    var value: Bool
    init(value: Bool) {
        print("init", value); self.value = value
    }
}
extension EnvironmentValues {
    @Entry var demo: Demo = .init(value: false)
}

struct ContentView: View {
    var body: some View {
        NavigationStack {
            NavigationLink("Demo") {
                Child()
            }
        }
    }
}
struct Child: View {
    @Environment(\.demo.value) var value: Bool
    var body: some View {
        Text("\(value)")
    }
}

このようにすれば、Child@Environment(\.demo)はデフォルト値を使います。そして何度かChildを表示してみると、コンソールが何度もDemo生成時のinit falseが出力されることがわかります。

init falseがたくさん出力されているスクリーンショット

ちなみに今のは敢えて値を注入しなかった結果ですが、実は注入したとしても、@Entryのデフォルト値が複数回生成されることあります。上記のコードを次のように改修してもう一度実行してみるとこのようになります:

struct ContentView: View {
+    @State private var demo: Demo = .init(value: true)
    var body: some View {
        NavigationStack {
@@ @@
        }
+        .environment(\.demo, demo)
    }
}

init falseが2回出力されているスクリーンショット

見ての通り、最初から.environmentで値を注入したにもかかわらず、注入後にデフォルト値のinit falseが2回も出力されていることがわかります。この挙動になっている原因は、あくまで推測ですが環境変数のdemoContentViewの内容を描画時に初めて注入されましたが、その前にAppの描画とContentView自身の描画の段階では注入されていないので、2回デフォルト値を使うしかないからかと思います。ただこれではなぜデフォルト値のinit falseは注入値のinit trueの後に使われるか説明がつきません。どのみちひとまず言えるのは、生成コストの高いものを@Entryでデフォルト値に定義するときは気をつけたほうがいいです。最初からOptionalにしてデフォルト値をnilにするか、Optionalにしたくない場合は意外と昔ながらのEnvironmentKeyを使ってstatie let defaultValueで定義する方法の方がいいかもしれません。

画面内の処理は@Environment(\.keyPath)の変更を追えない

今時のアプリは、非同期処理が当たり前のようになってきています。時には、設計の都合上実際の処理は親が行い、自分自身はあくまで処理のトリガーしか持たないことも考えられます。この時、例えば実際の処理が終わるまでぐるぐるを表示したいと考えることもあるでしょう。この場合ぐるぐるの表示タイミングは、トリガー発火してから該当処理が変更する環境変数の更新までと考えられます。一つ上のセクションのコードの延長線で言うと、自身がdemoの値を変更するトリガーを持ち、そしてそのdemoが変わったらぐるぐるを非表示にしたい、の仕様とします。この場合、下記のような実装を考える方もいるかもしれません:

            NavigationLink("Demo") {
-                Child()
+                Child { new in
+                    Task {
+                        try? await Task.sleep(for: .seconds(1))
+                        demo.value = new
+                    }
                }
struct Child: View {
-    @Environment(\.demo.value) var value: Bool
+    @Environment(\.demo.value) var remote: Bool
+    @State private var local: Bool?
+    private var isProcessing: Bool { local != nil }
+    private var binding: Binding<Bool> {
+        .init(
+            get: { local ?? remote },
+            set: { local = $0 },
+        )
+    }
+    var action: (Bool) -> Void
    var body: some View {
-        Text("\(value)")
+        Toggle("Toggle", isOn: binding)
+            .overlay {
+                ProgressView()
+                    .opacity(isProcessing ? 1 : 0)
+            }
+            .disabled(isProcessing)
+            .task(id: local) {
+                guard let new = local, new != remote else { return }
+                defer { local = nil }
+                action(new)
+                while local != remote {
+                    await Task.yield()
+                }
+            }
    }
}

この実装では、Childはトリガーとしてactionを持ちますが、actionの内容は親のContentViewが定義しています。また、Childは自身が責務を持ってる内部で使う値と区別させるために、環境変数のdemo.valueremoteとして読み取り、内部で使うものをlocalとして持ちます。そしてトグルの操作でlocalが書き込まれたらトリガーが発火し、1秒後にContentViewdemo.value、すなわちChildが持つremoteを変更します。Childはタスクの空きあれば常にremoteの値を読み取り、変更されてlocalと同じ値になったらlocalを消してぐるぐるが非表示になる、と言う動きのはずです。ところが実際動かしてみたらわかりますが、ぐるぐるがいつまで経っても消えないし、CPU使用率も上がりっぱなしです。

ぐるぐるがきえず、CPUの使用率が上がりっぱなしのスクリーンショット

なぜこうなるかというと、そもそも@Environmentで取得されている値は、.taskでキャプチャーされた時点で.taskの処理内では定数になるから絶対に変わらないからです。なのでViewの処理の中で@Environmentの変化を追跡しようとしてはいけません、追跡できずに無限ループになります。

今回の場合、一番適した方法はそもそもaction(Bool) async -> Voidでもらって、await action(new)が終わったらlocal = nilにすればいいかと思います。ただ実際の案件によっては、例えばサーバのレスポンスと状態の更新が別々の処理でタイミングがズレることがあったりで素直なasync処理にできないこともあるかもしれません。その場合は、処理内で@Environmentを追跡するのではなく、.task(id:)もしくはonChange(of:)で監視するのがいいでしょう。

            .task(id: local) {
                guard let new = local, new != remote else { return }
-                defer { local = nil }
                action(new)
-                while local != remote {
-                    await Task.yield()
-                }
            }
+            .task(id: remote) {
+                local = nil
+            }

もちろんこちら可読性の観点では完璧とは言い難いし、そもそもremoteの更新が本当に今のlocalの変更によるものか判別する手段すらない(サーバの設計がこうなった時点で完璧な解は無理な気もします)ですが、とりあえず覚えておいて欲しいのは、@Environmentの変化を追跡したい時、Viewの処理内ではなく、.task(id:)もしくはonChange(of:)で行うことです。

UIKitのModal遷移を行うと環境変数が引き継がれない

SwiftUIは残念ながら、UIKitほど画面遷移の自由度が高くありません。そのため、アプリの骨組みをUIKitで作り、各々の画面だけSwiftUIのViewで実装し、画面遷移が必要な時にUIKitの処理に任せる手法もよく使われています。しかしこの作り方をするときに気をつけて欲しいのは、UIKitのModal遷移メソッドを使うと、SwiftUIの環境変数が引き継がれないことです。

例えば今回の記事で何度も登場したDemoの環境変数をルートのAppで定義し、ContentViewをUIKitで作り、UIKitのModal遷移でChildを表示すると考えましょう。

@main
struct DemoApp: App {
+    @State private var demo: Demo = .init(value: true)
    var body: some Scene {
        WindowGroup {
            ContentView()
        }
+        .environment(\.demo, demo)
    }
}
struct ContentView: UIViewControllerRepresentable {
    func makeUIViewController(context: Context) -> UINavigationController {
        let vc = ViewController()
        let nc = UINavigationController(rootViewController: vc)
        return nc
    }
    func updateUIViewController(_ uiViewController: UINavigationController, context: Context) {}
}

final class ViewController: UIViewController {
    override func viewDidAppear(_ animated: Bool) {
        super.viewDidAppear(animated)
        let new = UIHostingController(rootView: Child { _ in })
        present(new, animated: true)
    }
}

上記のコードでは、DemoAppで一番大元のWindowGroupに対してdemo環境変数を注入して、そしてContentViewはSwiftUIのネイティブViewから、UIKitのUINavigationControllerを包んだUIViewControllerRepresentableにして、その中身のViewControllerが画面に表示されたらUIKitのpresentメソッドでSwiftUIのChildにModal遷移します。ところがページ数の都合でスクリーンショットを割愛しますが、いざこのアプリを実行してみるとわかると思います。Child@Environment(\.demo.value)は注入されたtrueではなく、デフォルト値のfalseが表示されます。

ちなみに、上記の例はサンプルコードを最小限に収めるために起動直後に自動遷移させていますが、ボタンを置いて手動遷移させても同じ結果です。逆に同じModal遷移でもSwiftUIの.sheetなどを使えば問題ないし、そしてUIKitでもnavigationController?.pushViewControllerでPush遷移を行えば問題ないです。UIKitのModal遷移だけが問題です。これは仕様なのかバグなのかはわかりませんが、UIKitを使ったModal遷移を行うときは気をつけましょう。

@Environment(\.keyPath)をフル活用したアーキテクチャ作り

さて、前振りがだいぶかかりましたが、これでようやくこの記事の大本命である@Environment(\.keyPath)を最大限に活用したアーキテクチャの紹介に入りたいと思います。まあアーキテクチャと言っても、そんなにたいそうなものではありません。と言うのも、私は過激派のMVVM不要論信者なので、一番シンプルなMV構成でプログラムを組むのが好きです。ただシンプルである故に、抽象化が非常に大事だと思います。そうでないと一つ一つの部品が担当することが膨らみ、依存の置き換えが難しくなりがちで、テストも書きにくければ、せっかくの使いやすいPreviewも作りにくくなります。

ところで、「抽象化」と聞くと、すぐprotocolを思い浮かぶ人が多いではないかと思います。もちろん間違いではありませんが、実はprotocolはあくまで抽象化のための手段の一つに過ぎません。極端の話、例えばボタンがあるとします。このボタンをタップすると、何かの非同期な処理が行うとします。この場合、何の処理を行うかはわかりませんが、とりあえずこの処理を「型」で表現すれば、() async -> Voidになりますね。実はこのように単純な型で表現すること自体も、立派な抽象化と言えます。

ただSwiftの場合、このような入力と出力が型として定義された変数、すなわち匿名関数のことをクロージャと言いますが、クロージャのままでは参照型になるので、特に@Environment(\.keyPath)ではいろいろ不都合がつきやすいです。そこでご紹介したいのはSwift 5.2から導入されたCallable Value機能です。値型のstructでもクロージャのように処理として呼び出せます。処理を外からもらうためには、作り方は次のようにstructにクロージャを保存して、それをfunc callAsFunction()で呼び出すだけです:

struct ButtonAction {
    private var action: () async -> Void
    // これで外部から処理を注入できます
    init(_ action: @escaping () async -> Void) {
        self.action = action
    }
    func callAsFunction() async {
        await action()
    }
}
// 作るときはこんな感じです
let buttonAction = ButtonAction {
    // ここで処理を入れてあげます
}
// そして呼び出すときはこんな感じです
await buttonAction()

見慣れていない方もいるかも知れませんが、実はこのbuttonActionのようなものは、実は皆さんがSwiftUI書くとき当たり前のように毎日使っているはずです。@Environment(\.dismiss)@Environment(\.openURL)などの環境変数はまさにこのように抽象化して作られています。つまり、我々も同じように、@Environment(\.keyPath)とCallable Valueを組み合わせて抽象化を行えるのです。

ここでこんなシチュエーションを考えてみましょう。例えばカウンターアプリを作っています。そしたら肝心なカウンターオブジェクトはこんな感じで作る人が多いではないでしょうか:

@Observable
final class Counter {
    var count: Int = 0
    func increment() {
        count += 1
    }
}

extension EnvironmentValues {
    @Entry var counter: Counter?
}

そしてこのカウンターを使うViewは、例えばこんな感じで実装されるでしょう:

struct CounterView: View {
    @Environment(\.counter) var counter: Counter?
    var body: some View {
        VStack {
            Text("Count: \(counter?.count ?? 0)")
            Button("Increment") {
                counter?.increment()
            }
        }
    }
}

立派なカウンターアプリですね。ところがここでちょっと問題が発生します。Counter自体は具象型のオブジェクトなので、例えばCounterView#Previewで一気に3桁の数値をカウントアップしてプレビューしたい、などの時に、それが難しいですよね。CounterViewが実際に必要なのは、今の数値countと、それをカウントアップする() -> Voidの処理だけなので、先ほど紹介したCallable Valueとかを駆使して、CounterViewの依存を抽象化しましょう:

Counter.swift
extension EnvironmentValues {
-    @Entry var counter: Counter?
+    @Entry var _counter: Counter? // `_`をつけるのは、基本これを直接使って欲しくない、という意図を伝えるためです
}
extension EnvironmentValues {
    var count: Int {
        guard let _counter else {
            os_log(.error, "Counter is nil")
            return 0
        }
        return _counter.count
    }
}
struct IncrementAction {
    private var action: () -> Void
    func callAsFunction() {
        action()
    }
    static func preview(
        _ action: @escaping () -> Void = {},
        file: String = #file,
        function: String = #function,
    ) -> Self {
        self.init {
            print(file, function)
            action()
        }
    }
}

private extension IncrementAction {
    init(_ counter: Counter?) {
        guard let counter else {
            os_log(.error, "Counter is nil")
            self = .preview()
            return
        }
        self.action = {
            counter.increment()
        }
    }
}

extension EnvironmentValues {
    var increment: IncrementAction {
        .init(_counter)
    }
}

こうすれば、CounterViewは次のように書き換えられます:

struct CounterView: View {
-    @Environment(\.counter) var counter?
+    @Environment(\.count) var count: Int
+    @Environment(\.increment) var increment: IncrementAction
    var body: some View {
        VStack {
-            Text("Count: \(counter?.count ?? 0)")
+            Text("Count: \(count)")
            Button("Increment") {
-                counter?.increment()
+                increment()
            }
@@ @@

そしたら、このCounterViewをプレビューで一気に3桁カウントアップしたいときは、次のように書けます:

private extension EnvironmentValues {
    @Entry var _previewCount: Binding<Int> = .constant(0)
    var previewCount: Int { _previewCount.wrappedValue }
    var previewIncrement: IncrementAction {
        .preview {
            _previewCount.wrappedValue += Int.random(in: 100..<1000)
        }
    }
}

#Preview {
    @Previewable @State var count: Int = 0
    CounterView(
        count: .init(\.previewCount),
        increment: .init(\.previewIncrement)
    )
    .environment(\._previewCount, $count)
}

これで、CounterViewのプレビューで、countの値を変更したり、incrementで一気に大きな数値を上げることができました。

実は2025年9月8日現在、SwiftではFunctionやInitializersにもKeyPathを使えるようにするプロポーザルがレビューされています。もしこれが通り実装されたら、上記の実装がさらに簡単になり、プレビュー用にEnvironmentValuesをたくさん拡張せずに済むと思われます:

private extension EnvironmentValues {
-    @Entry var _previewCount: Binding<Int> = .constant(0)
-    var previewCount: Int { _previewCount.wrappedValue }
-    var previewIncrement: IncrementAction {
-        .preview {
-            _previewCount.wrappedValue += Int.random(in: 100..<1000)
-        }
-    }
+    func preview(action: () -> Void) -> IncrementAction {
+        .preview(action)
+    }
}

#Preview {
    @Previewable @State var count: Int = 0
    CounterView(
-        count: .init(\.previewCount),
-        increment: .init(\.previewIncrement)
+        increment: .init(\.preview {
+            count += Int.random(in: 100..<1000)
+        })
    )
-    .environment(\._previewCount, $count)
+    .environment(\._count, $count)
}

もちろん、今回の例では、Counterが非常に単純なので、むしろそのままprotocolで抽象化した方がやりやすいですが、実際の案件ではより複雑で多くの機能を備えた部品も多いです。そのような部品をprotocolで抽象化するのもなかなか限界があったり、またprotocolの粒度もなかなか悩ましいですね。しかし今回紹介した方法なら、単一責任原則を最大限に守った抽象化ができるので、各Viewの依存を必要最小限に抑えつつ、プレビューも作りやすくなります。何ならそもそもCounterみたいな一元管理する部品を作る設計ではなく、例えばReduxなどのような演算と状態を分ける設計でも、必要な処理を注入する方法さえあれば抽象化可能です。ぜひ今回紹介した方法を、今後のアプリ開発で活用してください。ちなみに、実は筆者の個人アプリのQuickshaReや、個人ライブラリーのTardinessは、まさにこの仕組みをたくさん使っています。オープンソースですので、ぜひGitHubでel-hoshino/QuickshaReおよびel-hoshino/Tardinessをチェックしてみてください。そしてよければスターください。

あとがき

いかがでしたでしょうか。我々が普段何気なく使っている@Environment(\.keyPath)ですが、実は非常に強力な機能であり、うまく活用すればアプリのアーキテクチャにも大きく貢献します。今回の記事で少しでも今まで以上に@Environment(\.keyPath)の魅力を感じていただけたら、@Environment(\.keyPath)宣教師として嬉しい極まりないです。

また、今回の記事はhttps://github.com/el-hoshino/an_advanced_introduction_to_at_environment_keypathでも公開しております。締切後の修正や追記はそちらでも行うかもしれませんので、興味があればぜひご覧ください。

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?