圖形功能在開發機上運作正常,卻直到 CI 封存流程接近尾聲時才出現 Metal 編譯錯誤,問題通常不在於「機器效能不足」,而是著色器未被當成獨立的建置輸入管理。更穩妥的做法,是先在雲端 Mac 上使用目前的 Xcode 工具鏈,離線產生實機與模擬器版本的 metallib,再開始完整的專案建置。如此一來,語法錯誤、SDK 選擇錯誤及函式缺失都能更早暴露,失敗日誌也會更精簡。
為什麼要將著色器拆成獨立門禁
Xcode 會自動處理已加入目標的 .metal 檔案,但錯誤往往埋在包含相依性解析、資源複製及簽署步驟的冗長日誌中。獨立門禁並非取代 Xcode 建置,而是在建置前先回答三個問題:
- 所有著色器能否由目前的工具鏈編譯;
- 實機與模擬器目標能否分別產生程式庫;
- 應用程式實際參照的函式名稱能否從程式庫載入。
不要將開發機產生的
metallib直接複製到長期保存的產物目錄。工具鏈、SDK 或編譯參數變更後,舊檔案可能仍然存在,卻已無法代表目前的原始碼。
建議儲存庫只提交 .metal 原始碼與編譯腳本,並將 .air、.metallib 放入可清除的建置目錄。若團隊需要快取,快取鍵至少應包含原始碼摘要、Xcode 路徑、SDK 版本及目標平台。
固定本次建置使用的工具鏈
雲端 Mac 可能安裝多個 Xcode 版本。腳本不應依賴圖形介面中上次選取的版本,而應明確檢查 DEVELOPER_DIR。路徑必須依節點上的實際安裝狀況設定,不要在多個任務之間共用未經確認的值。
set -euo pipefail
: "${DEVELOPER_DIR:?DEVELOPER_DIR is required}"
xcodebuild -version
xcrun --sdk iphoneos --show-sdk-version
xcrun --sdk iphonesimulator --show-sdk-version
xcrun --sdk iphoneos --find metal
xcrun --sdk iphoneos metal --version
mkdir -p build/metal/metadata
{
echo "developer_dir=$DEVELOPER_DIR"
xcodebuild -version
echo "iphoneos=$(xcrun --sdk iphoneos --show-sdk-version)"
echo "iphonesimulator=$(xcrun --sdk iphonesimulator --show-sdk-version)"
xcrun --sdk iphoneos metal --version
} > build/metal/metadata/toolchain.txt
toolchain.txt 應與失敗日誌一併封存。它能快速區分「原始碼退化」與「執行器切換了 Xcode」這兩類問題。不要只記錄 xcodebuild -version,因為實際呼叫的 SDK 同樣會影響結果。
分別編譯實機與模擬器產物
先將每個原始碼檔案編譯成獨立的 .air,再合併成程式庫。逐檔編譯的優點是能將錯誤直接定位到特定檔案,而不是只得到籠統的連結失敗訊息。
set -euo pipefail
sources=(Shaders/*.metal)
if [ ! -e "${sources[0]}" ]; then
echo "No Metal source files found" >&2
exit 1
fi
for sdk in iphoneos iphonesimulator; do
out="build/metal/$sdk"
rm -rf "$out"
mkdir -p "$out/air"
for src in "${sources[@]}"; do
name="$(basename "$src" .metal)"
xcrun --sdk "$sdk" metal -c "$src" \
-o "$out/air/$name.air"
done
xcrun --sdk "$sdk" metallib "$out"/air/*.air \
-o "$out/AppShaders.metallib"
test -s "$out/AppShaders.metallib"
shasum -a 256 "$out/AppShaders.metallib" \
> "$out/AppShaders.metallib.sha256"
done
兩套產物必須存放在不同目錄,不能讓後執行的任務覆寫前一套。若原始碼透過 C/C++ 前置處理器巨集區分平台,應將巨集參數集中寫入腳本,並確保 Xcode Build Phase 使用同一組定義。
| 檢查對象 | 實機目標 | 模擬器目標 |
|---|---|---|
| SDK | iphoneos |
iphonesimulator |
| 中繼檔案 | 獨立 .air |
獨立 .air |
| 輸出程式庫 | 單獨封存 | 單獨封存 |
| 最終驗收 | 實機或正式封存 | 模擬器冒煙測試 |
以載入測試驗證函式契約
檔案非空只能證明編譯器產生了輸出,無法證明應用程式查找的函式仍然存在。重新命名 kernel 或 fragment 函式後,Swift 程式碼中的字串可能未同步修改。應在測試目標中載入程式庫,並查詢關鍵進入點。
import Metal
import XCTest
final class ShaderLibraryTests: XCTestCase {
func testRequiredFunctionsExist() throws {
let device = try XCTUnwrap(MTLCreateSystemDefaultDevice())
let url = try XCTUnwrap(
Bundle(for: Self.self).url(
forResource: "AppShaders",
withExtension: "metallib"
)
)
let library = try device.makeLibrary(URL: url)
for name in ["imageVertex", "imageFragment", "toneMapKernel"] {
XCTAssertNotNil(
library.makeFunction(name: name),
"Missing Metal function: \(name)"
)
}
}
}
函式清單應取自應用程式實際建立管線時使用的名稱。測試套件還必須確認複製的是 iphonesimulator 產物,而不是工作區中碰巧殘留的實機檔案。最低限度的冒煙測試是建立 MTLDevice、載入程式庫並查詢函式;若涉及紋理格式或執行緒群組限制,再加入管線建立測試。
失敗時應保留哪些證據
排查 Metal CI 故障時,最有價值的不是完整工作目錄,而是少量可供比對的資訊:
DEVELOPER_DIR、Xcode 及兩個 SDK 的版本;- 實際執行的編譯命令與標準錯誤輸出;
- 每個
.metal檔案的原始碼摘要; - 兩套
metallib的大小與 SHA-256; - 載入失敗的函式名稱、測試目標及執行目標;
- 本次任務使用的提交識別碼。
原始碼摘要可使用 find Shaders -name '*.metal' -print0 | sort -z | xargs -0 shasum -a 256 產生。產物雜湊值改變不應單獨觸發失敗,因為工具鏈升級也可能改變二進位檔案;正確策略是先比較工具鏈指紋,再以重新編譯、載入及管線建立結果作為門禁。
在 MacMLab 的獨享實體節點上執行這類任務時,同一個工作區仍可能受到連續作業的污染。每次建置前都應清空目標輸出目錄,任務結束後只封存中繼資料、日誌及最終程式庫。如此既能避免舊 .air 混入新程式庫,也能為下一次失敗提供可重現的起點。
常見問題
Xcode 已會編譯 Metal 檔案,為何還要加入離線編譯?
獨立步驟可在完整封存前發現語法、SDK 與目標差異,並提供較短的錯誤日誌;正式建置仍應保留 Xcode 原有流程。
實機與模擬器能共用同一個 metallib 嗎?
不建議共用。應分別使用 iphoneos 與 iphonesimulator SDK 編譯,再於對應目標驗證載入和函式查找。
metallib 的雜湊改變就代表建置不可靠嗎?
不一定。先檢查 Xcode 路徑、SDK 與編譯器版本是否改變,再以載入測試和畫面驗收判斷產物是否有效。
獨享實體節點
將開發與建置任務交給獨享雲端 Mac
依任務選擇機型、節點區域與租用週期。每個實例都對應獨享實體機,採用非虛擬化環境。