💉

「Xcode Previewを気軽に使うためのDI戦略」要約&補足

2023/09/21に公開

はじめに

この記事はiOSDC Japan 2023での「Xcode Previewを気軽に使うためのDI戦略」の要約と補足です。
https://fortee.jp/iosdc-japan-2023/proposal/2bfb2b2e-5e5e-44a7-87c9-e0dc66819a1e

スライドも公開しているので、そちらも併せてご確認ください。

発表を見ていない方のために要約を書いていますが内容自体は発表のものと同じかつ長いので、発表を見た方は要約をスキップして、いきなり補足から見ていただいて大丈夫です。

発表の要約

Xcode Previewをする上でのDI

Xcode Previewを利用する上で、Dependency Injection (DI) は避けては通れないテーマです。
例としてAPI通信をして記事の一覧話取得する ItemListView を考えてみます。

struct ItemListView: View {
    @StateObject var viewModel = ItemListViewModel()
    
    var body: some View {
        // ...
	.task {
	    await viewModel.fetchItems()
	}
    }
}

struct ItemListView_Previews: PreviewProvider {
    static var previews: some View {
        ItemListView()
    }
}

class ItemListViewModel: ObservableObject {
    @Published private(set) var items: [Item] = []
    @Published private(set) var error: (any Error)? = nil
    
    func fetchItems() async {
	do {
            items = try await APIClient.shared.getItems()
	} catch {
	    self.error = error
	}
    }
}

このコードには課題があります。
シングルトンを使って、API呼び出しをしていることです。

func fetchItems() async {
    do {
        items = try await APIClient.shared.getItems()
    } catch {
        self.error = error
    }
}

これによって様々な問題が発生してしまいます。

  • Xcode PreviewでUIの確認をするたびにAPI呼び出しされてしまう
  • Xcode Previewの実行結果が、サーバーの実装、macのネット環境など外部要因に依存してしまう

特に2つ目は致命的です。
外部要因に依存してしまうと、Xcode Previewで確認したい目的のUIを表示するためにサーバーの設定を変更したり、macの通信環境を変更したりする必要があります。

そこでDIが必要になります。
DIをサポートするThird Partyライブラリはいくつかありますが、ここでは標準フレームワークで実現可能な、以下の2つを考えます。

  • Initializer Injection (Constructor Injection)
  • Environment / EnvironmentObject

Initializer Injection (Constructor Injection)

DIをする上で一番シンプルな方法はInitializer Injectionです。

protocol APIClientProtocol {
    func getItems() async throws -> [Item]
}

struct ItemListView: View {
    @StateObject var viewModel: ItemListViewModel
    
    var body: some View {
        // ...
	.task {
	    await viewModel.fetchItems()
	}
    }
}

class ItemListViewModel: ObservableObject {
    @Published private(set) var items: [Item] = []
    @Published private(set) var error: (any Error)? = nil
    
    private let apiClient: any APIClientProtocol
    
    init(apiClient: some APIClientProtocol) {
        self.apiClient = apiClient
    }
    
    func fetchItems() async {
	do {
            items = try await APIClient.shared.getItems()
	} catch {
	    self.error = error
	}
    }
}

主な変更点は以下の3つです

  • APIClientのためのprotocolを定義する
  • APIClientProtocolItemListViewModelinitで差し込む
  • ItemListViewModelItemListViewinitで差し込む

Initializer Injectionはコンパイル時に依存が差し込まれていることを保証することができるので、シンプルながら一番安全なDI手法といえます。

Initializer Injectionの課題

Initializer Injectionは安全な方法ですが、一方で課題もあります。
依存のバケツリレー」が必要ということです。

例えばさっき紹介したItemListViewからItemDetailViewに遷移するとします。
そして、このItemDetailViewではDatabaseが必要だったとします。
ということで、先ほどと同様、このDatabaseをInitializer Injectionします。

struct ItemDetailView: View {
    init(item: Item, databsse: any Database) { 
        // ...
    }
}

ところで、このItemDetailViewItemListViewから遷移するので、ItemListViewの中のどこかで

ItemDetailView(item: item, database: database)

と書く必要が出てきます。
itemはAPI呼び出しの結果なのでItemListViewの中にある情報ですが、databaseについてはItemListViewは知りません。
なので、ItemListViewに対してdatabaseをDIする必要があります。

struct ItemListView: View {
    @StateObject var viewModel: ItemListViewModel
    let databse: any Database
    
    init(viewModel: ItemListViewModel, databse: some Darabase) {
        // ....
    }
}

