動画配信アプリ「CL」でiOSエンジニアをしている下村 (@s2mr)です。 CL(シーエル)はLDH所属グループ・アーティストのライブキャスト動画やMVを視聴できるサービスです。 2020年6月にリリースしてから今年で3年目を迎えるプロダクトになり、開発期間を含めると4年目になります。 一つのアプリに配信機能も含まれているため、アプリの規模は大きいものになります。 今回の記事では、ビルド時間を改善するためにCLで試してきたことをまとめてみたいと思います。

はじめに

本記事では、私たちの開発環境と、それが抱えるビルド時間の問題についてお伝えします。 まず、以下が私たちの開発環境の概要です。

  • CIツール: CircleCI(x86_64 macOS)
  • Xcodeのバージョン: 14.2
  • 使用しているMac: MacBookPro M1Max (Ventura 13.2.1)

プロジェクトが大きくなるにつれて、CIにおけるビルド時間が徐々に長くなってきました。 それにより、ビルド時間の短縮が重要な課題となりました。

CLのiOSアプリでは、以下の方法でライブラリを導入しています。ライブラリの導入方法については、基本的にはキャッシュの再利用性を考慮し、可能な限りCarthageを利用するようにしています。以下に示すリストは、上位ほど優先して利用する手法です。

  • Carthage
  • PodBuilder
  • CocoaPods

Swift Package Manager(以下SPM)は現在主流になりつつありますが、私たちのプロジェクトはまだ全てXcodeGenで管理されています。

XcodeのUIからSPMのライブラリを追加したり、.xcodeprojからPackage.swiftを参照することは可能です。しかし、checkoutや依存関係の解決に時間がかかること、またCI上でのキャッシュ管理方法がまだ十分に確立していないと感じているため、現状ではSPMを導入していません。

プロジェクトの構成について

CLのプロジェクトは以下の主要な部分で構成されています。

  • Auth: 認証機能
  • CLFoundation: Foundationに関連するコード
  • CastComponent, CastModel, CastProto, CastRequest: 配信機能に関連するコンポーネント、モデル、プロトコル、リクエスト
  • CommunityComponent: コミュニティ機能のコンポーネント
  • CyberLDH: プロジェクトのコアロジックなどが含まれるアプリ本体
  • ImageLoader: 画像ローディング機能
  • InAppPurchase: アプリ内課金機能
  • Model: アプリケーションのデータモデル
  • UICatalog: アプリ内のUIを一覧で見るためのアプリ
  • Playground: 機能ごとにアプリを実行するためのアプリ
  • Utility: 汎用ユーティリティ
  • 各種TestDouble: それぞれのフレームワークのMock実装
  • 各種Tests: それぞれのフレームワークのテスト

重要な部分は、CyberLDH, UICatalog, Playground, 各Component, 各TestDouble, Modelになります。

CLにはAppStore公開用のアプリ以外に、UICatalog, Playgroundの二つのアプリが含まれています。

UICatalogアプリ

アプリ内で使用しているコンポーネントを一覧でみれるもので、すべてのComponentフレームワークをimportしています。

Playgroundアプリ

目的に応じたComponentのframeworkをimportして、特定の機能だけでUIを確認することができます。例えば、NavigationController経由でアクセスする深い画面などはこれを使うことで簡単にデバッグを行うことができます。現在はUIをロジックしか確認できませんが、API込みで動作できるように今後改善予定です。

各Componentフレームワーク

機能ごとに分かれており、xibやswiftでのUI定義の実装が含まれています。 アプリを作る上で一番肥大化しやすいのがUIの部分なので、施策や機能ごとに分割しています。これにより、差分ビルドにかかる時間を削減することができます。

このように細かくフレームワークを分割しておくことで、ビルド時間の短縮をしたり、ウィジェット機能を追加するときにも少ない変更で対応できフレキシブルな設計になります。

Carthageとバイナリの活用

私たちの開発チームでは、CI上でキャッシュを使用できるように、ライブラリのビルド成果物をxcframeworkとして扱うようにしています。ライブラリがCarthageをサポートしている場合(リポジトリのルートに.xcodeprojファイルがある場合)、私たちはCarthageを用いてライブラリの導入を行っています。

しかし、ベンダーから提供される多くのライブラリはバイナリ形式でのみ配布されています。そのような場合、私たちはjsonファイルを作成し、Cartfileにbinaryとしてライブラリを記述します。Firebase、GoogleCast、GoogleTagManager、GoogleAnalyticsなどがその例として挙げられます。

本セクションでは、GoogleCastを例に、ライブラリの導入手順を説明します。

GoogleCastのxcframeworkは、公式サイトからダウンロードできます。ここでは、「ゲストモードなしのダイナミック XCFramework」を導入してみます。公式サイトに従って進めると、xcframeworkをダウンロードするためのURLは以下のとおりです。

https://dl.google.com/dl/chromecast/sdk/ios/GoogleCastSDK-ios-no-bluetooth-4.7.1_dynamic_beta.xcframework.zip

Carthageを用いれば、バイナリのみのフレームワークを導入することが可能です。Carthageの公式ドキュメントに従って、以下の手順を踏みます。

まず、次のようなjsonファイルを、リポジトリ内のCarthageModule/GoogleCast.jsonというパスに保存します。

{
  "4.7.1": "https://dl.google.com/dl/chromecast/sdk/ios/GoogleCastSDK-ios-no-bluetooth-4.7.1_dynamic_beta.xcframework.zip"
}

その後、Cartfileでは次のようにこのjsonファイルを参照します。

binary "CarthageModule/GoogleCast.json" ~> 4.7

