一个被多个 App 引用的 Swift Framework,只要删除一个公开方法、收紧一个参数类型,或给公开协议增加必选成员,就可能让下游工程在升级后无法编译。单元测试通常覆盖实现行为,却不会明确告诉你“这次修改破坏了公开接口”。在 OnceMac 的云端 Mac 构建节点上,可以把 Swift API Digester 放到合并检查中:先保存已发布版本的 API 快照,再用完全相同的工具链生成候选快照,最后比较两者。
先固定比较条件
API 快照并不只由源码决定。Xcode 版本、Swift 编译器、SDK、目标架构、构建配置和条件编译标记都会影响结果。第一步不是运行比较,而是把这些输入写进日志并固定下来。
set -euo pipefail
xcodebuild -version
xcrun swift --version
SDK_PATH="$(xcrun --sdk iphonesimulator --show-sdk-path)"
echo "SDK_PATH=$SDK_PATH"
CI 应明确选择 Xcode,并让基线与候选分支使用同一镜像或同一节点模板。目标三元组也要保持一致,例如统一使用 arm64-apple-ios17.0-simulator。不要在一边生成真机接口、另一边生成模拟器接口,否则平台条件差异会被误判为代码变更。
API 基线是发布契约,不是编译缓存。它应该进入版本控制并接受评审,普通构建任务不能自动覆盖它。
构建可供分析的 Framework
以下示例假设 scheme 名和 module 名均为 MyKit。先清理独立的 Derived Data,再以 Release 配置构建。BUILD_LIBRARY_FOR_DISTRIBUTION=YES 会生成模块稳定性所需的接口文件,也能让检查条件更接近二进制分发场景。
DERIVED_DATA="$PWD/.build/api-dd"
PRODUCTS="$DERIVED_DATA/Build/Products/Release-iphonesimulator"
rm -rf "$DERIVED_DATA"
xcodebuild build \
-scheme MyKit \
-configuration Release \
-destination "generic/platform=iOS Simulator" \
-derivedDataPath "$DERIVED_DATA" \
BUILD_LIBRARY_FOR_DISTRIBUTION=YES \
SKIP_INSTALL=NO \
CODE_SIGNING_ALLOWED=NO
构建后先确认模块确实存在,不要让 Digester 在错误的搜索路径上产生难懂的“module not found”。
test -d "$PRODUCTS/MyKit.framework"
find "$PRODUCTS/MyKit.framework/Modules" -maxdepth 3 -type f -print
如果工程通过工作区管理依赖,需要补充 -workspace;如果 scheme 不共享,CI 无法发现它,应先在工程设置中共享,而不是靠脚本猜测路径。
生成并比较 API 快照
为当前发布基线和候选代码分别构建一次,然后输出 JSON。基线文件建议放在仓库的 api-baselines/ 目录,并在文件名中记录平台,不必写入机器路径或构建编号。
mkdir -p api-baselines .build/api-report
xcrun swift-api-digester \
-dump-sdk \
-module MyKit \
-sdk "$SDK_PATH" \
-target arm64-apple-ios17.0-simulator \
-F "$PRODUCTS" \
-o ".build/api-report/current-ios-simulator.json"
xcrun swift-api-digester \
-diagnose-sdk \
-input-paths "api-baselines/MyKit-ios-simulator.json" \
-input-paths ".build/api-report/current-ios-simulator.json" \
> ".build/api-report/diagnostics.txt"
首次接入时,把确认过的 current-ios-simulator.json 复制为基线并提交。之后 CI 只读取该文件。若命令返回非零状态,应保留诊断文本作为构建附件,而不是只显示一句“兼容性检查失败”。
重点审查哪些变化
| 变化 | 默认判断 | 处理方式 |
|---|---|---|
| 删除公开类型或方法 | 破坏性 | 恢复接口,或进入明确的主版本变更 |
| 修改参数、返回值或泛型约束 | 破坏性 | 提供兼容重载并安排弃用周期 |
| 给公开协议增加必选成员 | 高风险 | 考虑提供默认实现 |
| 新增公开方法或类型 | 通常兼容 | 检查命名、可见性和平台标记 |
| 仅调整内部实现 | 不应出现 | 检查访问级别是否意外扩大 |
public 并不总等于有意承诺。若某个声明不该被外部调用,优先收紧访问级别,而不是长期维护忽略规则。
把诊断结果变成可维护门禁
建议把流程拆成“构建”“生成快照”“比较”“上传报告”四步。脚本启用 set -euo pipefail,并把 Derived Data 放进任务独立目录,避免并行作业互相读取产物。对多模块仓库,可维护一个模块清单逐个执行,而不要复用上一模块的 PRODUCTS 路径。
门禁失败后,评审者需要看到三项信息:使用的 Xcode 与 Swift 版本、基线和候选快照、完整诊断文本。只有确认变化符合版本策略时,才在同一个合并请求中更新基线。若允许失败任务自行重写基线,检查会永远通过,也就失去意义。
处理误报的顺序
先核对工具链,再检查 SDK 与目标三元组,随后比较构建参数和条件编译标记,最后才判断是否属于 Digester 输出噪声。可以用以下清单缩短排查:
xcodebuild -version是否完全一致;- scheme、配置和 destination 是否一致;
BUILD_LIBRARY_FOR_DISTRIBUTION是否两边都启用;- 模块搜索路径是否指向本次任务的产物;
- 基线是否来自最后一个已发布接口,而不是任意历史提交;
- 生成文件中是否混入工作目录等易变绝对路径。
用版本策略收尾
兼容性门禁只负责发现变化,不能替团队决定版本号。删除接口、改变公开类型或新增协议要求,通常需要进入不兼容版本计划;新增接口仍要经过命名与可用性审查;被弃用的接口则应保留约定周期,再由后续版本移除。
最稳妥的流程是:发布分支保存基线,合并请求只生成候选快照,CI 输出机器诊断,维护者结合版本策略作出决定。这样既不会把所有 API 变化一概禁止,也不会让一次无意的 public 修改直接传递给所有下游工程。
常见问题
Swift API Digester 能替代单元测试吗?
不能。它检查公开 Swift 接口的结构变化,不验证业务行为、运行时语义或 Objective-C 接口;应与单元测试、集成测试和产物验收并行执行。
什么时候应该更新 API 基线?
只有在团队确认接口变化符合版本策略后才更新。基线文件应与代码一起评审,并在发布分支或明确批准的变更中提交,不能由普通 CI 自动覆盖。
为什么同一份代码会产生不同的比较结果?
常见原因是 Xcode、Swift 编译器、SDK、目标架构或构建配置不同。生成基线和执行比较时必须固定这些条件,并在日志中输出实际工具链信息。
把下一次构建放到独立物理节点上。
选择芯片、内存、存储、节点与租期,获得不与其他客户共享资源的云端 Mac。