Mobile Tech Lead の久保出です。今回は、Wantedly VisitアプリのiOSビルド、とくに差分なしビルドを約80%短縮した話を書きます。
以前の状況
Visitアプリでは、画像や文字列といったリソースを SwiftGen でコード生成していました。SwiftGenは、Assets.xcassets や Localizable.strings から型安全なシンボルを自動生成してくれるツールです。たとえば画像なら Asset.xxx.image、文字列なら L10n.xxx といった形で、文字列キーのtypoをコンパイル時に検出できます。
このSwiftGenを、Swift Package Manager(SPM)の Build Tool Plugin(外部パッケージの SwiftGenPlugin)として各モジュールに組み込み、ビルドのたびに自動生成する構成をとっていました。リソースを型安全に扱える仕組みとして、長く重宝していたものです。
課題: 差分なしビルドに60秒以上
ところが、開発を続けるなかで、コードを何も変えていないのにビルドが遅いことが気になり始めます。計測すると、変更なしビルドに66〜79秒かかっていました。
調べていくと、20モジュール × 最大2ファイル = 34個の生成ファイルが毎回再コンパイルされる連鎖が起きていました。この再コンパイルだけで+50〜60秒のオーバーヘッドになっていました。
原因: prebuildCommands()
根本原因は、外部 SwiftGenPlugin が prebuildCommands() ベースで実装されていたことでした。
SPMの Build Tool Plugin には、コード生成を差し込むAPIが2種類あり、キャッシュ特性が大きく異なります。
- 実行タイミング:
prebuildCommands()は毎回、buildCommands()は入力が変わったときだけ - input/outputの宣言:
prebuildCommands()はできない、buildCommands()はできる - ビルドシステムによるスキップ:
prebuildCommands()は不可、buildCommands()は可能
prebuildCommands() は input/output を宣言できないため、ビルドシステムは「生成結果が最新かどうか」を判断できず、毎回コード生成を実行します。すると生成ファイルが毎回書き直され、タイムスタンプが更新され、それに依存するコードの再コンパイルが走り、ビルドを遅くしていました。
解決策: 3段階での移行
一気に作り替えるとリスクが高いので、段階的に進めました。
Phase 0: 自作のローカルプラグインで buildCommands() 化
まず、外部 SwiftGenPlugin を、buildCommands() ベースの自作ローカルプラグイン SwiftGenBuildPlugin に置き換えました。
ポイントは、input(.xcassets / .strings / .stencil)と output(生成される .swift)を明示的に宣言することです。これにより、入力が変わっていなければビルドシステムがコード生成自体をスキップできるようになります。.xcassets ディレクトリは中身を再帰的に展開して input に含め、アセットの追加・削除まで検知できるようにしました。
この時点で、差分なしビルドは66秒から17秒に短縮されました。
Phase 1+2: SwiftGenをやめ、Xcode標準のシンボル生成へ
Phase 0で速くはなりましたが、「そもそもSwiftGenが要るのか?」という問いが残ります。Xcode 26 では、Asset CatalogやString Catalogから標準機能で型安全なシンボルが自動生成されるようになっていました。
そこで、SwiftGen依存を撤廃し、標準APIへ置き換える方針にしました。
Asset.xxx.image→UIImage(resource: .xxx)Image(uiImage: Asset.xxx.image)→Image(.xxx)L10n.xxx→Text(.xxx)/String(localized: .xxx)
ただ、この移行は21モジュールにまたがる膨大な作業でした。ここで効いたのが作業のSkill化です。
パイロット移行を1モジュールで行い、そこで行った移行手順を一連の手順として Claude Code のスキル(swiftgen-migrate-module) に落とし込み、再現性を高めました。
工夫したのは、スキルに未定義の処理や特殊なケース(基盤モジュールのpublic wrapper設計や、プレースホルダー付きキーの扱いなど)に当たったら、スキルがそこで処理を止めて人間に判断を仰ぐようにした点です。さらに、そこで下した人間の判断をスキル自体に書き戻して反映することも手順に組み込みました。こうすると、同じ特殊ケースに次のモジュールで再び当たったときには、もうスキルが自走で処理できます。移行を進めるほどスキルが賢くなり、人間の介入回数が減っていく、という設計です。
結果として「1モジュール = 1 PR」でほぼ自走でき、人間は設計判断にだけ集中できました。同じ作業をN回繰り返す移行こそ、Skill化の費用対効果が高い領域だと実感しています。
Phase 3: SwiftGenの完全除去
全21モジュールがSwiftGenから脱却できたら、最後にインフラ部分を一掃します。swiftgen.yml(21本)、カスタムStencil、Phase 0で作った自作プラグイン、Package.swift から依存を除去するなどしました。
役目を終えた移行用スキルもこのPhaseで削除し、そこで得た実用知見(Text(.xxx) と String(localized: .xxx) の使い分けなど)はチームの AGENTS.md に転記して残しています。
最終的に、差分なしビルドは約14秒になりました。
効果
プロジェクト全体での推移は以下です。
- 移行前(外部SwiftGenPlugin /
prebuildCommands): 66〜79秒 - Phase 0後(自作
buildCommandsプラグイン): 17秒 - Phase 3後(SwiftGen完全除去): 約14秒(約80%削減)
何より、Xcode標準のツールへ移行できたことも大きな成果と言えます。
学びと残課題
ビルド時間の課題から原因の特定には、AIに分析してもらい実現できました。
普段Xcodeで開発していると、ビルドが遅いとわかっていても分析しづらいのです。しかしAIにより、ビルド時間を解析するXcodebuildのフラグの存在やSPM Build Tool Pluginの仕様について知ることができました。
14秒まで時間を削減できましたが、まだ課題は残っています。Xcodeの Run script フェーズでもまだ無駄なタスクが存在していることが明らかになっており、次の改善余地として残っています。
まとめ
ビルド時間の課題感からツールを見直し、標準的なツールへ移行することで、差分なしビルドを66〜79秒から約14秒(約80%削減)まで短縮できました。あわせて、繰り返しの多い移行作業をSkill化して自走させたことで、全体の移行コストも大きく抑えられました。
同様にビルド時間に悩んでいる方の参考になれば幸いです。