「Xcode Previewを気軽に使うためのDI戦略」要約&補足
はじめに
この記事はiOSDC Japan 2023での「Xcode Previewを気軽に使うためのDI戦略」の要約と補足です。
スライドも公開しているので、そちらも併せてご確認ください。
発表を見ていない方のために要約を書いていますが内容自体は発表のものと同じかつ長いので、発表を見た方は要約をスキップして、いきなり補足から見ていただいて大丈夫です。
発表の要約
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を定義する -
APIClientProtocol
をItemListViewModel
のinit
で差し込む -
ItemListViewModel
をItemListView
のinit
で差し込む
Initializer Injectionはコンパイル時に依存が差し込まれていることを保証することができるので、シンプルながら一番安全なDI手法といえます。
Initializer Injectionの課題
Initializer Injectionは安全な方法ですが、一方で課題もあります。
「依存のバケツリレー」が必要ということです。
例えばさっき紹介したItemListView
からItemDetailView
に遷移するとします。
そして、このItemDetailView
ではDatabase
が必要だったとします。
ということで、先ほどと同様、このDatabase
をInitializer Injectionします。
struct ItemDetailView: View {
init(item: Item, databsse: any Database) {
// ...
}
}
ところで、このItemDetailView
はItemListView
から遷移するので、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
だけでなく、例えば、userDefaults
、keychain
、fileManager
などを受け取る必要が発生します。
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
であることがポイントです。
呼び出し側では、AppDependency
がEnvironment
経由で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が提案しているベストプラクティス(生のInt
やBool
のみを用いる)は実現できていません。
このあたり実際のアプリのパフォーマンスをモニタリングし、状況に合わせてチューニングが必要かもしれません。
おわりに
Xcode Previewを使用するためのハードルをできるだけ下げるDI手法を紹介しました。
Xcode Previewを行う際にコンパイラが必要十分な依存を知れるので、安全さと手軽さをできるだけ両立した手法だと思っています。
ただ、この手法はまだ個人開発で運用しているだけで、まだまだ未知の課題もあると思います。
もし使ってみて、何か課題があった際は教えていただけると幸いです。
この記事がみなさんの開発の助けになれば幸いです。
Discussion