图形功能在开发机上运行正常,却在 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 编译,并在对应运行目标上执行加载与函数查找测试。
Metal 产物哈希变化是否一定表示构建异常?
不一定。工具链或 SDK 变化可能改变二进制内容,应先比对 DEVELOPER_DIR、SDK 版本和编译器版本,再结合冒烟测试判断。
独享物理节点
把开发与构建任务放到独享云端 Mac
按任务选择机型、节点区域和租用周期。每份实例对应独享物理机,是非虚拟机环境。