XcodeGen + SPM (Local Swift Package) によるマルチモジュール構成

# はじめに
[isowords](https://github.com/pointfreeco/isowords)式マルチモジュール構成が流行っていると思いますが、XcodeGenを導入しているプロジェクトで、XcodeGenを使ったままSPMによるマルチモジュール構成を作ることはできるのかな？と思って検証してみました。

※[単一のパッケージでやる方法](#20240403追記-単一のパッケージで同じことを実現する)も書きました

# サンプルコード
https://github.com/takehilo/MyiOSPlayground/tree/2703b4020038c583c9568aa7a4dd400416f798bb/XcodeGenWithLocalPackage

# モジュール構成
このようなモジュール構成のプロジェクトを作っていきます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/81715/28cc8503-5210-5d38-16bf-31af6fa7ef6b.png)

# パッケージの作成
XcodeGenは導入済みの状態を想定します。

Xcode上で`File > New > Package...`をクリックします。

`Library`を選択します。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/81715/c98b9dab-e414-0591-781a-0f94fb71c948.png)

モジュール名は`SharedModel`にします。
また、モジュールのプロジェクトへの追加はXcodeGenにて行うため、「Add to」には何も指定しないようにします。

![Save.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/81715/9f54fcd7-da41-8dba-131a-ad4e1a926b00.png)

これで空のパッケージが作成されます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/81715/a5df52cf-7187-4253-fc5e-0eaaea3f1f59.png)

同じ要領で、`AppFeature`、`LoginFeature`、`HomeFeature`モジュールを作成しておきます。

# XcodeGenの設定ファイルでローカルパッケージを指定する
XcodeGenの設定ファイル（以降は`project.yml`とします）では、Github等のリモート環境にあるパッケージだけでなく、ローカル環境のパッケージも参照することができます。

`project.yml`の`packages`に以下のように記述することで、XcodeGenに対して先ほど作成したパッケージをローカルパッケージとして認識させることができます。
※`project.yml`と各パッケージのフォルダは同じ階層にあるものとします

```yaml
packages:
  SharedModel:
    path: SharedModel
  AppFeature:
    path: AppFeature
  LoginFeature:
    path: LoginFeature
  HomeFeature:
    path: HomeFeature
```

プロジェクトファイルをビルドして開いてみると、Project Navigatorでは以下のように表示されます。
デフォルトでは`Packages`というグループにパッケージが追加されるようです。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/81715/a3c7d5ac-6daa-9156-9b2e-c315f6759140.png)

役割ごとにグループを分けることも可能です。
以下のように修正してみます。

```yaml
packages:
  SharedModel:
    path: SharedModel
    group: Core  # <- 追加
  AppFeature:
    path: AppFeature
    group: Feature  # <- 追加
  LoginFeature:
    path: LoginFeature
    group: Feature  # <- 追加
  HomeFeature:
    path: HomeFeature
    group: Feature  # <- 追加
```

役割ごとにフォルダが分かれ、見やすくなりました。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/81715/188f0547-a84b-6504-2935-14e8cccb6bb0.png)

# 依存関係を設定する
各パッケージの`Package.swift`を修正し、依存関係を設定します。

例えば`AppFeature`パッケージの`Package.swift`は以下のように実装します。
`dependencies`に依存先のパッケージを指定しています。

```swift
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`です

```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`に記述します。

```yaml
targets:
  XcodeGenWithLocalPackage:
    type: application
    platform: iOS
    dependencies:
      - package: AppFeature  # <- 追加
　　　　　　　# ...
```

プロジェクトファイルをビルドして開くと、メインターゲットの依存先として`AppFeature`が追加されていることがわかります。

![XcodeGenWithLocalPackage_—_XcodeGenWithLocalPackage_xcodeproj.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/81715/f233b361-eece-9fc1-918f-2e09dfee389a.png)

# パッケージのユニットテストを実行できるようにする
メインターゲットのスキームでユニットテストを実行したら、各パッケージのユニットテストも実行されるようになっていると一括ですべてのテストが実行できるので便利です。

