XcodeでSwift Package Manager実用段階

こんにちは、モバイル基盤部のヴァンサン(@vincentisambart)です。

Swift Package ManagerはAppleがXcodeで公式にサポートしている唯一のパッケージマネージャーです。Xcode公式サポートの他に、Swift Package Manager形式でのみ提供されているswift-algorithmsswift-atomics、将来的に期待されているswift-async-algorithmsといった準標準ライブラリを利用できるようになるという大きなメリットがあります。

クックパッドiOSアプリ(以下クックパッドアプリ)で一部の依存パッケージをXcodeのSwift Package Manager対応を使って入れるようにしました。この導入で得たいくつかの知見をまとめました。

XcodeのSwift Package Manager対応

本来のSwift Package ManagerがSwiftプロジェクトの一部です。コマンドラインでswift packageで使えます。活用するプロジェクトの構成をPackage.swiftで定義します。

ですが、今回話したいのはSwift Package ManagerのパッケージをXcodeのプロジェクトで使う時の話です。依存されているパッケージのプロジェクト構成がPackage.swiftで定義されていますが、メインのプロジェクトの構成がXcodeプロジェクト(xcworkspaceまたはxcodeproj)で定義されています。

Swift Package Managerがオープンソースであるのに対し、Xcode側の実装はオープンソースのSwift Package Managerの一部を使いながらもクローズドソースです。

プロジェクト構成の定義がPackage.swiftではないので、swift packageコマンドが使えません。Xcode内ではプロジェクト設定のPackage Dependenciesタブで依存パッケージを変更できます。

ターゲットの設定では、Build PhasesのLink Binary with Librariesに使いたいパッケージのライブラリを指定します。

また、XcodeのFile > Packagesメニューにキャッシュリセットやパッケージ更新用のコマンドがあります。

パッケージ自体はディレクトリ構造をSwift Package Managerの期待した構造に合わせると、Package.swiftが割りと作成しやすいと思いますが、もっと詳しく説明すると長くなるので今回はしません。1つだけ不自然な点を挙げると、なぜかPackage.swiftで未対応なOSを指定できないようです。platformsiOSだけを指定しても、macOSが未対応になるわけではなく、macOSに関して最低OSバージョンがデフォルトのものになるだけです。

最近までの流れ

以前からクックパッドアプリでSwift Package Managerを導入をしようとしていました。hiragramの記事で説明されているように2020年12月に試みましたが、クックパッドアプリで使える状態にまだ達していなかったため断念しました。

2021年12月リリースのXcode 13.2.1では、複雑な依存関係だと手元でのビルドに問題なかったが、AdHocやApp Store配布用のエキスポートが失敗していました。ですが、先月2022年4月にリリースされたXcode 13.3.1でXcode 13.2.1で起きていた問題が解消されて、クックパッドアプリでついに使えるようになりました。

社内ライブラリで使うには

最初に試したとき、GitHub.comなどに公開されているパッケージはXcodeのデフォルト設定でSwift Package Manager対応を利用してビルドできましたが、社内ライブラリではうまく動きませんでした。これだと導入のメリットがだいぶ限られてしまいます。

Xcodeが非公開レポジトリを取得するとき、デフォルト設定では、~/.sshに入ったシステム標準の設定が使われるのではなく、Xcode独自の仕組みが使われます。クックパッドでは、リモートで働くとき、VPNまたはSSHトンネル使う必要があります。開発者のマシンでシステムのSSHが既に設定されていますし、複雑なSSH設定はXcode独自の仕組みでできないので、XcodeがシステムのSSHの設定を使ってほしかったです。幸いなことに、このような設定があります。すべての開発者の手元で以下のコマンドを実行すればXcodeの設定を変えられます。

defaults write com.apple.dt.Xcode IDEPackageSupportUseBuiltinSCM YES

この件に関するネットでの記事を見ると、上記のコマンドの頭にsudoが入った記事もありますが、僕の試した限りでは逆にsudoが入っていると効果がありませんでした。YESの代わりに1を使っても問題ありません。

すべての開発者の手元で実行されるようにしなければいけないのは不便なので、プロジェクト作成、環境設定、依存パッケージインストールのようなスクリプトがあれば、そこでやるのがおすすめです。

# 現在の設定を確認する
# (`|| true`は`set -e`を使ってもエラーにならないため)
using_system_ssh=`defaults read com.apple.dt.Xcode IDEPackageSupportUseBuiltinSCM 2> /dev/null || true`
# まだ設定されていない場合のみ設定を変える
if [ "$using_system_ssh" != "YES" ] && [ "$using_system_ssh" != "1" ]; then
  defaults write com.apple.dt.Xcode IDEPackageSupportUseBuiltinSCM YES
