SwiftPMによるマルチモジュール構成でSwiftGenをビルド時に実行する

はじめに

こんにちは、モバイルチームのオジマです。

私が担当しているサイボウズ Office 新着通知のiOSアプリでは、これまでXcodeGenとCocoaPodsを用いてマルチモジュールなアーキテクチャを構成していました。しかし、開発環境の構築においてRubyのバージョンなど気を使う点がいくつかあり、特にM1チップを搭載したMacで問題になることが多くありました。そのため、SwiftPMを用いたマルチモジュール構成への移行を行いました。

この記事では、SwiftPMによるマルチモジュール構成へ移行する際に発生したSwiftGenの利用シーンでの課題とそれに対する解決方法を紹介します。

課題

従来のXcodeGenとCocoaPodsを用いたマルチモジュール構成では、XcodeGenのpreGenCommandでSwiftGenによるコード生成を実行していました。マルチモジュール構成の基盤技術をXcodeGenからSwiftPMに移行したことによって、SwiftGenの実行タイミングを再考する必要がありました。当初はSwiftGenのコマンドを別途手動で実行していたのですが、煩雑な作業だったため自動化したいというモチベーションが生まれました。

自動化にあたり最初に検討したのは、XcodeのBuild PhasesでSwiftGenを実行する方法でした。しかしこの方法は、SwiftGenによるコード生成がSwiftPMで管理されているモジュールのビルドより後に行われてしまいます。つまりBuild PhasesでSwiftGenを実行すると以下の流れで処理が行われることになります。

  1. SwiftPMで管理されているモジュールのビルド
  2. XcodeのBuild Phasesに記載したSwiftGenコマンドの実行
  3. xcodeprojで管理されているソースのビルド

この順番ではSwiftGenによるコード生成がSwiftPMで管理されているモジュールのビルドに間に合わないため、期待通りの動作になりません。

解決方法

そこで今回は、SwiftPMのプラグイン機能を用いてこの課題を解決することにしました。プラグインはSwift 5.6から導入された機能です。この機能を利用することで、SwiftPMで管理されている各モジュールのビルド時にコード生成などの特定の処理を実行できるようになります。

残念ながらSwiftGenは、記事執筆時点でプラグイン機能を簡単に利用するためのPRが取り込まれていません。プラグインの対応は利用者側で整えることも可能ですが、今回は有志の方が作られたnicorichard/SwiftGenPluginを用いることにします。

今回は、以下のディレクトリ構成とPackage.swift、またswiftgen.ymlによってプラグイン機能を用いたビルド時のSwiftGenの実行を実現しました。

.
├── App
│   └── SampleApp.xcodeproj
├── Package.swift
├── Sources
│   ├── ModuleA
│   │   ├── Hoge.swift
│   │   ├── Resources
│   │   │   └── ModuleA.strings
│   │   └── swiftgen.yml
│   └── ModuleB
│       ├── Piyo.swift
│       ├── Resources
│       │   └── ModuleB.strings
│       └── swiftgen.yml
└── Tests
let package = Package(
    name: "SampleApp",
    platforms: ...,
    products: ...,
    dependencies: [
        .package(url: "https://github.com/nicorichard/SwiftGenPlugin", exact: "6.5.1")
    ],
    targets: [
        .target(
            name: "ModuleA",
            exclude: ["swiftgen.yml"],
            dependencies: [],
            plugins: [
                .plugin(name: "SwiftGenPlugin", package: "SwiftGenPlugin")
            ]
        ),
        .target(
            name: "ModuleB",
            exclude: ["swiftgen.yml"],
            dependencies: [],
            plugins: [
                .plugin(name: "SwiftGenPlugin", package: "SwiftGenPlugin")
            ]
        )
    ]
)
strings:
  - inputs: Resources/Hoge.strings
    outputs:
      templateName: structured-swift5
      output: ${DERIVED_SOURCES_DIR}/HogeStrings.swift