こうすることで、普段通りのコマンドを使ってライブラリをインストールすることが可能となります。

carthage update --cache-builds --use-xcframeworks --platform ios

Firebaseのバイナリ活用

CLではFirebaseの導入にCocoaPodsを使用してきましたが、ソースコードからのビルドには時間がかかるという問題がありました。具体的には、CI上でのビルド(x86_64アーキテクチャ)が3分程度かかるという状況でした。

このビルド時間を節約するため、私たちはCarthageを用いた導入を検討しました。Firebaseは公式でCarthageのサポートを行っていますが、これはまだ試験的なもので、不安定な動作が報告されています。FirebaseのCarthageサポートについてはこちらのリンクをご覧ください。

私たちもCarthageを通じてFirebaseの導入を試みましたが、以下のようなエラーが発生し、インストールが不可能でした。

Failed to write to /.../Carthage/Build/FirebaseRemoteConfig.xcframework: Error Domain=NSCocoaErrorDomain Code=513 ""FirebaseRemoteConfig.xcframework" couldn't be removed because you don't have permission to access it."
Failed to write to /.../Carthage/Build/iOS/Protobuf.framework: Error Domain=NSCocoaErrorDomain Code=513 "“Protobuf.framework” couldn’t be removed because you don’t have permission to access it." UserInfo={NSFilePath=/Users/me/app/Carthage/Build/iOS/Protobuf.framework, NSUserStringVariant=(
    Remove
), NSUnderlyingError=0x7f999f55a270 {Error Domain=NSPOSIXErrorDomain Code=66 "Directory not empty"}}

このissueに記されているように、これはFirebaseライブラリをバイナリ形式で導入する際に、共通の依存ライブラリをコピーするときに競合状態が発生しているようです。

解決が難しかったためこのアプローチは放棄し、Firebaseの各モジュールを個別にダウンロード可能なように、Cartfileに以下のように記述しました。

binary "CarthageModule/Generated/FirebaseCore.json" ~> 10.10.0
binary "CarthageModule/Generated/FirebaseCoreInternal.json" ~> 10.10.0
binary "CarthageModule/Generated/FirebaseAuth.json" ~> 10.10.0
binary "CarthageModule/Generated/FirebaseAnalytics.json" ~> 10.10.0
binary "CarthageModule/Generated/FirebaseCrashlytics.json" ~> 10.10.0
...

上記に加え、CLではGoogleTagManagerやGoogleAnalyticsも使用しているため、以下の記述も追加しています。

binary "CarthageModule/GoogleTagManager.json" ~> 7.4
binary "CarthageModule/GoogleAnalytics.json" ~> 7.4

各jsonは以下のような形式になっています。

{
  "10.10.0": "https://github.com/s2mr/firebase-ios-sdk-xcframeworks/releases/download/10.10.0/FirebaseCore.xcframework.zip"
}

ただ、jsonの量が多く、手動での入力は大変なため、これを自動で生成できるようにスクリプトを作成しています。xcframeworkを自分のリポジトリでホスティングしていますが、これはこちらでSPMのbinaryTargetとして使用するために同様の手法を用いている方からインスピレーションを得ています。

ただし、安全性を考慮して、非公式のリポジトリから配布されるものではなく、自分でホスティングしています。自分でホスティングするためには、公式で提供されている全xcframeworkが一つのzipファイルに含まれているのを一つ一つのzipに変換し、リリースに載せるだけです。この方法により、Firebaseの各モジュールを個別にダウンロードでき、それぞれはビルド済みのバイナリなので、ビルド時間を大幅に短縮することができます。

PodBuilderを使用したライブラリ導入

CocoaPodsで導入が必要なライブラリは、そのままCocoaPodsを用いています。しかし、ビルドに時間がかかるライブラリについては、PodBuilderを用いて導入を行っています。

具体的には、grpc/grpc-swift や、apple/swift-protobuf といったライブラリでPodBuilderを使用しています。理想的には、これらのライブラリもxcframework形式でビルドできれば良いのですが、grpc-swiftが依存している apple/swift-nio がxcframework形式でビルドできないため、通常のframework形式でビルドしています。swift-nioをxcframework形式でビルドできない問題については、こちらのissueに報告されています。

PodBuilderを使用することで、ビルド時間が長いライブラリでも事前にビルドしたバイナリを導入することができ、ビルド時間の短縮につながります。

Fastlaneのオプション変更

CLではFastlaneのscanアクションを使用してテストを実行しています。CircleCIで実行しているUnitTestのビルドログを調査していると、同じファイルを2度ビルドしているという現象を発見しました。詳しく調べてみたところ、scanを利用している別のユーザーも同様の問題に遭遇していたことがわかりました。

scanアクションを普通に実行すると xcodebuild build test ~~~が実行されます。 xcodebuildコマンドはサブコマンドとして複数のactionを引数に取ることができますが、この test アクションにはソースコードのビルドが含まれています。 通常では、xcodebuild build testでもソースコードが2度ビルドされることは起きないのですが(ローカルでのテストでは1度のみのビルドでした)、何らかの理由で環境によっては2度ビルドされてしまうようです。

そのため、scanアクションにはbuildアクションをスキップするための skip_buildというオプションが実装されています。 これを使用することで、CLではユニットテストにおけるビルドにかかる時間が約半分になりました。

最後に

最後までお読みいただきありがとうございました。 CLのiOSチームでは、ビルド基盤の改善から施策の実装まで幅広く行える環境が整っています。 新しい技術の導入も積極的に検討しています。 この記事を読んで興味を持った方がいらっしゃいましたら、お気軽にお声がけください!