KMP の xcframework を R2 にキャッシュして Xcode Cloud のビルドを速くする
背景
Kotlin Multiplatform(KMP)で共通化したコードは Gradle で xcframework にして iOS アプリへ渡します。
この Gradle の実行を ci_post_clone.sh や Run Script に書いている構成があります。
Xcode Cloud は毎回まっさらな環境でビルドを始めます。
KMP のコードを変えていなくても、毎回 Gradle で xcframework をフルビルドします。
検証に使った小さな KMP プロジェクトでも Gradle の実行に 2 分 30 秒ほどかかりました。 実際のプロジェクトではもっと長くなるはずです。 そこで、KMP の入力が前回と同じなら Gradle を飛ばします。 前回の xcframework を R2 から取得して展開します。 入力が変わっていれば、これまでどおり Gradle でビルドします。
判定の流れ
保存先は Cloudflare R2 です。
生成をスキームの Pre-action に置く
xcframework は、スキームの Build の Pre-actions で生成します。
Pre-action は xcodebuild がビルドを始める前に実行されるので、xcframework が無い状態からでも、生成してからビルドが進みます。
Run Script に置くと、xcodebuild が Run Script の実行前に There is no XCFramework found で止まります。
cd "$SRCROOT/.."
./scripts/kmp_framework_cache.sh ./gradlew :shared:assembleSharedReleaseXCFramework
「Provide build settings from」にアプリのターゲットを指定すると $SRCROOT が使えます。
スキームは Shared にしてリポジトリに含めます。
Pre-action は Xcode Cloud でもローカルの xcodebuild でも同じように動きました。
測ったのは Build アクションだけです。
Test アクションは測っていません。
ci_post_clone.sh では Xcode Cloud に入っていない JDK と aws CLI を Homebrew で入れます。
brew install openjdk@17
brew install awscli || echo "awscli のインストールに失敗しました" >&2
Homebrew の JDK は PATH に入りません。
kmp_framework_cache.sh の冒頭で探して JAVA_HOME に設定しています。
PATH が最小限の環境で JDK が /Library/Java/JavaVirtualMachines にも Homebrew にも無いと Unable to locate a Java Runtime で失敗します。
その場合は Xcode から見える形で JAVA_HOME を渡します。
ci_scripts は Xcode プロジェクトまたはワークスペースと同じディレクトリに置きます。
.xcodeproj が iosApp/ の下にあるなら、iosApp/ci_scripts です。
キーと保存の方針
キーは次の内容から作ります。
shared/とgradle/の中にあるファイルのパスと内容- ルートの
build.gradle.kts、settings.gradle.kts、gradle.propertiesのパスと内容 - Xcode のビルドバージョン
ブランチ名とコミット SHA は含めません。
Swift だけを変えたブランチでも main が保存した xcframework を使えます。
xcframework には実機とシミュレーターの両方が入るので SDK や構成はキーに含めません。
対象のファイルが 1 バイトでも違えば別のキーになり、Gradle がフルビルドします。
差分だけを再ビルドする仕組みではありません。
xcframework を丸ごと置き換えます。
shared/ の外にある KMP モジュールを参照する構成では、そのモジュールもキーに加えます。
加えないと、変更しても HIT し続けて古い xcframework を使ってしまいます。
キャッシュの失敗では、ビルドを止めません。
- 取得した tar.gz は一時ディレクトリに展開し、成功してから出力先へ移します。壊れていれば MISS として扱います。
- キーの計算に失敗したらキャッシュなしで実行します。
- 書き込みは
mainの通常の実行だけです。PR とブランチの実行は読むだけにして、PR が保存したものをmainが使わないようにします。
Xcode Cloud の準備
R2 にバケットを作り、そのバケットだけに Object Read & Write を許可した API トークンを発行します。 ワークフローに環境変数を 4 つ設定します。
| 変数名 | 値 |
|---|---|
R2_BUCKET_NAME | バケット名 |
R2_ENDPOINT_URL | https://<アカウント ID>.r2.cloudflarestorage.com |
AWS_ACCESS_KEY_ID | API トークンのアクセスキー(Secret) |
AWS_SECRET_ACCESS_KEY | API トークンのシークレット(Secret) |
名前に AWS_ が付いているのは aws CLI がこの名前を読むからです。
値は R2 のものを入れます。
計測結果
Xcode Cloud の Build アクションを条件を変えて 1 回ずつ実行しました。 時間は、ログの最初と最後のタイムスタンプの差を 1 の位で四捨五入したものです。
| 条件 | ci_post_clone | xcodebuild | 合計 |
|---|---|---|---|
| キャッシュなし | 80 秒ほど | 160 秒ほど | 240 秒ほど |
| MISS(生成して保存) | 80 秒ほど | 170 秒ほど | 250 秒ほど |
| HIT(別ブランチから) | 90 秒ほど | 20 秒ほど | 100 秒ほど |
HIT のとき合計は 240 秒ほどから 100 秒ほどになり、140 秒ほど(約 57%)短くなりました。 xcframework の取得と展開は 5 秒ほどで、Gradle は実行されていません。 HIT の 90 秒ほどの大半は、JDK と aws CLI のインストールです。
MISS は、キャッシュなしより 5 秒ほど長くなりました。 保存には 3 秒ほどかかり、xcframework は 7.9 MB でした。
スクリプト全体
scripts/kmp_framework_cache.sh
#!/bin/bash
#
# KMP の xcframework を、入力のハッシュをキーにしてキャッシュする。
#
# 使い方: kmp_framework_cache.sh <framework を生成するコマンド...>
# 例: kmp_framework_cache.sh ./gradlew :shared:assembleSharedReleaseXCFramework
#
# キャッシュにあれば xcframework を取得して展開し、生成コマンドを実行せず、
# なければ生成コマンドを実行して保存する。
#
# 保存先(優先順):
# KMP_CACHE_DIR ローカルのディレクトリ(検証用)
# R2_BUCKET_NAME / R2_ENDPOINT_URL / AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY
# Cloudflare R2(aws CLI が必要)
# どちらもない キャッシュなしで生成コマンドを実行する
#
# 書き込みは KMP_CACHE_WRITE=1、または Xcode Cloud の main ブランチ(PR 以外)のときだけ。
# KMP_CACHE_DISABLE=1 でキャッシュを使わない。
# キャッシュ関連の失敗はビルドを止めず、キャッシュなしで生成コマンドを実行する。
set -uo pipefail
[[ $# -gt 0 ]] || { echo "使い方: $0 <command...>" >&2; exit 2; }
readonly REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
readonly OUT_DIR="${REPO_ROOT}/shared/build/XCFrameworks/release/Shared.xcframework"
readonly PREFIX="kmp-framework"
log() { echo "[kmp-cache] $*"; }
# Xcode Cloud には JDK が無く、PATH にも Homebrew が無いため、ci_post_clone.sh で入れたものを探す。
if [[ -z ${JAVA_HOME:-} ]] && ! /usr/libexec/java_home > /dev/null 2>&1; then
for prefix in /opt/homebrew /usr/local; do
jdk="${prefix}/opt/openjdk@17/libexec/openjdk.jdk/Contents/Home"
[[ -d ${jdk} ]] && { export JAVA_HOME="${jdk}"; break; }
done
fi
for prefix in /opt/homebrew /usr/local; do
[[ -d ${prefix}/bin ]] && PATH="${PATH}:${prefix}/bin"
done
export PATH
# --- キー -------------------------------------------------------------------
# 作業ツリーの内容をハッシュする(未コミットの変更や新規ファイルも反映するため)。
compute_key() {
cd "${REPO_ROOT}" || return 1
local files
files=$(
git ls-files -z --cached --others --exclude-standard -- \
shared gradle build.gradle.kts settings.gradle.kts gradle.properties \
| while IFS= read -r -d '' f; do [[ -f ${f} ]] && printf '%s\0' "${f}"; done \
| LC_ALL=C sort -z | xargs -0 shasum -a 256
) || return 1
[[ -n ${files} ]] || return 1
{
echo "${files}"
echo "xcode=${XCODE_PRODUCT_BUILD_VERSION:-$(xcodebuild -version | tr '\n' ' ')}"
} | shasum -a 256 | cut -d' ' -f1
}
# --- 保存先 -----------------------------------------------------------------
backend() {
if [[ ${KMP_CACHE_DISABLE:-} == 1 ]]; then
echo none
elif [[ -n ${KMP_CACHE_DIR:-} ]]; then
echo local
elif command -v aws > /dev/null && [[ -n ${R2_BUCKET_NAME:-} && -n ${R2_ENDPOINT_URL:-} \
&& -n ${AWS_ACCESS_KEY_ID:-} && -n ${AWS_SECRET_ACCESS_KEY:-} ]]; then
echo r2
else
echo none
fi
}
can_write() {
[[ ${KMP_CACHE_WRITE:-} == 1 ]] \
|| [[ -z ${KMP_CACHE_WRITE:-} && ${CI_BRANCH:-} == main && -z ${CI_PULL_REQUEST_NUMBER:-} ]]
}
# fetch <key> <dest>: 成功で 0。キャッシュに無い場合は静かに、それ以外の失敗は理由を出して非 0。
fetch() {
local err
case "$(backend)" in
local) cp "${KMP_CACHE_DIR}/${PREFIX}-$1.tar.gz" "$2" 2> /dev/null ;;
r2)
err=$(aws s3 cp "s3://${R2_BUCKET_NAME}/${PREFIX}/$1.tar.gz" "$2" \
--endpoint-url "${R2_ENDPOINT_URL}" --region auto --only-show-errors 2>&1) && return 0
[[ ${err} == *404* ]] || log "R2 からの取得に失敗しました: ${err##*$'\n'}"
return 1
;;
esac
}
store() {
case "$(backend)" in
local) mkdir -p "${KMP_CACHE_DIR}" && cp "$2" "${KMP_CACHE_DIR}/${PREFIX}-$1.tar.gz" ;;
r2) aws s3 cp "$2" "s3://${R2_BUCKET_NAME}/${PREFIX}/$1.tar.gz" \
--endpoint-url "${R2_ENDPOINT_URL}" --region auto --only-show-errors ;;
esac
}
# --- 取得と保存 -------------------------------------------------------------
# 一時ディレクトリに展開してから出力先へ移す(壊れたキャッシュで出力先を消さないため)。
restore() {
local tmp
tmp=$(mktemp -d) || return 1
if fetch "$1" "${tmp}/cache.tar.gz" && mkdir "${tmp}/out" \
&& tar -xzf "${tmp}/cache.tar.gz" -C "${tmp}/out" \
&& rm -rf "${OUT_DIR}" && mkdir -p "$(dirname "${OUT_DIR}")" && mv "${tmp}/out/$(basename "${OUT_DIR}")" "${OUT_DIR}"; then
rm -rf "${tmp}"
return 0
fi
rm -rf "${tmp}"
return 1
}
save() {
local tmp start
tmp=$(mktemp -d) || return 1
start=$(date +%s)
if tar -czf "${tmp}/cache.tar.gz" -C "$(dirname "${OUT_DIR}")" "$(basename "${OUT_DIR}")" && store "$1" "${tmp}/cache.tar.gz"; then
log "保存しました ($(du -h "${tmp}/cache.tar.gz" | cut -f1), $(( $(date +%s) - start ))s)"
else
log "保存に失敗しました"
fi
rm -rf "${tmp}"
}
# --- 本体 -------------------------------------------------------------------
if [[ $(backend) == none ]]; then
[[ -n ${CI_XCODE_CLOUD:-} && ${KMP_CACHE_DISABLE:-} != 1 ]] && log "保存先の設定が不足しているため、キャッシュを使いません"
exec "$@"
fi
KEY=$(compute_key) || KEY=""
if [[ -z ${KEY} ]]; then
log "キーを計算できないため、キャッシュを使いません"
exec "$@"
fi
START=$(date +%s)
if restore "${KEY}"; then
log "HIT ${KEY:0:12} ($(( $(date +%s) - START ))s)"
exit 0
fi
log "MISS ${KEY:0:12}"
"$@" || exit $?
if can_write && [[ -d ${OUT_DIR} ]]; then
save "${KEY}"
fi
exit 0
まとめ
KMP の入力のハッシュをキーにして xcframework を R2 に置くことで、Xcode Cloud の Build は 240 秒ほどから 100 秒ほどになりました。 KMP を使っていて、ビルド時間が気になっている方は、よければ試してみてください。