fi

Package.resolved

パッケージマネージャーはユーザーが基本的にどういうパッケージのどういうバージョンを使いたいのか指定しますが、バージョンの指定は固定にできるとはいえ、メジャーバージョンが変わらなければ更新しても良いこともよくあります。

パッケージマネージャーは指定を満たすバージョンと明記されていないけど依存されているパッケージを解決します。Swift Package Managerでは、解決されたパッケージのリストとそれぞれのバージョンがPackage.resolvedというJSONファイルに入ります。全ての開発者がそれぞれのパッケージの同じバージョンを使わないとややこしいので、アプリはこのファイルをレポジトリに入れるのを強く推奨します。CocoaPodsでいうとPodfile.lock、CarthageでいうとCartfile.resolved、RubyGemsでいうとGemfile.lockと同じ役目です。

本来のSwift Package Managerでは、Package.resolvedPackage.swiftと同じディレクトリに入りますが、Xcodeではメインで使われているのがxcworkspaceかxcodeprojかによってディレクトリが少し違います。xcworkspaceの場合、MyProject.xcworkspace/xcshareddata/swiftpm/Package.resolvedですが、xcodeprojの場合、MyProject.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolvedです(もちろん「MyProject」は自分のプロジェクト名に置き換えます)。

xcworkspaceの場合、必ずxcworkspaceを開けるように気を使いましょう。xcodebuildもいつもxcworkspaceを指定するように。

Xcodeでパッケージがうまく取得できていない時

Xcodeでプロジェクトを開く時、必要であればXcodeがプロジェクトを取得しようとしますが、それが失敗する時はたまにあります。そういう時、XcodeのメニューにあるFile > Packages > Reset Package Cachesがおすすめです。

xcodebuild

Xcodeのユーザー体験はXcode自体のUIがメインですが、CIやスクリプトは基本的にxcodebuildを使います。xcodebuildとXcodeのSwift Package Manager対応絡みで気を使う必要のある点がいくつかあります。

SSH認証

Xcode自体と違って最近xcodebuildがデフォルトでシステムのSSH設定を使うようになっています。念のために明記したい場合、-scmProvider systemでできます(SCM=Source Code Management)。(逆にXcode独自の認証方法は-scmProvider xcodeで指定できます)

依存パッケージ解決

Package.resolvedがない場合、依存パッケージ解決がまず依存パッケージやバージョン指定を見てPackage.resolvedを生成してくれます。Package.resolvedの情報があれば、それに従って依存パッケージ解決が必要なパッケージバージョンを取得してくれます。

Xcodeでプロジェクトを開く時やxcodebuildでビルドをする時に必要であれば依存パッケージ解決が自動的に行われるが、xcodebuildで依存パッケージ解決だけをしたい場合以下のコマンドでできます。

xcodebuild -resolvePackageDependencies -workspace MyProject.xcworkspace -scheme MyProject

明確でありたければ-scmProvider systemを指定できますし、取得されたパッケージの保存先を-clonedSourcePackagesDirPathで指定できます。

キャッシュ

クラウドで動くCIはスピードを出すにはキャッシュをうまく活用するのが大事です。xcodebuildで依存パッケージの取得を簡単にキャッシュできます。上記に説明されたようにxcodebuildで依存パッケージ解決を実行して、-clonedSourcePackagesDirPathで指定されたディレクトリをキャッシュすれば良いです。その後xcodebuildでビルドコマンドなどを実行する際、改めて-clonedSourcePackagesDirPathで同じディレクトリを指定するのをお忘れずに。

Package.resolvedの更新

XcodeのメニューにFile > Packages > Update to Latest Package Versionsでパッケージを更新できますが、現時点でxcodebuildにこういう機能がありません。

クックパッドアプリは毎週自動的に全てのパッケージを更新してPRを出すCIジョブがあります。このジョブのためxcodebuildでパッケージを更新する方法が必要でした。

更新のコマンドがないとはいえ、少し強引ではありますが、方法がないわけではありません。Package.resolvedがなければ、依存パッケージ解決が最新パッケージを取りに行きます。試してみると、キャッシュがあれば最新のバージョンではなく以前と同じバージョンになってしまうのでxcodebuildがキャッシュを見に行かないようにする必要があります。

