本文へスキップ

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 でビルドします。

判定の流れ

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_URLhttps://<アカウント ID>.r2.cloudflarestorage.com
AWS_ACCESS_KEY_IDAPI トークンのアクセスキー(Secret)
AWS_SECRET_ACCESS_KEYAPI トークンのシークレット(Secret)

名前に AWS_ が付いているのは aws CLI がこの名前を読むからです。 値は R2 のものを入れます。

計測結果

Xcode Cloud の Build アクションを条件を変えて 1 回ずつ実行しました。 時間は、ログの最初と最後のタイムスタンプの差を 1 の位で四捨五入したものです。

条件ci_post_clonexcodebuild合計
キャッシュなし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 を使っていて、ビルド時間が気になっている方は、よければ試してみてください。

参考リンク

この記事をシェア