CATS CATS PRODUCTIVITY BLOG

iOS開発におけるパッケージ管理方法の私見

こんにちは。CATS Productivity Team の iOS 担当 青山 (@ra1028fe5) です。
最近は同チームのメンバーが海外 旅行 カンファレンスに行っている中、黙々とデスクで仕事しています。

今回は iOS 開発におけるパッケージ管理についてです。
ライブラリについてだけではなく、開発に利用する Swift 製ツールの管理についても軽く触れます。
最近では Xcode と Swift Package Manager (libSwiftPM) の統合により、これがデファクトスタンダートになっていく事が前提ですが、まだしばらくはサードパーティツールを利用していくことになる理由と個人的に気に入っている管理方法を書こうと思います。

[Disclaimer] ツール自体の優劣を説いた内容ではありません

はじめに

個人的な浅い感覚ですが、iOS の開発は他のプラットフォームの開発に比べて、エコシステムがあまり発達していない傾向にあると思います。
2020 年になっても、未だにベストプラクティスと呼べるような方法は見たことが無いですし、長年試行錯誤を重ねた今の方法でも十分に辛いです。

この記事では、長期的にチームで運用していくことを想定して、開発速度や安定性のバランスを取り、個人的に最も良かった方法と其の理由をつらつらと書いていきます。

結論から言えば、 CocoaPodscocoapods-binary でアプリケーション用ライブラリを管理し、 CocoaPodsSwift Package Manager で CLI ツールを管理していて、実際に弊社のいくつかのプロジェクトで採用している方法です。

パッケージ管理に要求されること

パッケージ管理と言っても、個々人で想像するものが異なると思いますので、個人的な要件を前述しておきます。

  • 定義に従って依存パッケージをインストールできる
  • 依存同士のバージョン衝突を解決することができる
  • 解決したバージョンで固定・共有し、再現性がある
  • プロジェクトローカルにインストールし、ホスト環境を汚染しない
  • framework(library)は事前ビルドできる
  • 併用するパッケージマネージャを最小限に抑える

細かいことを書けば色々とありますが、大きくは上述を満たせて、且つ管理が楽な方法が個人的に思う「良い」パッケージ管理の要件です。
それぞれのライブラリによって最も合うツールがあると思いますが、それら全てを併用してしまうのが最も安定しないので、意図的に避けてバランスを取るようにしています。

Swift Package Manager について

これからの iOS 開発におけるパッケージ管理において主軸になるべきファーストパーティツールです。以下 SwiftPM と省略します。

公開からしばらくの間は Swift 製の CLI ツールなど、 Apple プラットフォームのアプリケーション以外におけるパッケージ管理のみを対象にされていましたが、Xcode11 になってようやくアプリ開発に利用できるようになりました。
これは SwiftPM の CLI ツールが Apple プラットフォームの architecture に向けた framework を生成できるようになったわけではなく、SwiftPM のコアライブラリである libSwiftPM が Xcode に統合されることで実現されました。
それによって GUI から簡単な操作でライブラリを追加でき、アプリのビルド時に必要なライブラリが生成されていなければ同時にビルドしてくれるので、シンプルに利用できます。

の難点の一つは、依存の宣言や解決が xcodeproj 以下のファイルに記録されることです。ただでさえ複雑なプロジェクトファイルに依存まで書き込まれるのは少し厄介ですね。
また成果物は Derived Data 以下に配置されるので ビルドキャッシュのクリーンに伴って動かなくなったりします。
そしてこの記事を書いた現在は、成果物は強制的に static library としてリンクされるので、リソースを含むライブラリには対応していません。これは将来的に可能になるプロポーザルが accept されています。 SE-0271: Package Manager Resources

その他諸々の理由から、SwiftPM のみで開発できるようになるのはもう少し先。また、将来的に高度なビルドシステムを構築しようとするとすると少々障壁があるように思い、今の所採用は見送っています。

ただし、Swift 製 CLI ツールについては別です。
最近は XcodeGen の流行りもあり、同開発者(yonaskolb)による mintを利用してツール管理を行うのがベストであるという話題をよく見かけますが、実は CLI の方の SwiftPM のみでほぼ同じことが可能です。
Package.swift ファイルの dependencies に必要な executable を含むツールを記述し

import PackageDescription

let package = Package(
    name: "Tools",
    dependencies: [
        .package(url: "https://github.com/yonaskolb/XcodeGen.git", .upToNextMinor(from: "2.13.1"))
    ]
)

