LoginSignup
6
2

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

Last updated at Posted at 2024-03-28

はじめに

isowords式マルチモジュール構成が流行っていると思いますが、XcodeGenを導入しているプロジェクトで、XcodeGenを使ったままSPMによるマルチモジュール構成を作ることはできるのかな?と思って検証してみました。

単一のパッケージでやる方法も書きました

サンプルコード

モジュール構成

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

image.png

パッケージの作成

XcodeGenは導入済みの状態を想定します。

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

Libraryを選択します。

image.png

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

Save.png

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

image.png

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

XcodeGenの設定ファイルでローカルパッケージを指定する

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

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

packages:
  SharedModel:
    path: SharedModel
  AppFeature:
    path: AppFeature
  LoginFeature:
    path: LoginFeature
  HomeFeature:
    path: HomeFeature

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

image.png

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

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

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

image.png

依存関係を設定する

各パッケージの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"]
        ),
    ]
)

LoginFeatureHomeFeatureSharedModelにのみ依存させます。
※以下はLoginFeaturePakcage.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が追加されていることがわかります。

XcodeGenWithLocalPackage_—_XcodeGenWithLocalPackage_xcodeproj.png

パッケージのユニットテストを実行できるようにする

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

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

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

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

image.png

ローカルパッケージにリモートパッケージへの依存を追加する

ローカルパッケージでライブラリを使いたいケースはもちろんありますね。

その場合は、パッケージの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欄にリモートパッケージが表示されました。

image.png

おわりに

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

2024/04/03追記 単一のパッケージで同じことを実現する

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

パッケージの作成

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

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

image.png

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

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

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では以下のように見えます。

image.png

2024/04/08追記 AppPackageをルートに配置する

XcodeGen 2.40.0group""を指定できるようになりました。

packages:
  AppPackage:
    path: AppPackage
    group: ""

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

image.png

6
2
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
6
2