Carthageによるフレームワークのビルドは時間が掛かるので、CIでは良い感じにキャッシュを当ててもらいたい。この記事の中ではそれをやってくれる平凡な設定を貼るのだけど、なぜそれが効率的なのか理解するにはCarthageとCIのキャッシュの仕組みを理解する必要があるので、まとめておく。我が社(エンジニア募集中)のCIはすべてCircleCIなのでCircleCIの話になるのだけど、たぶん他のCIサービスでも似た仕組みはありそう。

ちなみにこれは、半年ほど前に @chuganzy さんにTwitterで教えてもらったのがきっかけで調べ始めたもの。ずっとやろうと思ってたのだけど全然やってなくて、遂にやった。うっかりしている間に半年も経っていて、自分の先延ばし能力に驚かされた…。

Carthageのキャッシュ

Carthage 0.20(2017年2月リリース)からCarthageのbootstrapコマンドには--cache-buildsというオプションが入っていて、これを使えば再ビルドが不要なフレームワークのビルドはスキップしてくれる。

再ビルドが必要かどうかの判定には、Carthage/Buildに入っている.Project.versionというファイルが使われている。.Project.versionはCarthageがチェックアウトしたソースのcommitish(SHA1ハッシュやタグなどのコミットを参照するもの)と、ビルドしたフレームワークのSHA256ハッシュを持っている。これらの値はつまるところ、Carthage/Buildに入っているフレームワークのバージョンがCartfile.resolvedが指しているバージョンと一致しているかどうかの確認に使われる。この辺りのことは、Documentation/VersionFile.mdに書かれている。

フレームワークがSwiftのものだった場合、CarthageはフレームワークがビルドされたSwiftのバージョンと、ローカルのSwiftのバージョンが一致しているかどうかもチェックしてくれる。これはSource/CarthageKit/VersionFile.swift辺りを読むとわかる。

要するに、Carthageはライブラリを更新した時には更新されたフレームワークだけをビルドくれるし、Swiftのバージョンが変えた時にはSwiftのバージョンが食い違ってくれるものだけどビルドしてくれるという、dependency managerとして至極真っ当なキャッシュの管理をしているということがわかる。これは後ほど説明するパーシャルキャッシュを使う上で重要となる。

CircleCIのイミュータブルなキャッシュ

CircleCIではdependencyのキャッシュをキー毎に持つことができる。特徴的なのは、キーに対するキャッシュが完全にイミュータブルという点で、1度キーにキャッシュを保存したら同じキーには2度と書き込むことができない。なので、同じキーに対して常に同じキャッシュ(あるいは同じものとして扱えるキャッシュ)が生成されるように、キーを設計する必要がある。

Carthageを使う場合、Carthage/Buildがキャッシュの対象となる。Carthage/Buildにビルドされるフレームワークは、Swiftのバージョンとフレームワークのバージョンの2つによって変わるので、これらをキャッシュキーに含める必要がある。ここではSwiftのバージョンは手動で指定し、フレームワークのバージョンはCartfile.resolvedのチェックサムで表現し、carthage-swift4.2-{{ checksum "Cartfile.resolved" }}をキーとした。

このキーを使って、読み込みと保存の設定を以下のようにする。

steps:
  - restore_cache:
      keys:
        - carthage-swift4.2-{{ "checksum Cartfile.resolved" }}
  - run: carthage bootstrap --cache-builds --platform iOS
  - save_cache:
      key: carthage-swift4.2-{{ "checksum Cartfile.resolved" }}
      paths: Carthage/Build

CircleCIのパーシャルキャッシュ

CircleCIにはパーシャルキャッシュというものがある。これはキーに対するキャッシュが存在しない時に使われるキャッシュで、別のキーのキャッシュでも一部は再利用できるだろうという予測に基づいている。例えば、RxSwiftとNukeとLottieを使っていて、RxSwiftのみを更新した場合、Cartfile.resolvedが変更されるのでキャッシュのキーは変わるが、従来のキーのキャッシュでもNukeとLottieはそのまま使えるという感じ。

パーシャルキャッシュを使うには、steps.restore_cache.keysにパーシャルキャッシュをロードするキーのプレフィクスを追加する。CircleCIは1つ目のキーから順番に前方一致でマッチングを行い、ヒットしたキャッシュのうち最新のものを使用する。


steps:
  - restore_cache:
      keys:
        - carthage-swift4.2-{{ "checksum Cartfile.resolved" }}
        - carthage-swift4.2-
        - carthage-
  - run: carthage bootstrap --cache-builds --platform iOS
  - save_cache:
      key: carthage-swift4.2-{{ "checksum Cartfile.resolved" }}
      paths: Carthage/Build

上記の例では、2番目のキーがマッチするのは、ライブラリを更新してCartfile.resolvedも更新された時となる。この時、同じSwiftのバージョンで最近ビルドされたCarthage/Buildがキャッシュとして使われ、CarthageはCartfile.resolvedCarthage/Buildで異なるバージョンになっているフレームワークのみをビルドする。例えば、RxSwiftとNukeとLottieを使っていて、RxSwiftを更新した場合、Carthageが再ビルドするのはRxSwiftだけとなる。

3番目のキーがマッチするのは、異なるSwiftのバージョンを使う時となる。この時、とにかく最近にビルドされたCarthage/Buildがキャッシュとして使われる。CarthageはSwiftのバージョンが異なっているフレームワークも再ビルドする。例えば、RxSwiftとNukeとLottieを使っていて、ライブラリのバージョンは変えずにSwiftのバージョンだけを変えた場合、RxSwiftとNukeが再ビルドされる。LottieはObjective-Cのフレームワークなのでキャッシュが使われる。

2番目のキーがマッチする場合も、3番目もキーがマッチする場合も、1度キャッシュがビルドされてしまえば、以降はどこのブランチでも1番目のキーがマッチするようになるので、再ビルドは全ブランチを通じて1回のみで済む。

まとめ

CarthageとCircleCIのキャッシュの機能をマジメに使っていれば、フレームワークやSwiftのバージョンを更新にあたってCIのビルド時間を気にする必要はほとんどなくなる。ABIが安定化されたり、Swift Package ManagerがiOSをサポートしたりすれば状況は変わるだろうけど、同じような概念は頭に入れておくと良さそう。

追記 (2018/12/06)

この記事を投稿したら、またTwitterで教えてもらえた。

記事の中では手動でSwiftのバージョンを指定していたが、これを自動化しておくと手間も省けるし更新忘れも防げる。元々、キャッシュキーにswift --versionの結果を含めたいと思ってはいたが、CIのステップでファイルに出力しておいて、そのチェックサムを使うという発想はなかった。

ブログを書いておくと、色々教えてもらえて便利。