npm のように executable をビルド・実行できます。

swift run xcodegen

プロジェクトルートの Package.swift に書けない場合は --package-path で指定すればファイルを分ける事もできます。
いつか Swift Forums のどこかで見かけたのですが、ドキュメントはパっと探せませんでした。
mint を使う場合はどうしても Homebrew などに依存してしまいますが、この方法なら Swift の公式エコシステムで完結するのでおすすめです。
ただし、テンプレートファイルを持つ SwiftGen のようなリソースを含むツールは利用できません。mint はツール側が対応していれば利用できるので、この点に関して有利です。

Carthage について

Carthage の利点や、解決した CocoaPods 全盛期の課題については、インターネット上に素晴らしい記事が複数ありますので、ここでは私が選ばなかった理由・難点についてのみ記載します。

以前のプロジェクトでは下記のように、成果物をキャッシュしつつ利用して十分満足できていました。ちなみに、個人的にライブラリ開発者によるビルド済みのバイナリのインストールは、セキュリティ的にも、Library Evolution に対応したライブラリがほとんどない状況的にも個人的に好まず --no-use-binaries を指定しています。

carthage update --platform iOS --use-ssh --no-use-binaries --cache-builds

しかし、利用期間が長くなって来て見えてくる難点として一つはバージョンアップによるデグレードが頻発する点です。
非常に複雑な iOS のライブラリの仕組みを扱っているので仕方ないことなのですが、頻繁に CI がコケたり、SwiftPM でインストールしている Carthage 自体がビルドできなくなることへの対応が辛かった記憶があります。

また、無限に増えていく bcsymbolmap ファイルの削除など、いくつかの軽いスクリプティングが必須になることや、CLI ツール管理には利用できない、対応(推奨)していないライブラリが多い(Firebase などの SDK 系)などの理由で現在は利用していません。(OSS では --no-build --use-submodules オプションで利用しています)

CocoaPods について

言わずもがな、最も長い歴史と最も大きいコミュニティを持つパッケージマネージャです。
クリーンすると再ビルドが必要で、ビルド時間がかさむという課題を Carthage が華麗に解決してからはモダンな現場では下火になっています。

ソフトウェアとしては大きなコミュニティによって活発に開発が進められているというのが最大の利点だと思います。
battle-tested と言われることがありますが、最も多くの環境のプロダクションで実践試験済みであり、通常ではカバーしきれないほどのユースケースやバグに対応していますし、困った時は調べれば良い解決方法が見つかることが多いです。

ライブラリの他に CLI ツールのバイナリも CocoaPods で管理することができます。やはり様々な形式のパッケージ管理を統合されているのは素晴らしいですね。
SwiftPM がまだ対応していない SwiftGen のようなリソースを含むツールにも使えるので、私は殆どのツールを CocoaPods でインストールしています。
ただし、XcodeGenCarthage (併用している場合) は CocoaPods より前に実行しなければならないという矛盾により別のインストール方法が必要なので、SwiftPM を併用しています。

以前は、インストールが遅いという難点がありましたが、バージョン 1.7 から CDN に対応して高速化されました。
実行時に、xcodeproj 下のファイルに直接リンクの仕組みのためのスクリプトを勝手に挿入されるのもあまり快くはありませんが、他と比べて高度なリンクの仕組みを持つので目を瞑りましょう。

意外と便利なのが、Podfile から ruby スクリプトを介入できるので少々ハックなことをしなければならない場合に、特別な苦労をしなくて済みます。
ライブラリと言っても様々な開発者がそれぞれの設定でリリースしていますが、Carthage ではこれができないので fork して設定だけ変更しなければならないケースがあります。

CocoaPods が下火になっていった理由は、前述したビルドに時間がかかる点が殆どな訳ですが、cocoapods-binary というプラグインによって CocoaPods で管理しつつ、事前ビルドすることができるという理想的な解決ができます。

cocoapods-binary について

CocoaPods でインストールするライブラリの宣言に binary: true を付けるだけで事前ビルドしてくれる夢のツールです。

plugin 'cocoapods-binary'

pod 'DifferenceKit', '~> 1.1.5', binary: true