呼び出し側はこんな感じになります。

ItemListView(
    viewModel: ItemListViewModel(apiClient: apiClient),
    database: database
)

ItemListViewはAPI通信をするだけであり、本来はdatabaseを必要としないViewですが、遷移先のために受け取る必要ができてしまいます。
今後遷移先の画面が増えてしまうと、databaseだけでなく、例えば、userDefaultskeychainfileManagerなどを受け取る必要が発生します。

ItemListView(
    viewModel: ItemListViewModel(apiClient: apiClient),
    database: database,
    userDefaults: userDefaults,
    keychain: keychain,
    fileManager: fileManager
)

実際にItemListViewが必要としているのは、ItemListViewModelに渡すapiClientだけですが、遷移先のためにたくさんの依存を渡す必要ができてしまいます。

これはXcode Previewをする時にも弊害があります。
動作確認をするのに必要なのは、apiClientだけなのに、それ以外のモックも用意する必要があり、手軽に動作確認ができません。

Environment / EnvironmentObject

SwiftUIではpropertyWrapperのEnvironment / EnvironmentObjectを用いてバケツリレーを回避できます。

アプリのエントリーポイントとなるViewで依存をEnvironmentに流します。
(Environmentをカスタムする方法はここでは省略します)

RootView()
    .environment(\.apiClient, APIClient())
    .environment(\.database, Database())

呼び出す側はpropertyWrapper経由で必要な依存を取り出せます。

struct ItemListView: View {
    @Environemnt(\.apiClient) var apiClient
    
    init() { // initでapiClientを受け取る必要がない
        // ...
    }
}

initで依存を渡す必要がなくなったので、この方法だとバケツリレーをしなくて良くなりそうです。

Environemnt / EnvironmentObject の課題

バケツリレーという課題を解決した Environmentですが、この方法にも別の課題が存在します。

  • 依存の登録を忘れていてもコンパイルエラーにならない
  • initでEnvironmentの値にアクセスできない

例えば、Environmentを用いたItemListViewをPreviewするとき、次のように書く必要があります。

ItemListView()
    .environemnt(\.apiClient, MockAPIClient())

しかし、仮に上のenvironemnt(\.apiClient, MockAPIClient())の呼び出しが不足していてもコンパイルエラーになりません。
各Viewが中でどの依存を使っているのか把握した上で、毎回適切な依存をEnvironmentに流す必要があります。
さらにViewを使うために必要な依存が増えた際、追加でEnvironmentを登録する必要がありますが、コンパイラはそれを指摘してくれず、自分でコードを読んで修正する必要があり、Previewを試すハードルは高いです。

また、initでアクセスできないのも地味に不便です。
次のコードのように、APIClientやDatabaseをViewModelのようなObservableObjectに渡したいユースケースは少なくありません。

struct ItemListView: View {
    @StateObject var viewModel: ItemListViewModel
    @Envirtonment(\.apiClient) var apiClient: 
    
    init() {
        let viewModel = ItemListViewModel(apiClient: apiClient)
	self._viewState = StateObject(wrappedValue: viewModel)
    }
}

しかし上のコードはXcodeがランタイムに警告を出し、正しく動作しません。
(これは自分の想像ですが、initの時点ではどのViewのヒエラルキーに乗るのか定まっていないため、Viewのヒエラルキーが必要なEnvironmentは使用できないようになっていると考えられます)

こういったことからEnvironemtも解決策にはならなさそうです。

提案手法

アイデア

Xcode Previewを気軽に試したい時、開発者にとっての関心はPreview対象のViewだけです。
言い方を変えると、遷移先の画面はPreview時には関心の対象外です。
このPreviewの関心の対象かどうか、異なるDI手法を取ります。

  • Previewの関心の対象 → Initializer Injection
  • Previewの関心の対象外 → Environemnt

実装

まずはアプリ全体の依存を1つにまとめたstructを定義します。

struct AppDependency {
    let apiClient: any APIClient
    let database: any Database
}

そして、これをアプリのエントリーポイントからEnvironmentで流します。

let dependency = AppDependency(
    apiClient: APIClient(),
    databse: Databse()
)

RootView()
    .environment(\.dependency, dependency)

依存をそれぞれEnvironemntとして流すのではなく、1つのstructにまとめているのが工夫です。
こうすることで、もし依存を追加する時には、AppDependencyにプロパティを追加するようにします。
こうすることで、それを利用している箇所がコンパイルエラーになるので、依存を渡し忘れる心配はありません。

