一个 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 编译吗?
不能把它当作 Swift 编译缓存。ccache 主要包装 Clang,对 Objective-C、Objective-C++、C 和 C++ 编译单元有效;Swift 仍应依靠稳定的 DerivedData、合理并发和 Xcode 自身的增量机制。
为什么第二次构建仍然没有缓存命中?
先检查 Xcode 实际调用的编译器是否为包装器,再比较工作区路径、编译参数、生成头文件和宏定义。路径随机化、时间戳宏或每次变化的生成文件都会改变缓存键。
CI 应该保存整个 DerivedData 还是只保存 ccache 目录?
优先把 ccache 目录作为独立缓存保存。DerivedData 与 Xcode 版本、工程状态和绝对路径耦合更深,恢复前需要更严格的键和失效策略。
为下一次构建准备独享物理 Mac
从三档固定配置中选择适合开发、持续集成或本地模型测试的云端 Mac,并按任务周期完成配置。