個人開発のサードパーティですが、それなりにユーザーがいるようで 公式ドキュメント にも載っています。
CocoaPods 最大の難点であるビルド時間を解消できるので、これにより

  • 最大コミュニティによる実践試験
  • ライブラリの導入を一貫して面倒を見てくれる
  • 事前ビルド可能
  • 殆どのライブラリが対応
  • ruby スクリプトでビルド・リンクに介入可能
  • CLI ツールもインストール可能

という、かなり都合の良いパッケージマネージャになります。
CI 上でも、Pods/ ディレクトリ下をキャッシュに載せてしまえば、同環境での CI の横断的な高速化が期待できます。

Firebase については static framework で配布されていて、それ自体の依存ライブラリをバイナリ化するために、binary: true したいところですが、エラーになるので Carthage 用に配布されている dynamic framework を下記のようにローカルに手書きした Podspec で導入するのをお勧めします。
他にも static framework で配布されているライブラリの導入に少し工夫するケースがありますが、総合的に見てかなり良いツールだと思います。

Pod::Spec.new do |s|
    s.name             = 'FirebaseAuth'
    s.version          = '6.4.3'
    s.summary          = 'The official iOS client for Firebase Authentication'
    s.homepage         = 'https://firebase.google.com'
    s.license          = { :type => 'Apache', :file => 'LICENSE' }
    s.authors          = 'Google, Inc.'

    # https://dl.google.com/dl/firebase/ios/carthage/FirebaseAuthBinary.json
    s.source = { :http => 'https://dl.google.com/dl/firebase/ios/carthage/6.16.0/FirebaseAuth-e24be2fcbaaaf213.zip', :flatten => true }
    s.vendored_frameworks = 'FirebaseAuth.framework'

    # Firebase系以外の依存もzipに入っているが、他のPodsと衝突する可能性があるので
    # dependencyとして別途インストールしてCocoaPodsの依存解決に任せる
    # https://github.com/CocoaPods/Specs/blob/master/Specs/6/3/6/FirebaseAuth/6.4.3/FirebaseAuth.podspec.json
    s.dependency 'GoogleUtilities', '~> 6.5'
    s.dependency 'GTMSessionFetcher/Core', '~> 1.1'
end

Static framework (library) について

ここまで、dynamic framework としてのインストール方法を書いてきましたが、アプリの起動速度改善のため、static framework を利用したい場合はどうかというと、正直まだ時間を投資できていないのでなんとも言えません。
しかし、CocoaPods の利点の一つとして、インストール時にスクリプトが介入できるので、最も対応しやすいかと思っています。
全て未確認ですが、いくつかプラグインもあるようです。

ただ iOS 12 辺りからは、あまり意識しなくても十分高速に起動しますので、一般的なアプリが利用するライブラリ程度の数のリンクではあまり差は出ないかもしれません。

Google や Uber、 Lift のようなテックカンパニーではアプリのモジュール化が非常に進んでいて、数百のモジュールがありますので dynamic link では起動速度的に問題が有り、static link が絶対ですが、これはライブラリ管理どうこうではなく異なると対応が必要になると思います。

CLI ツールに Homebrew は?

Homebrew 自体は最高のツールだと思いますが個人的には、お仕事でチーム開発をする場合はできるだけ避けるのが良いと思います。
理由はそのままなんですが、バージョン管理とプロジェクトローカルへのインストールに対応してないので CI やメンバー間での差異があり、失敗しやすく、ハマりやすいからです。
コンパイルが必要な言語は後方互換がうまく行かないケースが多いので、場合によっては curl で取ってきた方がまだ良かったりします。

将来のパッケージ管理

最初に述べたように、SwiftPM がデファクトスタンダードとして成長していくのが正しいと思いますが、実際の現場ではもう数年はサードパーティが生きていくのではないでしょうか。
少し前に出た Accio というツールも少し気になっています。

個人的にはパッケージ管理以上に、より高度なビルドシステムの必要性を感じているので、BazelBuck を使うとことでライブラリ管理も解決できるかもしれません。
まだ構想しかありませんが、Bazel + CocoaPods + cocoapods-binary で安定した構成が作れるんじゃないかなーと夢想しています。

最後に

プロダクティビティとはちょっと外れた話題かもしれませんが、パッケージ管理に対する私見を書きました。
それほど熱心に調べ切った内容ではなく、最近はアプリ開発から少し離れているので、誤った内容があるかもしれません。参考にする程度にしていただければと思います。

チームや利用しているパッケージの種類によっても最適解は異なると思いますので、良ければ皆さんのチームでのやり方も是非教えて下さい。

おしまい。