`project.yml`の`shemes`で以下のように記述すると、ローカルパッケージのユニットテストを実行対象にすることができます。

```yaml
schemes:
  XcodeGenWithLocalPackage:
    build:
      targets:
        XcodeGenWithLocalPackage: all
    test:
      targets:
        - package: AppFeature/AppFeatureTests  # <- パッケージのユニットテストを指定できる
        - package: LoginFeature/LoginFeatureTests
        - package: HomeFeature/HomeFeatureTests
        - package: SharedModel/SharedModelTests
```

スキームの設定を見るとパッケージのユニットテストが対象として追加されていることがわかります。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/81715/9f76b099-f33b-8bb1-f989-2133ca67c8bd.png)

# ローカルパッケージにリモートパッケージへの依存を追加する
ローカルパッケージでライブラリを使いたいケースはもちろんありますね。

その場合は、パッケージの`Package.swift`に依存を追加します。
例えば、各FeautureモジュールにTCAを追加してみましょう。

```swift
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欄にリモートパッケージが表示されました。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/81715/a2091a11-8688-aabf-2f3f-982c42a37f9c.png)

# おわりに
isowords式マルチモジュール構成はXcodeGenが解決しようとした課題を3rdパーティライブラリに頼らずに実現できるので、理想的なのだと思います。
いずれはそうしたい気持ちがありますが、CIなどを含めXcodeGenを前提として開発環境を構築しているので、XcodeGenを廃止するのもそれなりにコストがかかります。
今回検証した方法であれば、開発環境を大きく変えずにマルチモジュール化を進めることができそうです。

# 2024/04/03追記 単一のパッケージで同じことを実現する
前述の方法はパッケージとモジュールが 1:1 でしたが、パッケージは一つにしてその中に複数のモジュールを定義するというやり方ができることに気づきました。
こちらの方がシンプルで管理がしやすく、isowords式にも近しいやり方に見えます。

https://github.com/takehilo/MyiOSPlayground/tree/d28c28638391428f4aa2cbd2606433b9d9aae723/XcodeGenWithLocalPackage

## パッケージの作成
上述のやり方で`AppPackage`という名前で空のパッケージを作成します。

単一のパッケージに複数のモジュールを実装するので、フォルダ構成は以下のようになります。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/81715/ddbec536-3bb4-beba-bea5-979412e63741.png)

## Package.swift
`targets`にすべてのモジュールを定義します。
`products`では公開するモジュールを定義します。今回のサンプルでは`AppFeature`だけ公開すればOKです。

```swift
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`で読み込む対象も一つだけになります。

```yaml
packages:
  AppPackage:
    path: AppPackage
```

メインターゲットの依存の書き方が上述のやり方とは若干変わります。
パッケージ名とモジュール名が別なので、`product`に`AppFeature`を指定する必要があります。

```yaml
targets:
  XcodeGenWithLocalPackage:
    type: application
    platform: iOS
    dependencies:
      - package: AppPackage
        product: AppFeature  # <- モジュールを指定
```

テストターゲットの指定も若干変わります。
各ターゲットは`AppPackage`配下にあるので、以下のように指定します。

```yaml
schemes:
  XcodeGenWithLocalPackage:
    build:
      targets:
        XcodeGenWithLocalPackage: all
    test:
      targets:
        - package: AppPackage/AppFeatureTests    # <- AppPackage/XxxTests という指定になる
        - package: AppPackage/HomeFeatureTests
        - package: AppPackage/LoginFeatureTests
```

## Xcodeでの見え方
Project Navigatorでは以下のように見えます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/81715/807e5160-02a0-d5a1-db90-3d6a736a4fbf.png)

### 2024/04/08追記 AppPackageをルートに配置する
XcodeGen [2.40.0](https://github.com/yonaskolb/XcodeGen/releases/tag/2.40.0)で`group`に`""`を指定できるようになりました。

```yaml
packages:
  AppPackage:
    path: AppPackage
    group: ""
```

`""`を指定すると、どのフォルダにも入らずルートにパッケージを配置できるようになります。
より見やすくなりましたね。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/81715/9c7aad2a-1366-d758-f04f-723591dfd9a0.png)