一個 iOS 專案可能只有少量 Swift 程式碼,卻包含數十個 Objective-C、C 或 C++ 編譯單元。雲端 Mac 的工作每次進入新的工作目錄後,這些原生相依項目都會重新呼叫 Clang,因而失去增量建置的優勢。ccache 適合處理這一層重複工作:它會根據預處理結果、編譯參數與編譯器識別資訊產生鍵值,命中時直接重用目的檔。
它不是通用的 Xcode 加速開關。接入前必須先確認耗時確實發生在 Clang,而不是 Swift 前端、連結、資源編譯或測試階段。
先確認快取對象
保留一份完整的建置記錄,搜尋 CompileC、CompileC normal,以及 .m、.mm、.c、.cc、.cpp 檔案。只有當這些步驟占用明顯時間時,投入 ccache 才有價值。純 Swift 專案即使設定正確,效益也可能接近零。
先記錄三項基準:
- 清理後完整建置的耗時;
- 不修改程式碼再次建置的耗時;
- 原生編譯單元數量,以及最慢的幾個目標。
不要一開始就快取整個 DerivedData。它包含索引、模組快取、建置資料庫與中間產物,和 Xcode 版本、SDK、絕對路徑及專案圖有較深的耦合。ccache 目錄更適合作為獨立且可觀測的快取單元。
先證明工作確實重複呼叫了 Clang,再討論命中率。否則很容易把連結變慢、Swift 型別檢查或指令碼階段的耗時,誤判為快取失效。
用包裝器接管 Clang
在建置環境中安裝 ccache 後,不要取代系統編譯器檔案。應在專案工作區中建立兩個輕量包裝器,再透過 Xcode 建置設定傳入。如此只要移除參數即可回退,也不會影響同一節點上的其他工作。
set -eu
export CCACHE_BIN="$(command -v ccache)"
export CCACHE_DIR="$HOME/.cache/pingmini-ccache"
export CCACHE_BASEDIR="$PWD"
mkdir -p .ci/bin "$CCACHE_DIR"
cat > .ci/bin/clang <<'SH'
#!/bin/sh
exec "${CCACHE_BIN:?CCACHE_BIN is required}" /usr/bin/clang "$@"
SH
cat > .ci/bin/clang++ <<'SH'
#!/bin/sh
exec "${CCACHE_BIN:?CCACHE_BIN is required}" /usr/bin/clang++ "$@"
SH
chmod +x .ci/bin/clang .ci/bin/clang++
ccache --set-config="cache_dir=$CCACHE_DIR"
ccache --set-config="base_dir=$PWD"
ccache --set-config="compiler_check=content"
ccache --set-config="compression=true"
ccache --set-config="max_size=20G"
ccache --zero-stats
xcodebuild \
-workspace "$WORKSPACE" \
-scheme "$SCHEME" \
-configuration Release \
-derivedDataPath "$DERIVED_DATA" \
build \
CC="$PWD/.ci/bin/clang" \
CPLUSPLUS="$PWD/.ci/bin/clang++"
ccache --show-stats
CC 負責 C 與 Objective-C,CPLUSPLUS 負責 C++ 與 Objective-C++。不要包裝連結器,也不要把包裝器偽裝成 Swift 編譯器。執行後應從詳細記錄確認實際執行的是 .ci/bin/clang;只看到 ccache 已安裝,並不能證明 Xcode 使用了它。
讓快取鍵跨工作保持穩定
CI 經常把同一個提交簽出到含有工作編號的目錄。即使原始碼沒有變化,絕對路徑仍會不同,除錯資訊、標頭檔路徑與編譯參數都可能因此改變。CCACHE_BASEDIR 與 base_dir 會將工作區內的路徑改寫為相對形式,是跨工作重用快取的第一步。
此外仍須固定下列輸入:
固定工具鏈與建置參數
快取鍵應包含 Xcode 主要版本、SDK、目標架構、組態名稱與鎖定檔摘要。升級工具鏈時主動切換快取命名空間,會比讓新舊目的檔混在同一目錄更容易稽核。
同一份快取不能同時服務 Debug 與 Release,也不要讓不同架構共用經過人為簡化的鍵值。ccache 會辨識參數差異,但外層持久化鍵涵蓋範圍過廣,會造成大量無效下載與淘汰。
控制產生的檔案
若產生的標頭檔每次都寫入目前時間、隨機識別碼或暫時性的絕對路徑,所有相依於它的編譯單元都會失效。產生指令碼應只在內容變更時覆寫檔案,並在與暫存檔比較後進行不可分割的替換:
generate_config > "$TMPDIR/build-config.new"
if ! cmp -s "$TMPDIR/build-config.new" Generated/build-config.h; then
mv "$TMPDIR/build-config.new" Generated/build-config.h
else
rm "$TMPDIR/build-config.new"
fi
避免在原始碼中使用會隨每次編譯而變化的時間巨集。若業務確實需要建置時間,請將它放入獨立檔案,縮小受影響的編譯範圍。
用兩輪建置驗證快取命中
首次接入時應執行兩輪隔離驗收。第一輪將統計歸零,保留空快取並完成完整建置;第二輪刪除 DerivedData、保留 ccache,再以完全相同的工具鏈與參數重新建置。只有第二輪出現直接命中,才能證明重用不依賴舊的建置目錄。
檢查 cacheable calls、hits、misses 與 uncacheable calls,不要只看整體命中百分比。首次建置原本就應以 miss 為主;如果第二輪仍然全部 miss,請依下列順序排查:
- 查看建置記錄,確認包裝器確實被呼叫;
- 比較兩輪的工作區、SDK、架構與編譯參數;
- 檢查產生的標頭檔是否每次都會變更;
- 檢查是否存在時間巨集、隨機值或絕對路徑;
- 確認工作結束後,快取目錄確實已被保存。
若 uncacheable calls 很多,請使用 ccache 除錯記錄,只重現一個編譯單元,避免為整次建置產生大量診斷檔案。找出原因後應關閉除錯,防止記錄本身占滿磁碟。
設定清理與回退邊界
快取上限應依原生目標的規模決定,而不是任其無限增長。定期執行 ccache --cleanup,並監控目錄大小、淘汰數量與寫入失敗。多個並行工作可以共用同一目錄,但底層目錄必須位於可靠的本機檔案系統;不穩定的遠端掛載會讓省下的編譯時間消耗在中繼資料存取上。
懷疑發生錯誤重用時,先保留記錄與快取統計,再以 CCACHE_DISABLE=1 重新執行同一個命令。若問題消失,應依工具鏈、目標與組態縮小快取範圍;不要把停用驗證、放寬時間戳記判斷或忽略標頭檔變更,當成長期修正方式。
最終驗收標準不是某個漂亮的命中率,而是三件事同時成立:產物與停用快取時一致、第二輪原生編譯顯著減少,而且快取失效能由工具鏈或輸入變化解釋。滿足這三點後,ccache 才能成為可維護的建置基礎設施。
常見問題
ccache 能直接加速 Swift 編譯嗎?
不能。這套配置主要包裝 Clang,因此適用於 Objective-C、Objective-C++、C 與 C++ 編譯單元;Swift 仍要依賴穩定的 DerivedData、適當並行度與 Xcode 增量編譯。
為什麼第二次建置仍然沒有快取命中?
先確認 Xcode 確實呼叫包裝器,再比較工作目錄、編譯參數、產生的標頭檔與動態巨集。這些內容只要變動,就可能建立不同的快取鍵。
CI 應保存 DerivedData 還是只保存 ccache 目錄?
建議先把 ccache 目錄當作獨立快取保存。DerivedData 與 Xcode 版本、絕對路徑、SDK 及工程圖狀態的耦合更高。
準備下一次建置所需的獨享物理 Mac
從三種固定配置中選擇適合開發、持續整合或本地模型測試的雲端 Mac,並依照任務週期完成設定。