# Package.resolvedを消す
rm -f MyProject.xcworkspace/xcshareddata/swiftpm/Package.resolved
# 空っぽなテンポラリディレクトリを作成する
tmpcache=`mktemp -d`
# 依存パッケージ解決をやる。残っていたキャッシュが使われないために、キャッシュディレクトリに作ったばかりのテンポラリディレクトリを指定する
xcodebuild -resolvePackageDependencies -workspace MyProject.xcworkspace -scheme MyProject -scmProvider system -clonedSourcePackagesDirPath "$tmpcache"
# テンポラリディレクトリを消しておく
rm -rf "$tmpcache"
# Package.resolvedに指定を満たすパッケージの最新のバージョンが使われるようになったはず

XcodeGenを使う場合気をつけること

以前giginetが説明したようにクックパッドアプリのプロジェクト構成はXcodeGenで定義しています。

プロジェクト構成を直接xcworkspaceまたはxcodeprojで定義する場合、Xcode内で依存関係に変更を加えるとPackage.resolvedが自動的に更新されますが、XcodeGenのプロジェクト設定を変えるだけでPackage.resolvedが更新されません。依存関係に変更を加えて、XcodeGenを実行してから、Package.resolvedを更新するにはXcodeを開くかxcodebuildで依存パッケージ解決をするか、が必要がです。気をつけないとPackage.resolvedの変更が入っていないPRを出すリスクが出てしまいます。

また、最近クックパッドアプリでライセンス管理にLicensePlistを使用していますが、LicensePlistが見ているのもPackage.resolvedです。Package.resolvedが更新されていないとLicensePlistの生成したファイルもプロジェクトファイルに入った依存関係設定と一致しません。

プロジェクト生成のスクリプトではXcodeGenを実行してからLicensePlistを実行していましたが、上記の理由で、LicensePlistを実行する前にxcodebuildの依存パッケージ解決をするようにしました。パッケージ解決が既に完了している場合プロジェクト生成スクリプトの実行時間が2~3秒伸びるのは少し残念ですが、分かりにくい状態を避ける方が大事だと思います。

他のパッケージマネージャーとの絡み

クックパッドアプリはCocoaPodsとCarthage両方を使っていましたが、ライブラリが複数なパッケージマネージャーをサポートしている場合、ライブラリを導入するときはどれを使うべきか悩んでしまいます。特定なパッケージマネージャーに寄せた方が運用しやすいです。

最近Appleプラットフォーム開発界隈が依存関係をSwift Package Managerに寄せつつあるように感じるので、クックパッドアプリもできるだけSwift Package Managerに寄せることにしました。

別のパッケージマネージャーからSwift Package Managerに移行するとき、ライブラリがPackage.swiftを既に提供していると、アプリのプロジェクトに参照のやり方を変えるだけのはずですが、他の変更も必要になることがあります。

例えば、Carthageを使って作成されたxcframeworkからSwift Package Managerに移行したら、クックパッドアプリでimportを足す必要があったSwiftファイルがありました。モジュールの扱い方の違いで、Carthage版だとライブラリをimportするだけでFoundationやUIKitが暗黙的にimportされることがありました。Swift Package Manager版だとそんなことがないので、そのimportを明記する必要があります。

また、クックパッドアプリのようにアプリが複数のモジュールで構成されている場合、パッケージマネージャーを変えることで、どのモジュールがどのパッケージを参照するのか少し変える必要があることがあります。基本的にSwift Package Managerの場合、参照をもっと多くの箇所で明記する必要がありました。

パッケージマネージャーとの絡みでのハマりどころといえば、XcodeのSwift Package Manager対応とCocoaPodsを併用している場合、XcodeでターゲットのBuild PhasesのDependenciesにSwiftパッケージが明記されていると、CocoaPods実行時に例外が発生してしまいます。CocoaPodsが使っているXcodeprojライブラリのバグです。修正はしましたが、現時点でこの修正が入ったXcodeprojのバージョンがまだリリースされていません(現時点で最新のリリースが昨年8月にリリースされた1.21.0)。ワークアラウンドとして、SwiftパッケージがターゲットのBuild PhasesのLink Binary with Librariesに入っていれば、Dependenciesに入っていなくても暗黙的に依存されるのでLink Binary with Librariesから消せば良いはずです。一応Xcodeprojの特定なコミットハッシュに依存するのも選択肢の1つです。

最後に

課題点がいくつかありましたが、それを越えればXcodeのSwift Package Manager対応が割りと便利だと思います。パッケージ作成はCocoaPodsと違ってスペックをどこかにアップロードする必要ありませんし、Package.swiftでパッケージ構成定義も楽です。

クックパッドアプリでは少しずつSwift Package Managerの利用を増やそうとしています。今週のリリースでCarthageを使わなくなってSwift Package Manager + CocoaPodsの構成になりました。