機能のモジュール化
機能単位でモジュール分割するにあたってどのように依存解決したら良いかを考えてみた。 以下のリポジトリであれこれしながら考えた。
機能モジュールとは
- 機能単位でモジュールを作成していくこと
- 上記の図のように Feature A 画面と Feature B 画面がある場合はそれぞれをモジュールにする
- 複数の画面をまとめて単一のモジュールにしてもよい
- Swift Package Manager を前提としている
- ビルドがめちゃくちゃ高速化される
- 機能単位でビルドできる
- Xcode Preview が利用できるようになる
- アプリを Run しなくてもレイアウトを確認できるようになる
- 責務を自然と意識することになる
対応の方針
モジュールの循環依存
- 機能モジュールは循環依存の関係が生じやすい
- Feature A.画面から Feature B 画面に遷移できる
- Feature B 画面から Feature A 画面に遷移できる
- 循環依存が生じているとビルドできない
// A Feature モジュール import BFeature public struct AView: View { func transition() -> { let bView = BView() let host = UIHostingController(rootView: bView) navigationController?.push(host) } } // B Feature モジュール import AFeature public struct BView: View { func transition() -> { let aView = AView() let host = UIHostingController(rootView: aView) navigationController?.push(host) } }
インターフェースモジュールによる依存解決
- 循環依存を防ぐためにインターフェースを分離してモジュール(Environment)とする
- 機能モジュールはインターフェースを介して実体を取得する
- App Target ではインターフェースの実体を提供する
- 各 Feature に DI する
- Environment には View のインスタンスを提供するインターフェース(View Buildable)を持つ
- App では各 View Buildable に準拠した Builder を持つ
- 各 Feature の View の初期化時に Builder を DI する
- 各 Feature の View は Builder を介して他の Feature の View を取得する
- Builder は Protocol Composition パターンで DI を実現する
- 各 Feature で必要なインターフェースの実装漏れを静的に検知できる
- 各 Feature で必要なインターフェースのみを公開できる
// Environment モジュール public protocol AViewBuildable { func buildAView(id: Int) -> AnyView } public protocol BViewBuildable { func buildBView() -> UIViewController } // App import AFeature import BFeature struct AppViewBuilder {} extension AppViewBuilder: AViewBuildable { func buildAView(id: Int) -> AnyView { let viewModel = AViewModel(id: id) return AnyView(AView(builder: self, viewModel: viewModel)) } } extension AppViewBuilder: BViewBuildable { func buildBView() -> UIViewController { let repository = BRepository() let viewModel = BViewModel(repository: repository) return BViewController(builder: self, viewModel: viewModel) } } // A Feature モジュール import Environment public struct AView: View { // 必要な View のインターフェースのみにアクセスを限定できる // DI された builder が指定したインターフェースを提供しない場合にはコンパイルエラーで検知できる typealias ViewBuildable = BViewBuildable & ... private let builder: ViewBuildable public init(builder: ViewBuildable, viewModel: View) { self.builder = builder self.viewModel = viewModel } func transition() -> { let bView = builder.buildBView() let host = UIHostingController(rootView: bView) navigationController?.push(host) }
Xcode Preview における実体の差し替え
Xcode Preview 用の Builder
// Environment モジュール public protocol XcodePreviewBuildable: AViewBuildable & BViewBuildable // 他のモジュールから初期化できないように internal に struct XcodePreviewViewBuilder: XcodePreviewBuildable { extension XcodePreviewBuilder { func buildAView(id: Int) -> AnyView { AnyView(Color.green) } func buildBView() -> UIViewController { .init() } } // PreviewProvider 内でのみ PreviewBuilder を参照できるように public extension PreviewProvider { static var previewBuilder: some XcodePreviewBuildable { XcodePreviewBuilder() } } // A Feature モジュール import Environment struct AView_Previews: PreviewProvider { static var previews: some View { AView( builder: previewBuilder, // Xcode Preview 用の Builder を DI viewModel: AViewModel(id: 1) ) } }
💡 Tips
Generics View の body で @ViewBuilder
をもつ View を使うと Xcode Preview がクラッシュするので @ViewBuilder
をもつ View を作りたいときは @ViewBuilder
なメソッドを用いる。(Xcode 13.3.1 で確認)
// ⛔️ struct FeatureView<T>: View { var body: some View { SubView { Text("クラッシュする") } } struct SubView<T: View>: View { @ViewBuilder let content: T var body: some View { content } } }
// ✅ struct FeatureView<T>: View { var body: some View { subView { Text("クラッシュしない") } } func subView<T: View>(@ViewBuilder content: () -> T) -> some View { content() } }
ViewModel の DI をしていく
ViewModel の DI ができないと困る
- 任意の状態を再現するために ViewModel のロジックを把握して操作する必要がある
- 全ての依存性の Mock を準備する必要がある
- View の実装時はレイアウトのみに集中したい
final class AViewModel: ObservableObject { @Published private(set) var items: [Item] = [] init( id: Int, repository1: Repository1Protocol, repository2, Repository2Protocol ) { self.id = id self.repository1 = repository1 self.repository2 = repository2 } func buttonDidTap() { ... // 任意の items の状態で Preview したいときに repository を操作する必要がある // View のレイアウト実装時に ViewModel のロジックを意識しないといけない items = repository.getItems() ... } } struct AView_Previews: PreviewProvider { static var previews: some View { // ViewModel の依存性の全てを準備する必要がある let repository1 = Repository1Mock() let repository2 = Repository2Mock() let viewModel = AViewModel( id: 1, repository1: repository2, repository2: repository2 ) AView( builder: previewBuilder, viewModel: viewModel ) } }
DI できるようにするために
- Observable Object を継承する Protocol に準拠させる
- @Published プロパティから
viewModel.$items
のような Publisherの生成ができなくなる- Protocol で Publisher のインターフェースを定義すれば良い
- そもそも Publisher が必要なケースはなさそう
onReceive(_:perform:)
が使いたい場合はonChange(of:perform:)
を使えるはずで Publisher がそもそも不要になる
- Binding は
$viewModel.items
ので作れる
protocol AViewModelProtocol: ObservableObject { var items: [Item] { get } func buttonDidTap() } final class AViewModel: AViewModelProtocol { @Published private(set) var items: [Item] = [] init( id: Int, repository1: Repository1Protocol, repository2, Repository2Protocol ) { self.id = id self.repository1 = repository1 self.repository2 = repository2 } func buttonDidTap() { ... items = repository.getItems() ... } } struct AView_Previews: PreviewProvider { class ViewModel: AViewModelProtocol { let items = Array(repeating: Items(), count: 10) func buttonDidTap() {} } static var previews: some View { AView( builder: previewBuilder, viewModel: ViewModel() ) } }
機能モジュール作成手順 📝
Swift Package の作成
- メニューから Swift Package を作る
- File > New > Package
- Add to は App
- Group は Package
- 不要なディレクトリ・ファイルを削除する(場合によって)
- .gitignore
- README.md
- Package.swift を記述する
// swift-tools-version: 5.6 import PackageDescription let package = Package( name: "Logger", platforms: [.iOS(.v14)], products: [ // ライブラリの定義。これを定義すると App Target や他の Swift Package から参照できる。 .library(name: "Logger", targets: ["Logger"]) ], dependencies: [ // 依存するライブラリの定義。 .package(name: "Entity", path: "../Entity"), .package(name: "Extensions", path: "../Extensions") ], targets: [ // ライブラリに含めるターゲット。 .target( name: "Logger", dependencies: ["Entity", "Extensions"], path: "./Sources" ) ] )
実装
- View, ViewModel, Repository などを機能モジュールで実装する
- Buildable(機能のインターフェース)を Environment に定義する
- Builder で準拠する
課題・理想
- 各種 Assets の扱いについて
- Assets 用のモジュールを作る or 各モジュールに Assets を持たせてしまうとよさそう
- 複数の Package で使う外部依存性が重複する
- 単一の Swift Package にする
- モジュールのテストについて
- CI では差分のあるモジュールを検知して該当モジュールのテストだけ実行するとよさそう
- Xcode Preview Builder は自動生成したい
リソースファイルの扱い方についてメモ
- Apple Developer Documentation
- Swift5.3 だとバグがある?
- https://newbedev.com/use-resources-in-unit-tests-with-swift-package-manager
- https://forums.swift.org/t/5-3-resources-support-not-working-on-with-swift-test/40381/9
- https://forums.swift.org/t/swift-5-3-spm-resources-in-tests-uses-wrong-bundle-path/37051/21
- https://github.com/Nef10/SPMResourcesInTest
- What is Bundle.module?