XcodeGenによる新時代のiOSプロジェクト管理

こんにちは。モバイル基盤部の@giginetです。平成最後のエントリを担当させていただきます。

iOSアプリの開発では、Xcodeが生成するプロジェクトファイルである、*.xcodeprojをリポジトリで共有するのが一般的です。

しかし、この運用は大規模なプロジェクトになるほど、数多くの課題が発生します。

クックパッドiOSアプリは巨大なプロジェクトであり、通常の*.xcodeprojによる管理には限界が生じていました。

そこで、昨年秋にXcodeGenというユーティリティを導入し、プロジェクト管理を改善したので、その知見をお伝えします。

f:id:gigi-net:20190425150234g:plain

従来のプロジェクト管理の問題点

ファイル追加の度にコンフリクトが発生する

*.xcodeprojファイルはプロジェクトに含まれるソースファイルの管理を行っています。

開発者がプロジェクトにファイルを追加すると、このプロジェクトファイルが更新されることになります。

そのため、同時に数十人が開発するクックパッドiOSアプリの開発環境では、プロジェクトファイルのコンフリクトが日常茶飯事で、解消に多くの工数が発生していました。

レビューがしづらい

*.xcodeprojは特殊なテキストデータで表現されますが、とても人間が読める物ではなく、差分をレビューするのは困難です。

簡単なファイルの移動でも複数の差分が発生します。

大きな変更に向かない

クックパッドiOSアプリでは、巨大なビルドターゲットを分割し、ビルド時間を改善する『霞が関*1と呼ばれる取り組みを行っています。

マルチモジュール化を行っていくに当たって、ドラスティックな*.xcodeprojの変更に耐える必要がありました。

単なるファイル追加であれば、コンフリクトの解消や、レビューの難しさという問題はまだ解決可能でしたが、ターゲットやBuild Configurationの追加、大量のファイルの移動といったプロジェクトの変更をもはや人類が適切に扱うことは困難でした。

XcodeGenとは

そこで導入したのがXcodeGenです。

XcodeGenは、XcodeのプロジェクトデータをYAMLで記述し、定義から冪等に*.xcodeprojを生成できるユーティリティです。

このようなYAMLを定義し

targets:
  Cookpad:
    type: application
    platform: iOS
    sources:
      - path: Cookpad

XcodeGenを実行すると、*.xcodeprojを自動生成することができます。

$ xcodegen
Loaded project:
  Name: Cookpad
  Targets:
    Cookpad: iOS application
  Schemes:
    Cookpad
⚙️  Generating project...
⚙️  Writing project...
Created project at Cookpad.xcodeproj

f:id:gigi-net:20190425150303p:plain

このツールの導入により、数々の問題が解消できました。

導入して良かったこと

ファイルツリー構成が強制される

まず、*.xcodeprojの問題点として、ファイルシステム上のツリーと、プロジェクトの保持するツリーが一致しないという問題がありました。

追加されるファイルは、ファイルシステム上の階層と必ずしも一致しませんし、思い思いに追加されるため、プロジェクトが煩雑になります。

XcodeGenによる生成では、ファイルツリーからプロジェクト構造を生成するため、この不一致が解消されます。

targets:
  Cookpad:
    sources:
      - path: Cookpad

例えば、この指定では、Cookpad以下のファイル全てがCookpadターゲットに所属するため、ファイルシステム上の位置を強制することができます。

f:id:gigi-net:20190425150316p:plain

コンフリクト解消が不要に

上記の仕様による一番わかりやすい恩恵は、プロジェクト差分のコンフリクトからの解放です。

従来の*.xcodeprojでは、開発者がソースファイルを追加する度に更新が入り、差分が発生していました。

しかし、XcodeGenの仕様においては、ソースファイルの追加時にリポジトリへのファイル追加以外の操作が不要になり、一切のプロジェクトのコンフリクトがない世界が到来しました。

ターゲットの追加が容易に

上記の特性はビルドターゲットの追加にも役立ちます。XcodeGenでは、わずか数行のYAMLの定義のみでビルドターゲット追加を行うことができます。

targets:
  CookpadCore:
    type: framework
    platform: iOS
    sources:
      - CookpadCore

f:id:gigi-net:20190425150330p:plain