ここでは各モジュールごとにコード生成を行いたかったため、各モジュールのディレクトリにswiftgen.ymlを配置しています。また、Package.swiftではPackagedependencies引数にSwiftGenPluginを含めるとともに、ビルド時にSwiftGenによるコード生成を行いたいモジュールごとにTarget.PluginUsage.pluginでSwiftGenPluginを指定しています。各モジュールに配置したswiftgen.ymlはSwiftPMの管理対象から除外するため、Target.targetexclude引数でファイルを指定しています1

Xcode 13で起こる問題とその回避策

上記に挙げた解決方法は、Xcode 14.0 Beta 1では正常に動作しました。しかしXcode 13.4.1以下でかつSwiftPMで管理するモジュールが多い場合にReports Navigatorのレスポンスが悪くなる問題に遭遇しました。具体的には画像のようにパッケージの解決やビルドは終わっているにも関わらず、ビルドログが表示されずにインジケータが回り続けてしまいます。

ビルドが終わっているにも関わらずビルドログが表示されずにインジケータが回り続けてしまっている状態

時間を置くとビルドログは表示されインジケータは止まりますが、インジケータが表示されている間はXcodeのエディタ上でどこでエラーが発生したかが確認できません。ビルド後すぐにエラー発生箇所が特定できないこの問題は開発を行う上で大きな支障でした。

この問題の原因特定には至っていませんが、Package.Dependency.packageを利用せずに、Target.pluginTarget.binaryTargetを利用することでこの問題を回避することができました。これは実際にはnicorichard/SwiftGenPluginPackage.swift内部で行われていることを、ローカルのPackage.swiftに展開した状態です。この方法を用いるとビルドログはビルドに追従して表示され、ビルド終了後すぐにインジケータは停止します。

.
├── App
│   └── SampleApp.xcodeproj
├── Package.swift
├── Plugins
│   └── SwiftGenPlugin
│        └── main.swift
├── Sources
│   ├── ModuleA
│   │   ├── Hoge.swift
│   │   ├── Resources
│   │   │   └── ModuleA.strings
│   │   └── swiftgen.yml
│   └── ModuleB
│       ├── Piyo.swift
│       ├── Resources
│       │   └── ModuleB.strings
│       └── swiftgen.yml
└── Tests
let package = Package(
    name: "SampleApp",
    platforms: ...,
    products: ...,
    dependencies: [],
    targets: [
        .target(
            name: "ModuleA",
            exclude: ["swiftgen.yml"],
            dependencies: [],
            plugins: [
                .plugin(name: "SwiftGenPlugin")
            ]
        ),
        .target(
            name: "ModuleB",
            exclude: ["swiftgen.yml"],
            dependencies: [],
            plugins: [
                .plugin(name: "SwiftGenPlugin")
            ]
        ),
        .plugin(
            name: "SwiftGenPlugin",
            capability: .buildTool(),
            dependencies: ["swiftgen"]
        ),
        .binaryTarget(
            name: "swiftgen",
            url: "https://github.com/nicorichard/SwiftGen/releases/download/6.5.1/swiftgen.artifactbundle.zip",
            checksum: "a8e445b41ac0fd81459e07657ee19445ff6cbeef64eb0b3df51637b85f925da8"
        )
    ]
)

まとめ

今回は、SwiftPMを利用したマルチモジュール構成を導入した際に実際に直面した課題とその対応について紹介しました。課題解決を通して、Swift 5.6から新しく導入されたプラグイン機能を知ることができ、Swiftによる表現がさらに広がったことを実感しました。 SwiftGenを利用しているiOSプロダクトは数多くあると思うので、それらプロダクトの課題解決の一助になれば幸いです。


  1. swiftgen.ymlを管理対象から除外しない場合、パッケージの解決時に以下のような警告がでます。
    found 1 file(s) which are unhandled; explicitly declare them as resources or exclude from the target