機能のモジュール化

機能単位でモジュール分割するにあたってどのように依存解決したら良いかを考えてみた。 以下のリポジトリであれこれしながら考えた。

github.com

機能モジュールとは

  • 機能単位でモジュールを作成していくこと
    • 上記の図のように Feature A 画面と Feature B 画面がある場合はそれぞれをモジュールにする
    • 複数の画面をまとめて単一のモジュールにしてもよい
    • Swift Package Manager を前提としている
  • ビルドがめちゃくちゃ高速化される
    • 機能単位でビルドできる
  • Xcode Preview が利用できるようになる
    • アプリを Run しなくてもレイアウトを確認できるようになる
  • 責務を自然と意識することになる

対応の方針

  • 開発速度が大きく改善されるので他のデメリットはある程度許容したい
  • モジュール数に制限は設けない
  • Xcode Preview を使えるようにする
  • 実装漏れは静的に検知できるようにする

モジュールの循環依存

  • 機能モジュールは循環依存の関係が生じやすい
    • Feature A.画面から Feature B 画面に遷移できる
    • Feature B 画面から Feature A 画面に遷移できる
  • 循環依存が生じているとビルドできない

Swift Package の依存関係
View の依存関係

// 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 する
Swift Package の依存関係
View の依存関係
  • 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

  • Xcode Preview においても Builder の実態を View に提供する必要がある
  • ダミーの View を初期化して返すだけの Builder を Environment で用意する
    • 全ての機能モジュールで Xcode Preview する際に必要になる
    • 画面遷移先は View の実装で意識すべきでないのでダミーの View を返すだけで良い
    • ダミーの View で良いので共通化できる

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 で準拠する
    • AppViewBuilder(App Target における Buildable の実態)
    • XcodePreviewBuilder(Xcode Preview における Buildable の実態)

課題・理想

  • 各種 Assets の扱いについて
  • 複数の Package で使う外部依存性が重複する
  • モジュールのテストについて
    • CI では差分のあるモジュールを検知して該当モジュールのテストだけ実行するとよさそう
  • Xcode Preview Builder は自動生成したい

リソースファイルの扱い方についてメモ

参考