また、ターゲット間のソースファイルの移動も簡単です。 従来は、1ファイルごとにどのビルドターゲットでビルドされるか、という情報が保持されていたため、ファイルを移動する度にプロジェクトに大きな差分が発生していました。

しかし、XcodeGenでプロジェクトを生成することにより、特定のディレクトリ下のソースコードは、必ず特定のビルドターゲットに含まれることを保証することができるようになりました。

これにより、ビルドターゲット間の移動は単にgit mvするだけで済むようになりました。

この特性は、プロジェクトのマルチモジュール化に大きく役立ちました。

XcodeGenの導入

XcodeGenを導入したい場合、残念ながら既存の*.xcodeprojから簡単にプロジェクト定義ファイルを生成する方法はありません。

基本的には、ドキュメントを追いながら、生成結果を目で見て確認していきます。

GUIでの設定値をプロジェクト定義に忠実に移植する為には、既存の*.xcodeproj/project.pbxprojをテキストエディタで開き、設定値を探していくという地道な作業も発生しました。綺麗なプロジェクト定義を記述するには、Xcodeプロジェクトの構造をよく理解している必要があるでしょう。

最終的にクックパッドiOSアプリは、400行程度のYAMLファイルでほぼ元の挙動を再現することができました。

そこで、複雑なプロジェクトをXcodeGenの定義ファイルで記述するためのテクニックをいくつかご紹介します。

SettingGroup

まずはSettingGroupの機能です。 複数のターゲットで共通して利用したいビルドフラッグなどの設定をSettingGroupとして定義しておき、利用したいターゲットで読み込んで使用することができます。

settingGroups:
  SharedSettings:
    configs:
      OTHER_SWIFT_FLAGS: -DDEBUG
targets:
  Cookpad:
    type: application
    settings:
      groups: [Shared]
  OtherFramework:
    type: framework
    settings:
      groups: [Shared]

パッケージ管理

プロジェクトにCarthage*2でインストールしたライブラリを統合したい場合も簡単に記述できます。

targets:
  Cookpad:
    type: application
    platform: iOS
    dependencies:
      - carthage: RxSwift

このcarthage指定を用いるだけで、Embed Frameworkの設定や、Frameworkのコピーなど、Carthageの利用に必要な設定を自動で行ってくれます。

f:id:gigi-net:20190425150345p:plain

一方で、CocoaPodsを併用する場合、事態は複雑です。

CocoaPodsは、プロジェクトファイルにあとからビルド設定の注入を行う必要があるからです。現在のXcodeGenでは、プロジェクト定義だけでそれを管理することはできません。

例えば一連の処理をMakefileに記述するというアプローチが考えられるでしょう。

xcodegen
bundle exec pod install

ソースコード生成

Sourceryなどのコードジェネレーションと、XcodeGenを併用する場合には少し工夫が必要です。

XcodeGenによるプロジェクトツリーは、通常、存在しているソースファイルのファイルシステム上の構成により構築されます。

一方で、ソースジェネレーションを行うためには、他の定義からソースコードを生成するため、プロジェクトツリーが必要になります。 このように、鶏と卵問題が発生してしまうのです。

そこで、optionalオプションで、生成前のソースファイルの参照だけ持ち、先にプロジェクトを構築し、あとからソースジェネレーションを行うことでこの問題を解決しています。

targets:
  CookpadTests:
    type: unit-test
    platform: iOS
    sources:
      - path: "CookpadTests/AutoGenerated/AutoGenerated.swift"
        optional: true
        type: file

f:id:gigi-net:20190425150358p:plain

今後の課題

XcodeGenを大規模に運用している中で以下のような問題が発生しました。

主にプロジェクトの生成時間に関する課題で、現在解決している最中です。

CocoaPodsを利用するときの生成速度

CocoaPodsの利用時に、プロジェクト生成後、毎回pod installが必要なことは、先ほど触れました。

XcodeGenは冪等に実行されますが、それ故に、プロジェクト生成ごとにCocoaPodsによるビルド設定の注入を毎回行う必要が出てくるのです。

この仕組みでは、XcodeGenの生成ごとに毎回パッケージインストールが走り、数十秒の待ち時間が発生しています。

この問題を解決するアイディアはいくつかあります。

