はじめに
isowords式マルチモジュール構成が流行っていると思いますが、XcodeGenを導入しているプロジェクトで、XcodeGenを使ったままSPMによるマルチモジュール構成を作ることはできるのかな?と思って検証してみました。
※単一のパッケージでやる方法も書きました
サンプルコード
モジュール構成
このようなモジュール構成のプロジェクトを作っていきます。
パッケージの作成
XcodeGenは導入済みの状態を想定します。
Xcode上でFile > New > Package...
をクリックします。
Library
を選択します。
モジュール名はSharedModel
にします。
また、モジュールのプロジェクトへの追加はXcodeGenにて行うため、「Add to」には何も指定しないようにします。
これで空のパッケージが作成されます。
同じ要領で、AppFeature
、LoginFeature
、HomeFeature
モジュールを作成しておきます。
XcodeGenの設定ファイルでローカルパッケージを指定する
XcodeGenの設定ファイル(以降はproject.yml
とします)では、Github等のリモート環境にあるパッケージだけでなく、ローカル環境のパッケージも参照することができます。
project.yml
のpackages
に以下のように記述することで、XcodeGenに対して先ほど作成したパッケージをローカルパッケージとして認識させることができます。
※project.yml
と各パッケージのフォルダは同じ階層にあるものとします
packages:
SharedModel:
path: SharedModel
AppFeature:
path: AppFeature
LoginFeature:
path: LoginFeature
HomeFeature:
path: HomeFeature
プロジェクトファイルをビルドして開いてみると、Project Navigatorでは以下のように表示されます。
デフォルトではPackages
というグループにパッケージが追加されるようです。
役割ごとにグループを分けることも可能です。
以下のように修正してみます。
packages:
SharedModel:
path: SharedModel
group: Core # <- 追加
AppFeature:
path: AppFeature
group: Feature # <- 追加
LoginFeature:
path: LoginFeature
group: Feature # <- 追加
HomeFeature:
path: HomeFeature
group: Feature # <- 追加
役割ごとにフォルダが分かれ、見やすくなりました。
依存関係を設定する
各パッケージのPackage.swift
を修正し、依存関係を設定します。
例えばAppFeature
パッケージのPackage.swift
は以下のように実装します。
dependencies
に依存先のパッケージを指定しています。
import PackageDescription
let package = Package(
name: "AppFeature",
platforms: [.iOS(.v17)],
products: [
.library(
name: "AppFeature",
targets: ["AppFeature"]
),
],
dependencies: [
// 3つのパッケージに依存する
.package(path: "SharedModel"),
.package(path: "LoginFeature"),
.package(path: "HomeFeature"),
],
targets: [
.target(
name: "AppFeature",
// 3つのパッケージに依存する
dependencies: [
"SharedModel",
"LoginFeature",
"HomeFeature",
]
),
.testTarget(
name: "AppFeatureTests",
dependencies: ["AppFeature"]
),
]
)
LoginFeature
とHomeFeature
はSharedModel
にのみ依存させます。
※以下はLoginFeature
のPakcage.swift
です
import PackageDescription
let package = Package(
name: "LoginFeature",
platforms: [.iOS(.v17)],
products: [
.library(
name: "LoginFeature",
targets: ["LoginFeature"]
),
],
dependencies: [
.package(path: "SharedModel"),
],
targets: [
.target(
name: "LoginFeature",
dependencies: [
"SharedModel"
]
),
.testTarget(
name: "LoginFeatureTests",
dependencies: ["LoginFeature"]
),
]
)
パッケージ間の依存関係はこれで設定完了です。
最後に、メインターゲットからルートパッケージであるAppFeature
に依存する指定をproject.yml
に記述します。
targets:
XcodeGenWithLocalPackage:
type: application
platform: iOS
dependencies:
- package: AppFeature # <- 追加
# ...
プロジェクトファイルをビルドして開くと、メインターゲットの依存先としてAppFeature
が追加されていることがわかります。
パッケージのユニットテストを実行できるようにする
メインターゲットのスキームでユニットテストを実行したら、各パッケージのユニットテストも実行されるようになっていると一括ですべてのテストが実行できるので便利です。
project.yml
のshemes
で以下のように記述すると、ローカルパッケージのユニットテストを実行対象にすることができます。
schemes:
XcodeGenWithLocalPackage:
build:
targets:
XcodeGenWithLocalPackage: all
test:
targets:
- package: AppFeature/AppFeatureTests # <- パッケージのユニットテストを指定できる
- package: LoginFeature/LoginFeatureTests
- package: HomeFeature/HomeFeatureTests
- package: SharedModel/SharedModelTests
スキームの設定を見るとパッケージのユニットテストが対象として追加されていることがわかります。
ローカルパッケージにリモートパッケージへの依存を追加する
ローカルパッケージでライブラリを使いたいケースはもちろんありますね。
その場合は、パッケージのPackage.swift
に依存を追加します。
例えば、各FeautureモジュールにTCAを追加してみましょう。
import PackageDescription
let package = Package(
name: "AppFeature",
platforms: [.iOS(.v17)],
products: [
.library(
name: "AppFeature",
targets: ["AppFeature"]
),
],
dependencies: [
.package(path: "SharedModel"),
.package(path: "LoginFeature"),
.package(path: "HomeFeature"),
// ↓追加
.package(url: "https://github.com/pointfreeco/swift-composable-architecture", from: "1.9.2"),
],
targets: [
.target(
name: "AppFeature",
dependencies: [
"SharedModel",
"LoginFeature",
"HomeFeature",
// ↓追加
.product(name: "ComposableArchitecture", package: "swift-composable-architecture")
]
),
.testTarget(
name: "AppFeatureTests",
dependencies: ["AppFeature"]
),
]
)
Project NavigatorのPackage Dependencies欄にリモートパッケージが表示されました。
おわりに
isowords式マルチモジュール構成はXcodeGenが解決しようとした課題を3rdパーティライブラリに頼らずに実現できるので、理想的なのだと思います。
いずれはそうしたい気持ちがありますが、CIなどを含めXcodeGenを前提として開発環境を構築しているので、XcodeGenを廃止するのもそれなりにコストがかかります。
今回検証した方法であれば、開発環境を大きく変えずにマルチモジュール化を進めることができそうです。
2024/04/03追記 単一のパッケージで同じことを実現する
前述の方法はパッケージとモジュールが 1:1 でしたが、パッケージは一つにしてその中に複数のモジュールを定義するというやり方ができることに気づきました。
こちらの方がシンプルで管理がしやすく、isowords式にも近しいやり方に見えます。
パッケージの作成
上述のやり方でAppPackage
という名前で空のパッケージを作成します。
単一のパッケージに複数のモジュールを実装するので、フォルダ構成は以下のようになります。
Package.swift
targets
にすべてのモジュールを定義します。
products
では公開するモジュールを定義します。今回のサンプルではAppFeature
だけ公開すればOKです。
import PackageDescription
let package = Package(
name: "AppPackage",
platforms: [.iOS(.v17)],
products: [
.library(name: "AppFeature", targets: ["AppFeature"])
],
dependencies: [
.package(url: "https://github.com/pointfreeco/swift-composable-architecture", from: "1.9.2"),
],
targets: [
.target(
name: "AppFeature",
dependencies: [
"SharedModel",
"LoginFeature",
"HomeFeature",
.product(name: "ComposableArchitecture", package: "swift-composable-architecture")
]
),
.testTarget(
name: "AppFeatureTests",
dependencies: ["AppFeature"]
),
.target(
name: "HomeFeature",
dependencies: ["SharedModel"]
),
.testTarget(
name: "HomeFeatureTests",
dependencies: ["HomeFeature"]
),
.target(
name: "LoginFeature",
dependencies: ["SharedModel"]
),
.testTarget(
name: "LoginFeatureTests",
dependencies: ["LoginFeature"]
),
.target(
name: "SharedModel"
),
]
)
project.yml
パッケージは一つだけなので、project.yml
で読み込む対象も一つだけになります。
packages:
AppPackage:
path: AppPackage
メインターゲットの依存の書き方が上述のやり方とは若干変わります。
パッケージ名とモジュール名が別なので、product
にAppFeature
を指定する必要があります。
targets:
XcodeGenWithLocalPackage:
type: application
platform: iOS
dependencies:
- package: AppPackage
product: AppFeature # <- モジュールを指定
テストターゲットの指定も若干変わります。
各ターゲットはAppPackage
配下にあるので、以下のように指定します。
schemes:
XcodeGenWithLocalPackage:
build:
targets:
XcodeGenWithLocalPackage: all
test:
targets:
- package: AppPackage/AppFeatureTests # <- AppPackage/XxxTests という指定になる
- package: AppPackage/HomeFeatureTests
- package: AppPackage/LoginFeatureTests
Xcodeでの見え方
Project Navigatorでは以下のように見えます。
2024/04/08追記 AppPackageをルートに配置する
XcodeGen 2.40.0でgroup
に""
を指定できるようになりました。
packages:
AppPackage:
path: AppPackage
group: ""
""
を指定すると、どのフォルダにも入らずルートにパッケージを配置できるようになります。
より見やすくなりましたね。