※この記事は 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
が出力されることがわかります。
ちなみに今のは敢えて値を注入しなかった結果ですが、実は注入したとしても、@Entry
のデフォルト値が複数回生成されることあります。上記のコードを次のように改修してもう一度実行してみるとこのようになります:
struct ContentView: View {
+ @State private var demo: Demo = .init(value: true)
var body: some View {
NavigationStack {
@@ @@
}
+ .environment(\.demo, demo)
}
}
見ての通り、最初から.environment
で値を注入したにもかかわらず、注入後にデフォルト値のinit false
が2回も出力されていることがわかります。この挙動になっている原因は、あくまで推測ですが環境変数のdemo
はContentView
の内容を描画時に初めて注入されましたが、その前に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.value
をremote
として読み取り、内部で使うものをlocal
として持ちます。そしてトグルの操作でlocal
が書き込まれたらトリガーが発火し、1秒後にContentView
がdemo.value
、すなわちChild
が持つremote
を変更します。Child
はタスクの空きあれば常にremote
の値を読み取り、変更されてlocal
と同じ値になったらlocal
を消してぐるぐるが非表示になる、と言う動きのはずです。ところが実際動かしてみたらわかりますが、ぐるぐるがいつまで経っても消えないし、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
の依存を抽象化しましょう:
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でも公開しております。締切後の修正や追記はそちらでも行うかもしれませんので、興味があればぜひご覧ください。