まずは、CocoaPodsによるプロジェクトの設定変更を無効化し、自分で依存関係を記述する方式です。*3

もう一つの方法は、CocoaPods 1.7で利用可能になったincremental_installを有効にすることです。 このオプションを有効にすることで、差分がある依存関係のみが生成されるため、プロジェクト生成速度が改善すると踏んでいます。

いずれの方式も構想段階でまだ実用できていません。

プロジェクトキャッシュの問題

毎回プロジェクトファイルを上書きしていると、希にXcodeのビルドキャッシュが無効になり、フルビルドが発生してしまう問題にも遭遇しています。

この問題は、生成されたプロジェクトの差分が発生しないようにしても再現しており、解決していく必要があります。

まとめ

ご覧いただいたように、XcodeGenを使ったプロジェクト運用は、クックパッドiOSアプリほどの規模であっても十分に実用できていると言えます。

*.xcodeprojで苦しむのは平成までです。皆さんもプロジェクトを破壊して新しい時代を迎えませんか。

クックパッドではXcodeプロジェクトのコンフリクト解消で消耗したくないエンジニアを募集しています。

*1:詳しくは2月に行われたCookpad Tech Confの資料をご覧ください https://techconf.cookpad.com/2019/kohki_miki.html

*2:ちなみに筆者は最近Carthageのコミッターになりました 💪

*3:この手法は integrate_targets というオプションを有効にすることで実現できますが、難しいのでここでは解説しません

/* */ @import "/css/theme/report/report.css"; /* */ /* */ body{ background-image: url('https://cdn-ak.f.st-hatena.com/images/fotolife/c/cookpadtech/20140527/20140527163350.png'); background-repeat: repeat-x; background-color:transparent; background-attachment: scroll; background-position: left top;} /* */ body{ border-top: 3px solid orange; color: #3c3c3c; font-family: 'Helvetica Neue', Helvetica, 'ヒラギノ角ゴ Pro W3', 'Hiragino Kaku Gothic Pro', Meiryo, Osaka, 'MS Pゴシック', sans-serif; line-height: 1.8; font-size: 16px; } a { text-decoration: underline; color: #693e1c; } a:hover { color: #80400e; text-decoration: underline; } .entry-title a{ color: rgb(176, 108, 28); cursor: auto; display: inline; font-family: 'Helvetica Neue', Helvetica, 'ヒラギノ角ゴ Pro W3', 'Hiragino Kaku Gothic Pro', Meiryo, Osaka, 'MS Pゴシック', sans-serif; font-size: 30px; font-weight: bold; height: auto; line-height: 40.5px; text-decoration: underline solid rgb(176, 108, 28); width: auto; line-height: 1.35; } .date a { color: #9b8b6c; font-size: 14px; text-decoration: none; font-weight: normal; } .urllist-title-link { font-size: 14px; } /* Recent Entries */ .recent-entries a{ color: #693e1c; } .recent-entries a:visited { color: #4d2200; text-decoration: none; } .hatena-module-recent-entries li { padding-bottom: 8px; border-bottom-width: 0px; } /*Widget*/ .hatena-module-body li { list-style-type: circle; } .hatena-module-body a{ text-decoration: none; } .hatena-module-body a:hover{ text-decoration: underline; } /* Widget name */ .hatena-module-title, .hatena-module-title a{ color: #b06c1c; margin-top: 20px; margin-bottom: 7px; } /* work frame*/ #container { width: 970px; text-align: center; margin: 0 auto; background: transparent; padding: 0 30px; } #wrapper { float: left; overflow: hidden; width: 660px; } #box2 { width: 240px; float: right; font-size: 14px; word-wrap: break-word; } /*#blog-title-inner{*/ /*margin-top: 3px;*/ /*height: 125px;*/ /*background-position: left 0px;*/ /*}*/ /*.header-image-only #blog-title-inner {*/ /*background-repeat: no-repeat;*/ /*position: relative;*/ /*height: 200px;*/ /*display: none;*/ /*}*/ /*#blog-title {*/ /*margin-top: 3px;*/ /*height: 125px;*/ /*background-image: url('https://cdn-ak.f.st-hatena.com/images/fotolife/c/cookpadtech/20140527/20140527172848.png');*/ /*background-repeat: no-repeat;*/ /*background-position: left 0px;*/ /*}*/