📦

2022年 個人的おすすめ iOS プロジェクト構成

2022/09/09に公開

ここしばらく記事中のプロジェクト構成で個人的にアプリを作っていて何となく方向性が固まってきたので紹介します
内容的には目新しいものではなく昨今のマルチモジュール志向と、それを自分はどういった粒度でモジュールにしているかといったところが中心です

前提

  • SwiftUI で新しくアプリを開発する
    • (or 既存プロジェクトを SwiftUI 化しつつ徐々に移行する)
  • SwiftUI 標準の状態管理機能を利用したプレーンな作りで解説を進める
    • pointfreeco/isowords やそれに準ずる記事を色々参考にしているので、 TCA などのフレームワークを取り入れつつということも可能かと思います

はじめに

はじめに全体構成は以下のような図になります

ディレクトリ例

.
├── myapp
│   └── App.swift
├── Packages
│   └── myapp-lib
│       ├── Package.swift
│       └── Sources
│           ├── TopPage
│           ├── ...
│           └── Domain
└── project.yml

依存解決

本記事では依存解決を SwiftUI.Environment を利用して解決を行います
ここはフレームワークやチームによって千差万別かと思います

SwiftUI.Environment を利用する利点は例えばプレビュー用のカタログアプリにおいて、それぞれの状態確認用に Environment を上書きすることで、ある程度複雑な状態であっても任意の表示を再現しやすくなる点です

CatalogView.swift
List {
    ...
    NavigationLink {
        ItemTimelinePage()
            .environment(\.aClient, AClient(...))
    } label: {
        Text("タイムライン (読み込み中)")
    }

    NavigationLink {
        ItemTimelinePage()
            .environment(\.aClient, AClient(...))
    } label: {
        Text("タイムライン (2ページ目の取得に失敗する場合)")
    }
    ...
}

Domain

ここではアプリ内のデータモデルや先の Environment で解決される依存元の抽象インターフェースなどを用意します

Domain/AClient.swift
public struct AClient {
    struct Props {
        var fetchValue: (ID) async throws -> Value
    }

    var props: Props

    public init(fetchValue: @escaping (ID) async throws -> Value) {
        props = .init(fetchValue: fetchValue)
    }

    public func fetchValue(by id: ID) async throws -> Value {
        try await props.fetchValue(id)
    }
}

extension AClient {
    static var fatal: Self { ... }
}

extension EnvironmentValues {
    private struct Key: EnvironmentKey {
        static var defaultValue: AClient { .fatal }
    }

    public var aClient: AClient {
        get { self[Key.self] }
        set { self[Key.self] = newValue }
    }
}

Dependency

具象な実装は図中の FeatureDependency 内のモジュールと組み合わせて実装します
例えばこのような形になります

Dependency/AClientImpl.swift
import Domain
import API

extension AClient {
    public static func live(api: API) -> Self {
        return self.init(
            fetchValue: { id in
                do {
                    return try await api.fetchAValue(by: id)
                } catch {
                    throw DomainError.from(error)
                }
            }
        )
    }
}

App

アプリケーション本体は SwiftPM とは別管理で Xcode プロジェクトを手動で用意するか XcodeGen を用いて管理します
といってもここで扱う処理はエントリポイントと依存解決程度で具体的なロジックは置かないようにします
SwiftUI.Environment を利用した依存性注入は例えば以下の通りです

App.swift
import Domain
import Dependency

@main
struct App: Swift.App {
    var body: some Scene {
        RootView()
            .environment(\.aClient, /* AClient.live(api: API()) */)
            .environment(...)
    }
}

UI

UI レイヤーでは各画面単位の粒度でさらにモジュールを用意します
Environment 経由で依存を参照しつつ、直接 SwiftUI.State を操作したり、 ViewModel を活用するなど複雑度に応じて適宜実装方法は変わります

TopPageView.swift
public struct TopPageView: View {
    @Environment(\.aClient) var aClient
    @State private var state: TopPageState?

    public var body: some View { ... }
}

その他

  • ツール
    XcodeGen, SwiftGen などのツールは Swift Package Plugin と artifactbundle の生成・配布周りがこなれてきたら plugin で管理したいですが、もうしばらくは Mint を考えています

  • テスト
    テストコードはテストが必要なモジュールに合わせて適宜用意するイメージです

おわりに

今回はこのような構成でしたが、 Xcode Previews は強力でなるべくそれを活用できるようにしたいと思っているので、 Xcode のバージョンアップでまた構成も変わっていくと思います
※ 現状 App 側の Xcode プロジェクトだとプレビューが不安定なので、 Package.swift 側のプロジェクトと行き来しています 😓
 ・スキーマをプレビューのあるモジュールのものにしないとプレビューされない、後者だとその辺り気にせずにできている
 ・基本的に Package.swift 側で開発を進めて、たまにデバイスで動作確認したいときに App 側を開く運用になっています

また https://tech.nana-music.com/entry/2022/09/06/120709 を見かけて良いなと思ったので、この構成を基礎としてまた色々付け足して変更していきたいと思います

Discussion