ニコニコ動画 iOS アプリでの Xcode Cloud の活用


はじめに

こんにちは。ニコニコ開発部で ニコニコ動画 iOS アプリ を開発している兎澤です。
ニコニコ動画 iOS アプリでは2023年末より Jenkins から Xcode Cloud への移行を開始し、現在は完了しています。今回は、この移行をどのように進めていったのか?移行した結果どうだったか?について紹介します。

iOSDC 2024 のチャレンジトークンが記事内に 1つ 含まれています。ぜひ探してみてください! (※ 記事の見出しの横についている「#」はチャレンジトークンではありません。チャレンジトークンは文中に配置されています。)

課題

ニコニコ動画 iOS アプリの従来の CI/CD サービスは以下のような状況でした。

  • Jenkins を利用
    • 社内クラウド上のマシンを Controller とする
    • 社内ネットワークに接続された複数台の Mac mini を Agent とする
  • 社内ネットワークからのみアクセス可能
  • ボカコレ iOS アプリチーム と共有

しかし、この環境には以下のような課題がありました。

  • Mac mini の運用保守にコストがかかる
    • macOS/Jenkins/Xcode の定期的な更新が必要
    • キャッシュ、Keychain 周りでの不定期な問題の発生
    • 作業の属人化
  • 並列数に限度がある
    • マシンあたり単一ジョブのみを動作させていたため、管理している Mac mini の台数 (3台) が並列数の限界だった
    • ボカコレ iOS アプリチームと共有していたので、一方のチームで占有してしまうと他方のチームに影響があった

さらに、組織の方針により社内クラウドから別環境へ移行する必要が出てきました。そこで、上記のような課題もあったため、これを機に別の CI/CD サービスへの全面的な移行を検討することになりました。

CI/CD サービスの選定

CI/CD サービスを選定するために、簡単な試算を行いました。Jenkins API で1日あたりの Jenkins ジョブの実行時間を取得する cron を設定し1〜2月程収集したところ、以下のような結果となりました。

  • 中央値は232時間/月
  • ワーストケースで440時間/月
    • 最も実行時間が長かった日をベースに計算

Mac mini が M1 であるのに対し、Xcode Cloud は 2024/07 現在 Intel 版が使用されているようです。当時手元にあった Intel 版 Mac Pro でビルドしたところ、M1 Mac mini に対しておおよそ 70% の性能だったので、70% 程度の性能ベースで計算すると、中央値は332時間/月、ワーストケースで 629時間/月となります。予測が正しかった場合、Xcode Cloud のプランであれば 1000時間/月のプランが妥当そうです。

CI/CD サービスの候補としては以下を考えました。

  • Codemagic
  • Xcode Cloud
  • GitHub Actions
  • Bitrise

各社で公開されているプランをベースに、性能をなるだけ揃えて単位時間あたりの金額を計算すると、Codemagic と Xcode Cloud が同程度の安さでした。GitHub Actions および Bitrise は性能が良いのもありますが、例えば1000時間あたりの値段で比較すると桁が1つ違いそうです。
最終的には、以下のような理由から Xcode Cloud の1000時間/月プランを第一候補として考えることになりました。

  • 他と比べてコストが安い
  • Xcode と統合されており、扱いやすそうだった
  • App Store Connect との統合により、審査提出がしやすくなりそうだった
  • Apple 公式が出しているものなので、チーム内にトライしたいというモチベーションがあった

Xcode Cloud について

概要

Xcode Cloud は、Apple プラットフォームのアプリ開発者のための CI/CD サービスです。Apple が既に提供している Xcode、TestFlight、App Store Connect 等のツール、 サービスと統合されているのが特徴で、SaaS でありながら各開発者ローカルの Xcode 上で閲覧/操作が行えます。一方で App Store Connect 上でも Xcode 上と同等の操作が行えるようになっています。

ワークフローとその閲覧方法

Xcode Cloud は他の CI/CD サービスと同様に、何らかの契機 (開始条件) によりソースコードのビルド&デプロイフローを走らせます。このフローはワークフロー[1][2]と呼ばれます。
Xcode プロジェクト/ワークスペースを開いた後に Report ナビゲーターの Cloud タブに移行すると、Xcode はプロジェクト/ワークスペースを解析し、解決できた Product の一覧を表示してくれます[3]
この時解決される Product は、そのプロジェクト/ワークスペース内に存在し、かつ Xcode 上でログイン中の Apple ID で閲覧可能なものになるようです。例えば、ニコニコ動画 iOS アプリは複数の Swift Package に依存していますが、プロジェクトを開くとニコニコ動画アプリの Product だけでなく、依存する Swift Package に対応する Framework 群のワークフローも閲覧できるようになりました。

Xcode上のプロダクト一覧

また、複数の Apple ID で Xcode にログインしていた場合でも、各 Apple ID に紐づく Product がプロジェクト内に存在すれば、それらも全て閲覧できるようでした。

プランの選択

2024/07 現在、Xcode Cloud には以下のプランがあります。

  • 25コンピューティング時間/月: 無料
  • 100コンピューティング時間/月: 49.99USD/月
  • 250コンピューティング時間/月: 99.99USD/月
  • 1000コンピューティング時間/月: 399.99USD/月

無料プラン[4]があるので、まずは無料で試してみるのが良さそうです。ニコニコ動画 iOS アプリチームでも、まずは無料プランで動作の検証を行いました。
プランの選択は Apple Developer アプリから行えます。公式ドキュメントにある通り、まずは適当な Xcode Cloud ワークフローを作成しておく必要があります。

サブスクリプションの管理 ワークフローを設定すると、Apple Developer ProgramメンバーシップのAccount Holderは、iPhoneおよびiPadのApple Developerアプリの「アカウント」タブでサブスクリプションに登録し、追加のコンピューティング時間を利用できるようになります。サブスクリプションプランの価格は米ドル、または現地通貨(利用可能な場合)で設定されています。

https://developer.apple.com/jp/xcode-cloud/get-started/

Developer アプリでの Xcode Cloud プランの選択

移行の準備

Xcode Cloud ワークフローの作成前に行ったことについて紹介します。

Swift Package に移行する

Xcode Cloud における問題の一つはキャッシュです。
ニコニコ動画 iOS アプリは数多くのライブラリに依存しており、CI 実行の度にこれらをインストールすると時間がかかってしまいます。CI 実行時間削減のためにもキャッシュ機能が必要です。

デフォルトでは DerivedData がキャッシュされ[5]、その中に存在する Swift Package も同様にキャッシュされますが、CocoaPods/Carthage 等でインストールされたライブラリは基本的にキャッシュされません。

ニコニコ動画 iOS アプリでは、将来的な Xcode Cloud 移行も見据えて、既にほぼ Swift Package 移行を完了した後でした。しかし一部の広告SDKが Swift Package をサポートしていなかったため、CocoaPods と Swift Package を併用する形となっています。
それでもほとんどのライブラリは Swift Package に移行済みであったため、Xcode Cloud のキャッシュの恩恵を受けることができそうでした。

必要な Xcode Cloud ワークフローについて整理する

Xcode Cloud 移行の前に、従来の Jenkins ジョブを Xcode Cloud ワークフローで置き換えられるか検討します。従来の Jenkins ジョブには以下のような種類がありました。

  • ユニットテストの実行
  • DeployGate への InHouse/AdHoc/Development ビルドのアップロード
  • fastlane deliver によるストアページの作成と TestFlight アップロード
  • fastlane pilot による TestFlight アップロード

社内ライブラリの多くはユニットテストの実行のみで、ニコニコ動画 iOS アプリのリポジトリでは DeployGate や TestFlight でのアップロードも行っていました。

ユニットテストの実行については、適切なスキームと事前準備のための Custom Build Script (後述) を書けば問題なく行えそうです。TestFlight アップロードも Xcode Cloud だとスムーズに行えます。一方で、DeployGateについてはどうすべきかの検討が必要でした。

DeployGate は開発者以外の企画/デザイナ/品証チームの方々とのアプリの共有のために使っています。そのまま利用しても良いですが、Xcode Cloud では TestFlight へのアップロードが少ない手間で行えるので、これを機に DeployGate の利用を TestFlightの内部テストへ置き換えられないか調査しました。 双方の違いをまとめると、以下になります。

TestFlight(内部テスト) DeployGate
制限
  • メンバーに Apple ID が必要
  • 最新アプリバージョンでないとアップロードできない
  • プロファイルのインストールが不要
  • メンバーに Apple ID が不要
  • アプリバージョンに関わらずアップロードできる
  • プロファイルのインストールが必要
ビルドの識別
  • ビルド番号
  • テストノート
  • ビルドグループ
  • 配布ページ
  • リリースノート
メンバー数
  • 最大100人 (アプリ毎)
  • 一人あたり30デバイス
  • 無制限 (InHouse配布)
過去バージョンの保存
  • 最大60日間
  • アプリ毎に500件

チーム内で相談したところ、メンバー毎の Apple ID の発行やメンバー数、メンバー毎の台数や過去バージョンの保存期間等の制約は、大きな問題にならないことがわかりました。
運用上問題になりそうだったのはビルドの識別です。Jenkins の場合はビルドパラメータとしてリリースノートを指定して、配布ページ上でどの案件向けのアプリが配布されているか?を利用者にわかるようにしていました。Xcode Cloud の場合はワークフロー実行時にビルドパラメータのような任意の引数は取れません。しかし、テスター向けの情報をテストノートに記載することができます[6]。事前に指定のファイルをリポジトリ内に配置しておく必要があるのですが、Custom Build Script で動的に作成することもできそうです。

あるいは、ビルドグループも活用できます。Xcode Cloud ワークフローとその実行時の Start Condition によって、TestFlight 上でビルドグループという単位でビルドがグルーピングされます。「特定の案件用のビルドは特定のブランチからビルドする」などの運用ルールにしておけば、ブランチ名を識別子として利用者にビルドを識別してもらうことができそうです。
以下は、TestFlight アプリのビルドグループ一覧です。タグとワークフロー名で分類されていることがわかります。

TestFlightアプリで見るビルドグループの一覧

チーム内で相談した結果、DeployGate に関しては以下のような結論になりました。

  • DeployGate も Xcode Cloud 経由でアップロードできるようにする
    • 関係者が多く、一気に運用を変更するのが難しそうであったため
  • TestFlight 内部テスターの運用は並行して試していく
    • 少ない利用者から始めて、徐々に適用範囲を広げていく
    • 問題がありそうなら DeployGate の運用に戻す
    • 問題なさそうなら TestFlight 内部テスターに移行していく

ビルドの識別については、TestFlight 内部テスト用のブランチを案件毎に各自で切って、そこにマージ後にアップロードを行うルールとしました。

以上を踏まえると、最終的に必要な Xcode Cloud ワークフローの一覧は下記となります。

ワークフロー名 開始条件 概要
Unit Test 任意のPRの更新 ユニットテストを実行する
DeployGate Development-XXX upload ブランチ指定 (手動) DeployGate に Development ビルドをアップロードする
XXX は配布ページの識別子であり、配布ページ毎にワークフローを用意する
DeployGate AdHoc-XXX upload ブランチ指定 (手動) DeployGate に AdHoc ビルドをアップロードする
XXX は配布ページの識別子であり、配布ページ毎にワークフローを用意する
TestFlight upload for Internal Testing ブランチ指定 (手動) 内部テスト向けのアーカイブと内部テスト用の TestFlight アップロードを行う
TestFlight upload for Release タグ指定 (手動) ストア向けのアーカイブと内部テスト用の TestFlight アップロードを行う
ストアページ作成後、ビルドのみアップロードしたい場合に利用する
Release タグ指定 (手動) ストア向けのアーカイブと内部テスト用の TestFlight アップロード、ストアページの作成を行う

基本的な社内ライブラリでは Unit Test のみ必要で、ニコニコ動画 iOS アプリの場合は全てのワークフローが必要です。

DeployGate には複数の配布ページが存在し、Jenkins ジョブではジョブのビルドパラメータから対象ページを切り替えていました。しかし、Xcode Cloud ワークフローの実行時にはそのような任意のパラメータを指定することはできないので、仕方なく配布ページ毎にワークフローを作成します。また、Apple Developer Enterprise Program には Xcode Cloud が存在しないので、InHouse ビルドはそのままだと行えません。無理やり実行することもできますが、今回は割愛します。

一部ワークフローについては、手動ではなくブランチやタグの push を開始条件にしても良さそうですが、一旦手動で運用して様子を見ています。

Swift Package に対して Xcode Cloud を利用できるか調査する

Xcode Cloud ワークフローの実行対象となる Product には、App と Framework の2種類があります。以下は、複数のローカルの Swift Package を含んだニコニコ動画 iOS アプリにおいて、 Xcode Cluod ワークフロー作成を開始した時の画面です。

Xcode Cloud ワークフローのプロダクト選択画面

ここで App を選択すると、作成した Xcode Cloud ワークフローは App Store Connect 上に存在するアプリ定義と紐付きます。Framework を選択すると、まだ存在しなかった場合、App Store Connect 上に Framework が作成されます。これは現状 Xcode Cloud 関連操作のためだけの項目であるようです。

App Store Connect 上の Framework 一覧表示

iOS アプリの場合は App、アプリケーションではない社内ライブラリであれば Framework を選択すると良さそうです。ニコニコ動画 iOS アプリは複数の社内ライブラリに依存しており、それら社内ライブラリも Xcode Cloud に移行する必要があったため、それらは Framework として追加しました。

ただし、Xcode Cloud ワークフローの追加のためには Xcode プロジェクトもしくはワークスペースが必要です。例えば、Package.swift のみが配置されている Swift Package リポジトリの場合、そのままだと Xcode Cloud は利用できません。Apple のドキュメントでも以下のように言及されています。

You use a consistent Xcode project or workspace. https://developer.apple.com/documentation/xcode/requirements-for-using-xcode-cloud

これを解決するために、Swift Package を配布しているリポジトリでは、以下のように workspace を追加する必要があります。

  1. {任意の名前}.xcworkspace を追加する
  2. {任意の名前}.xcworkspace にローカルの Swift Package を追加する
    • ワークスペースを開いた後に、Swift Package 配置先のディレクトリをドラッグ&ドロップする
  3. ローカルの Swift Package のテスト実行用スキームを追加する

手順 2 では、{任意の名前}.xcworkspace/contents.xcworkspacedata が以下のようになっていれば良いです。

<?xml version="1.0" encoding="UTF-8"?>
<Workspace
    version = "1.0">
    <FileRef
       location = "group:{ローカルのSwiftPackageへのパス}">
    </FileRef>
</Workspace>

手順 3 のテスト実行用スキームの追加をしないと、Xcode が Swift Package を Framework として認識しません。ここで作成したスキームは、Xcode Cloud ワークフローでのテスト実行にも利用します。

Custom Build Script の作成

ニコニコ動画 iOS アプリでは複数のワークフローを実行します。ワークフローの作成手順自体は特筆すべきものはないので割愛して、今回はワークフロー毎に必要な Custom Build Script [7] 設定を紹介します。
Custom Build Script は Xcode Cloud ワークフローの特定タイミングで実行することができるスクリプトです。標準出力/標準エラー出力は Build Report のログに出力され、exit code も反映されます。実行タイミングには以下の種類があります。

ファイル名 タイミング 利用例
ci_post_clone.sh Git リポジトリを clone した後 必要に応じて Info.plist を更新する
ビルドに必要なツールをインストールする
ci_pre_xcodebuild.sh xcodebuild コマンドを実行する前 追加の依存がある場合にそのビルドを行う
ci_post_xcodebuild.sh xcodebuild コマンドを実行した後 外部サービスのストレージに成果物をアップロードする

CocoaPods による依存のインストール

CocoaPods による依存のダウンロードは全てのワークフローで必要です。これを Xcode Cloud 上で行う場合、いくつかの選択肢があります[8][9]

  • Homebrew を利用する
  • Bundler を利用する
  • RubyGems を利用する

元々開発チームでは Bundler を利用して fastlane や CocoaPods をインストールしていたため、Bundler を利用しようと考えていました。しかし、Xcode Cloud 上の Ruby のバージョンが古いために一部ツールのインストールがうまくいかず、最終的には Homebrew を利用することになりました。rbenv 等で Ruby の環境を整えることから始めると時間がかかってしまうためです。
実行タイミングは ci_post_clone.sh であり、以下のように記述します。ニコニコ動画 iOS アプリではプロジェクトルートに Podfile が存在するので、まずプロジェクトルートに移動しています。

# ci_post_clone

cd ${CI_PRIMARY_REPOSITORY_PATH}

brew install cocoapods
pod install

ニコニコ動画 iOS アプリでは、クラッシュ情報の収集に Firebase Crashlytics を利用しています。よってアーカイブを実施するワークフローでは dSYM アップロードが必要です。従来は fastlane の upload_symbols_to_crashlytics アクション を利用していましたが、Xcode Cloud 移行によって fastlane の責務も減ったので、なるべく依存を少なくしていきたいと考えました。
実行タイミングはアーカイブ後なので、ci_post_xcode_build.sh に以下のように記述します。

# ci_post_xcodebuild

if [[ -d "${CI_ARCHIVE_PATH}/dSYMs" ]]; then
    echo "🔄 FirebaseにdSYMをアップロードします"
    ${CI_DERIVED_DATA_PATH}/SourcePackages/checkouts/firebase-ios-sdk/Crashlytics/upload-symbols \
        -gsp ${CI_PRIMARY_REPOSITORY_PATH}/NicoNico/Resources/GoogleService-Info.plist \
        -p ios ${CI_ARCHIVE_PATH}/dSYMs
    echo "✅ FirebaseにdSYMをアップロードしました"
fi

GoogleService-Info.plist は、Buid Configuration やバンドル ID に合わせた適切なものを Custom Build Phase でコピーしています。これは Xcode Cloud 移行前からプロジェクトにあった設定です。

ストアページの作成

Release ワークフローの実行時には、fastlane によるストアページの作成を行いたいです。TestFlight アップロードは Xcode Cloud 側で行えますが、fastlane 実行については Custom Build Script の記述が必要です。
まず、Fastfile 側のレーン定義です。こちらは app_store_connect_api_keydeliver のみのシンプルな定義になります。

# Fastfile

platform :ios do
  desc "次バージョンのストアページを作成する"
  lane :store_page do |options|
    app_store_connect_api_key
    deliver
  end
end

次に、app_store_connect_api_key の実行のために Xcode Cloud ワークフローに環境変数を設定します。この時のポイントは、APP_STORE_CONNECT_API_KEY_IS_KEY_CONTENT_BASE64 を true に設定することです[10]APP_STORE_CONNECT_API_KEY_KEY に設定する p8 ファイルの内容には改行が含まれますが、これを環境変数に設定すると改行が失われてしまいます。また、app_store_connect_api_key はデフォルトだと改行を含む p8 ファイルの内容を期待しているので、APP_STORE_CONNECT_API_KEY_IS_KEY_CONTENT_BASE64 を true に設定した上で、base64 コマンドによってエンコードした文字列を APP_STORE_CONNECT_API_KEY_KEY に設定する必要があります。エンコードのためにはターミナル上で cat key.p8 | base64 を走らせます。

ストアページ作成用のワークフローのEnvironments管理画面

以上で fastlane 実行のための準備が整ったので、以下のように ci_post_xcodebuild.sh を編集します。CocoaPods と同様の理由から、fastlane も Homebrew 経由でインストールすることにしています。

# ci_post_xcodebuild

if [[ $CI_WORKFLOW = "Release" ]]; then
    echo "🔄 fastlaneをインストールします"
    brew install fastlane
    echo "✅ fastlaneをインストールしました"

    echo "🔄 審査出しページを作成します"
    fastlane ios store_page
    echo "✅ 審査出しページを作成しました"
fi

DeployGate による配信

DeployGate による配信のために、以下を実施します。

タイミング 作業
ci_post_clone.sh (必要であれば) アーカイブ時の Build Configuration を切り替える
ci_post_clone.sh アプリ表示名を変更する
ci_post_xcodebuild.sh DeployGate へアップロードする

まず、ci_post_clone.sh の設定は以下のようになります。

# ci_post_clone

if [[ $CI_WORKFLOW == "DeployGate "* ]]; then
    WORDS=($CI_WORKFLOW)
    TARGET=${WORDS[1]}

    #
    # Build Configuration の書き換え
    #
    SCHEME_PATH="NicoMediaClient.xcodeproj/xcshareddata/xcschemes/NicoMediaClient.xcscheme"
    if [[ $TARGET == "Development"* ]]; then
        /usr/bin/xmllint --shell "${SCHEME_PATH}" <<EOF
cd //ArchiveAction/@buildConfiguration
set DebugArchive
save
EOF
    fi

    #
    # アプリ表示名の書き換え
    #
    INFO_PLIST="${CI_PRIMARY_REPOSITORY_PATH}/NicoNico/Info.plist"
    /usr/libexec/PlistBuddy -c "Set :CFBundleDisplayName ${TARGET} ニコニコ動画" "${INFO_PLIST}"
fi   

ワークフロー名が「DeployGate 」から開始する場合に必要な作業を実施します。ニコニコ動画 iOS アプリのデフォルトのスキームだと、アーカイブ時の Build Configuration は Release になっています。しかし、開発中の動作確認では DEBUG フラグをオンにした状態でアーカイブしたいです。とはいえ、このためだけに専用のスキームを用意するのも大げさなので、動的に Build Configuration を DebugArchive という専用の Build Configuration に切り替えています。アプリ表示名の切り替えは、Info.plist を PlistBuddy で書き換えることで実現します。

ci_post_xcodebuild.sh の設定は以下のようになります。

# ci_post_xcodebuild

cd ${CI_PRIMARY_REPOSITORY_PATH}

if [[ $CI_WORKFLOW == "DeployGate "* ]]; then
    WORDS=($CI_WORKFLOW)
    TARGET=${WORDS[1]}

    if [[ $TARGET == "AdHoc"* ]]; then
        FILE_PATH="${CI_AD_HOC_SIGNED_APP_PATH}/${CI_PRODUCT}.ipa"
    elif [[ $TARGET == "Development"* ]]; then
        FILE_PATH="${CI_DEVELOPMENT_SIGNED_APP_PATH}/${CI_PRODUCT}.ipa"
    else
        echo "🛑 予期しないターゲット(${TARGET})が指定されました"
        exit 1
    fi

    if [[ ! -f $FILE_PATH ]]; then
        echo "🛑 ipaファイル ${FILE_PATH} が存在しませんでした"
        exit 1
    fi

    echo "🔄 DeployGate ${TARGET} にアップロードします"

    # https://docs.deploygate.com/docs/api/application/upload/
    curl \
        --url "https://deploygate.com/api/users/${DEPLOYGATE_USER}/apps" \
        -H "Authorization: Bearer ${DEPLOYGATE_API_TOKEN}" \
        -X POST \
        -F "file=@${FILE_PATH}" \
        --form-string "message=${CI_BRANCH}" \
        --form-string "distribution_key=${DEPLOYGATE_DISTRIBUTION_KEY}" \
        --form-string "release_note=${CI_BRANCH}"

    echo "✅ DeployGate にアップロードしました"
fi

Xcode Cloud でアーカイブを実行すると、各配布設定でコード署名されたアーカイブが専用のパスに出力されます[11]。それを振り分けた上で、DeployGate の API を叩けば良さそうです。Jenkins ではリリースノートはビルドパラメータとして受け取った任意の文字列だったのですが、Xcode Cloud にはビルドパラメータのような仕組みはないので、ブランチ名を代わりに設定します。
Xcode Cloud ワークフローの環境変数には、以下をそれぞれ設定します。

環境変数名
DEPLOYGATE_USER DeployGate ユーザー名
DEPLOYGATE_API_TOKEN DeployGate の API トークン
DEPLOYGATE_DISTRIBUTION_KEY 配布ページの Distribution Key

移行後の問題とその対応策

移行時や移行後にハマった問題と、その解決策を紹介します。

一部 Pod のインストールに失敗する問題

CocoaPods 経由でとあるライブラリをダウンロードしてくる際に、ほぼ確実に以下のような curl のエラーでダウンロードに失敗してしまうようになりました。

curl: (35) Recv failure: Connection reset by peer

色々と調査してみたものの原因を割り出すことができず、結局対象の Pod を Git 管理の対象に追加することで解決しました。 ちなみにこのエラーは、以下のような状況でも稀に発生することが確認されています。

  • 一部環境のために Java が必要なので、Xcode Cloud 上で Java をインストールする
  • Xcode Cloud 上で DeployGate へバイナリをアップロードする

幸い頻度はそこまで多くはないですが、不定期に CI が失敗するような状況は避けたいです。こちらで報告されているのと似たような状況かとは思われますが、有効な解決策はなさそうでした。

この問題については、現在Apple に問い合わせ中です。

テスト実行時間が増加してしまう問題

ニコニコ動画 iOS アプリには 15,000 件超のテストがあります。このように大量のテストがあるリポジトリでテストアクションを走らせると、謎の処理時間 (スタックする時間) が発生してしまうことがわかりました。

試しにとあるビルド結果を覗いてみます。 Xcode Cloud のログで Run xcodebuild test-without-building 時の時間を確認すると、23分ほどかかっています。ユニットテストの実行に 23 分というのはかなりかかっているように思えます。

ニコニコ動画iOSアプリのテスト実行時間

しかし、成果物から test-without-building 時のログを取得し、xcodebuild-test-without-building.log の内容を確認すると、開始から終了までの間は 4 分ほどしかありません。

2024-07-04T05:10:40.068483841Z	Command line invocation:
...
2024-07-04T05:14:30.824263538Z	Test session results, code coverage, and logs:
2024-07-04T05:14:30.824309186Z		/Volumes/workspace/resultbundle.xcresult
2024-07-04T05:14:30.824359593Z	
2024-07-04T05:14:30.824427909Z	** TEST EXECUTE SUCCEEDED **
2024-07-04T05:14:30.824470677Z	
2024-07-04T05:14:35.869509216Z	Testing started

別途 XCResult を取得し確認しても、同様に 4 分ほどしかかかっていませんでした。

ニコニコ動画iOSアプリのテストのXCResultファイル

一方で、Logs for Update Result Bundle with Metadata Command-stdout.log を確認すると以下のようになっており、この標準出力までの間に約20分が経過していることがわかります。

2024-07-04T05:34:09.853383922Z	Added external location with identifier "Xcode Cloud" to /Volumes/workspace/resultbundle.xcresult/Info.plist.

ログに現れていないこの時間を改善すべく、以下のような対応を試しましたが、いずれも効果はありませんでした。

  • Parallel Testing を有効にする
  • Collect Test Diagnostics on Failure をオフにする
  • OS_ACTIVITY_MODE = disable にする
  • fatalError の throw のテストをしている箇所の無効化

このスタックは Xcode Cloud に移行した各リポジトリで大なり小なり発生しているようでした。その後、各リポジトリにおいて、テストケース数とスタックしている時間の相関を調べたところ、おおよそ 0.06-0.09秒/テストケース分のオーバーヘッドが発生していると考えると辻褄が合いそうなことがわかりました。この予測が仮に正しかった場合、1ケースあたり0.06秒だとしても、15000件あれば 900秒=15分 となり、日々の CI に関わる時間と考えると決して少なくありません。
多くのリポジトリではそこまで問題にならないのですが、ニコニコ動画 iOS アプリではテストケース数が今も増え続けているのもあり、開発生産性に悪影響を及ぼします。実は、移行前にこの問題があることはわかっており、ユニットテストの実行については代替案として GitHub Actions を用意しつつ、開発に支障があればユニットテストの実行のみそちらに切り替えることを考えていました。

結果として、Xcode Cloud はそれなりの数並列実行することが可能なので、平時の開発業務ではそこまで問題になりませんでした。しかし急ぎのリリース作業時にはどうしてもボトルネックになってしまいます。

よって、GitHub Actions への切り替えも視野に入れつつ、何か改善する術がないか Apple に問い合わせており、現在はその返答を待っている状態です。

まとめ

良かったところ/悪かったところ

ニコニコ動画 iOS アプリ開発チームでは、既に #XcodeCloudへ移行 してから数ヶ月が経過しています。現時点での良かったところと悪かったところをまとめます。

良かったところ

  • fastlane match による証明書の管理が減った
  • fastlane の責務が減った
  • 最新の Xcode がすぐ使える
    • 最近で言うと、Xcode 16 beta が公開されてすぐに使えるようになっていました
  • 自動でビルドワーニングを Check notice として GitHub 上で報告してくれる
  • 手元の Xcode で CI 結果をすぐに確認できるのは便利

悪かったところ

  • 一部プロジェクトでテスト実行が非常に遅くなった
    • Apple に問い合わせ中
  • 通信が安定しないことがある
    • Apple に問い合わせ中
  • App Store Connect がたまに不安定
    • ページの表示が重かったり
    • Framework が閲覧できなくなったり
    • 一部ページが閲覧できなくなったり
    • Xcode Cloud の bug fix については、リリースノート を確認すると良い

今後の展望

まだまだ不安定な部分もありますが、一部テスト実行が遅くなってしまった点以外については概ね満足しています。チーム管理の Mac mini の保守も必要なくなり、運用が楽になりました。

移行自体は完了したので、今後は DeployGate から TestFlight 内部テストへの運用の切り替えを試していきます。また、ワークフローの開始条件が手動になっている箇所は、様子を見てルールを定め、できるだけ作業を自動化できるように改善していきます。

ドワンゴでは、ニコニコ動画を一緒に作ってくれる仲間を募集しています!iOSやAndroidのネイティブアプリ開発が好きな方、ドワンゴに興味がある、または応募しようか迷っている方がいれば、気軽に応募してみてください。


[1]: Configuring your first Xcode Cloud workflow - Apple Developer
[2]: Xcode Cloud Workflow Reference - Apple Developer
[3]: Configuring your first Xcode Cloud workflow - Apple Developer
[4]: Xcode Cloudの利用開始
[5]: Xcode Cloud workflow reference - Apple Developer
[6]: Including notes for testers with a beta release of your app - Apple Developer
[7]: Writing Custom Build Scripts - Apple Developer
[8]: Xcode Cloud – overview & setup - Wojciech Kulik
[9]: Making dependencies available to Xcode Cloud - Apple Developer
[10]: iOS: using App Store Connect API with fastlane
[11]: Environment variable reference - Apple Developer