そして、このAppDependencyをEnvironmentから取り出すViewを用意します。
少し長いですが、していることはとてもシンプルです。

  • Environemntにdependencyが登録されていたら、それをinitで受け取ったクロージャーにパスする
  • 登録されていなかったら、赤背景、白文字でdependencyが登録さてれいないことを表示する
struct DependencyProvider<ChildView: View>: View {
    @Environment(\.dependency) private var dependency: AppDependency?
    private let childView: (AppDependency) -> ChildView

    init(childView: @escaping (AppDependency) -> ChildView) {
        self.content = childView
    }

    var body: some View {
        if let dependency {
            childView(dependency)
        } else {
#if DEBUG
            Text("AppDependency is not set.")
                .foregroundColor(.white)
                .font(.system(.title3, weight: .black))
                .frame(maxWidth: .infinity, maxHeight: .infinity)
                .background(Color.red)
                .ignoresSafeArea()
#else
            EmptyView()
#endif
        }
    }
}

使い方

たったこれだけのシンプルな実装ですが、これでとても簡単に依存が取り出せるようになります。
先ほど紹介したItemListViewを例に使い方を説明します。

まずはItemListlViewをInitializer Injectionで実装します。
ここのinitではItemListViewの画面の中で使う依存だけを宣言します。

struct ItemListView: View {
    init(viewModel: ItemListViewModel) {
        // ...
    }
}

initに渡す必要があるのはその画面の中で必要な依存だけなので、Xcode Previewをするのも簡単です。

struct ItemListView_Previews: PreviewProvider {
    static var previews: some View {
        let viewModel = ItemListViewModel(apiClient: MockAPIClient())
        ItemListView(viewModel: viewModel)
    }
}

余計な依存を渡したりする必要はなく、必要十分な依存をコンパイラの指示通りに差し込むだけです。

そして、ItemListViewの中からItemDetailViewに遷移するには、先ほど作ったDependencyProviderを挟むだけで依存の解決ができます。

struct ItemListView: View {
    var body: some View {
        // ...
	.navigationDesitination(Item.self) { item in
	    DependencyProvider { dependency in
	        ItemDetailtView(
		    item: item, 
		    database: item.databsase
		)
	    }
	}
    }
}

DependencyProviderのtrailing closureの引数に渡されるdependencyの型はAppDependency?ではなくAppDependencyであることがポイントです。
呼び出し側では、AppDependencyEnvironment経由でDIされているかどうかを気にしなくていいです。

この時のItemListViewのXcode Previewの動作はこのようになります。

関心の対象であったItemListViewではモックした値を使って、記事の一覧を表示できています。
一方で、セルをタップした時にはItemDetailViewではなくて、赤い画面に遷移しています。
これは、Xcode PreviewではAppDependencyをEnvironmentに登録していないからです。
ただ、ItemDetailViewについては、ItemListViewのXcode Preview時の関心の対象ではないので、この動作で開発には一切問題ありません。
ItemDetailViewの動作確認をしたければ、ItemListViewと同様にItemDetailView用のXcode Previewを用意すれば良いだけです。

DependencyProviderの何が嬉しいのか

1. どのViewからでも依存が取り出せる

SwiftUIのViewのどこからでも、依存が取り出せます。
しかもnon-optionalな値として取得できるので、呼び出し側で依存がない場合を考えなくて良いのも嬉しいです。

2. 各Viewは必要な依存だけをinitで宣言できる

遷移先の画面のために余計な依存を宣言する必要がなく、コンパイラはそのViewを表示するための必要十分な依存だけを知ることができます。
渡す依存が多すぎても少なすぎてもコンパイルエラーになります。

3. Environmentの設定忘れに気づきやすい

AppDependencyの設定はアプリのRootViewで差し込む一回だけで良いので、一回設定すればよく、それ以降はコンパイラの指示に従って依存を渡すだけで良いです。
また、仮に設定し忘れた場合、アプリのいたるところが真っ赤になるので、簡単に気づくことができます。

4. DIの方法がView層で閉じている

SwiftUIのViewのEnvironmentを用いたDI手法なので、プレゼンテーション層やビジネスロジック層ではシンプルかつ安全なInitializer Injectionを維持することができます。
なので、単体テストをする時にはDependencyProviderに依存する必要がないのも嬉しいです。

発表の補足

ここからは時間の関係で発表に入れられなかった内容です。

UIKitでの実現方法

UIKitでならCoordinatorパターンやVIPERのRouterが使えます。
ただ、SwiftUIほど苦しくはないにしろ、各CoordinatorやRouterがAppDependencyをバケツリケーする必要があります。

iOS17からはUITraitCollectionに強力なアップデートがありました。
SwiftUIのEnvironemntと同様のことをUIKitのUITraitCollectionで実現するものです。
発表の時にその詳細な実装を紹介できなかったので、ここで紹介します。

まずはUITraitCollectionにカスタムキーを追加します。

enum DependencyKey: EnvironmentKey, UITraitBridgedEnvironmentKey {
    static let defaultValue: Dependency? = nil

    static func read(from traitCollection: UITraitCollection) -> Dependency? {
        traitCollection.dependency
    }

    static func write(to mutableTraits: inout UIMutableTraits, value: Dependency?) {
        mutableTraits.dependency = value
    }
}

extension EnvironmentValues {
    public var dependency: Dependency? {
        get { self[DependencyKey.self] }
        set { self[DependencyKey.self] = newValue }
    }
}

private struct DependencyTrait: UITraitDefinition {
    static let defaultValue: Dependency? = nil
}

extension UITraitCollection {
    public var dependency: Dependency? { self[DependencyTrait.self] }
}

extension UIMutableTraits {
    public var dependency: Dependency? {
        get { self[DependencyTrait.self] }
        set { self[DependencyTrait.self] = newValue }
    }
}

ポイントは1行目のUITraitBridgedEnvironmentKeyに準拠している点です。(それ以外はほぼgetter/setterの実装です)
これに準拠することで、SwiftUIのEnvironemntとUIKitのUITraitCollectionで相互に値のやりとりができます。

そして、UIKit用のUIDependencyProviderをこのように実装します。

public enum UIDependencyProvider {
    public static func makeViewController(for view: UIView, factory: (AppDependency) -> some UIViewController) -> UIViewController {
        if let dependency = view.traitCollection.dependency {
            factory(dependency)
        } else {
            UIHostingController(rootView: Text("Dependency is not set.")
                .foregroundColor(.white)
                .font(.system(.title3, weight: .black))
                .frame(maxWidth: .infinity, maxHeight: .infinity)
                .background(Color.red)
                .ignoresSafeArea()
            )
        }
    }
}

使い方としてはこんな感じになるかと思います。

class FooViewController: UIViewController {
    func buttonDidTap() {
        let nextViewController = UIDependencyProvider.makeViewController(for: view) { dependency in 
	    BarViewController(database: dependency.database)
	}
	self.present(nextViewController, animated: true)
    }
}

パフォーマンスの問題

実はUITraitCollectionを用いた方法を紹介しているWWDCの動画で「traitに登録するのは値型、理想はInt, Bool, DoubleもしくはIntをrawValueに持つenumにしてください」と言及があります。
どうやら、差分更新のシステムとして、頻繁に値の比較がされるみたいです。
「カスタムなデータを用いる場合はEquatableに準拠して、同値判定のパフォーマンスを限りなく高速にください」とも言及があります。

AppDependency自体はstructですが、内部ではたくさんのclassを持つことになります。
そのため、AppDependencyは上の言及に違反していることになります。
回避策として考えられるのが、内部で何かしらの識別子のようなものを用いて、それをEquatableの実装に使う方法です。

struct AppDependency: Equatable { 
    private let id = UUID()
    let database: any Database
    // ....
    
    static func == (lhs: Self, rhs: Self) -> Bool {
        lhs.id == rhs.id
    }
}

上のコードは内部でUUIDを発行し、その値だけを用いて同値判定をしています。
また、AppDepedencyの内部の依存を全てvarではなくletにしています。
こうすることでAppDependencyの依存を更新する際には、AppDependnecyのinitを呼ばざるを得ない状況にし、依存が更新されるとUUIDも更新される仕組みを実現しています。

この方法でパフォーマンスは改善できますが、それでもまだAppleが提案しているベストプラクティス(生のIntBoolのみを用いる)は実現できていません。
このあたり実際のアプリのパフォーマンスをモニタリングし、状況に合わせてチューニングが必要かもしれません。

おわりに

Xcode Previewを使用するためのハードルをできるだけ下げるDI手法を紹介しました。
Xcode Previewを行う際にコンパイラが必要十分な依存を知れるので、安全さと手軽さをできるだけ両立した手法だと思っています。

ただ、この手法はまだ個人開発で運用しているだけで、まだまだ未知の課題もあると思います。
もし使ってみて、何か課題があった際は教えていただけると幸いです。

この記事がみなさんの開発の助けになれば幸いです。

Discussion