做 iOS 开发,版本号和 Build 版本号的自增问题,听着不大,但几乎每个团队都因为它出过岔子。我早期带项目时,发版前全靠手工在 Xcode 里改 CFBundleShortVersionString 和 CFBundleVersion,催测高峰期漏改一次,App Store Connect 直接弹错:A build with the same version and build number already exists。整个提审流程卡死在最后一刻,所有人干瞪眼等我把数字改好重新打包上传。这种场景经历个两三次,你就会被逼着去想怎么把版本号自增彻底自动化。这篇文章把我个人在 Xcode 里解决版本号自增的经验整理成一条完整路线,覆盖苹果官方 agvtool、自定义 Shell 脚本、Fastlane 三种主流方案,还会把每种方案的适用边界和踩坑点讲清楚,无论是一个人维护的小项目,还是几十人的 CI 团队,都能找到能直接抄的配置。
1. 先把两个编号的职责彻底拆开:CFBundleShortVersionString 和 CFBundleVersion
1.1 两个 key 在工程里的真实身份
打开任意一个 iOS 工程的 Info.plist,你会看到两个长得差不多的字段:CFBundleShortVersionString 和 CFBundleVersion。前者叫"压缩版本号",也就是用户能看到的版本号,App Store 列表、系统设置、桌面图标下面显示的都是它;后者叫"构建号",主要给开发者自己追踪构建用的,TestFlight 构建列表、崩溃日志、Bugly 这类工具里显示的也是它。
我用一个比较土但很直观的类比:版本号是地图的"第几版",Build 号是同一版地图"印了多少次"。地图改版是产品决策,印了多少次是工程行为,两者虽然都注册在同一个 plist 里,但在团队里的决策权和更新频率是完全不同的。
从 Xcode 的视角看,这两个值可以写在 Info.plist 文件里,也可以通过 Build Settings 里的变量注入。这也是后面几种自增方案的根本分歧点——你到底改的是文件,还是改的是构建设置。弄不明白这一点,后面调试脚本会很痛苦。
1.2 Apple 对这两个 key 的硬性规则
Apple 并没有规定你必须用什么自增工具,但对这两个值的格式和唯一性是有要求的,尤其是到了提审环节,不合规直接卡流程。
| 维度 | 版本号(CFBundleShortVersionString) | Build 号(CFBundleVersion) |
|---|---|---|
| 用户可见位置 | App Store 页面、系统设置、桌面图标 | TestFlight 构建列表、崩溃日志 |
| 格式建议 | 1~3 段点分整数,如 1.0.0 | 数字和点组成的字符串,别带字母和空格 |
| 唯一性要求 | 对应商店页面的一个版本 | 同一版本号下必须全局唯一 |
| 更新频率 | 语义有变化才改 | 每次上传新包都应该增大 |
这里特别提醒一句:很多人想在版本号里加个v前缀或者-beta后缀,比如1.2.3-beta,App Store 是不接受的。Beta 这个信息应该放在 TestFlight 的内部/外部测试组管理上,不要塞进版本号字符串里。
1.3 想清楚你的具体需求再选下面的方案
自增方案没有绝对最优,只有适不适合你的团队和流程。动手之前先回答三个问题:
- 你只想要 Build 号自增,还是版本号也要跟着自动变?
- 自增逻辑运行在本地开发机、CI 服务器,还是两边都要?
- 工程里是一个 target,还是有 Widget、Extension、Framework 多个 target?
如果只维护一个小工具 App,一个人手动打包,那用最简单的 Shell 脚本就够了;如果是团队多人协作、天天往 TestFlight 传包,不上 Fastlane 你迟早会被构建号冲突搞疯。下面三章就是这三条路的完整走法。
2. agvtool 官方自增方案:十分钟配完,但有个时机坑必须先知道
2.1 三步配置开启 Apple Generic
agvtool 是 Xcode 自带的版本管理命令行工具,不需要装任何东西。第一件事是让工程开启 Apple Generic 版本管理:
- 选中 TARGETS 下的主 target,进入 Build Settings,搜索
Versioning。 - 把
Versioning System改成Apple Generic。 - 在搜索出来的列表里,找到
Current Project Version,填一个起始 Build 号,比如1;找到Marketing Version,填起始版本号,比如1.0.0。
然后打开 Info.plist,确认 CFBundleShortVersionString 和 CFBundleVersion 这两个字段是变量引用而不是写死的数字:
<key>CFBundleShortVersionString</key> <string>$(MARKETING_VERSION)</string> <key>CFBundleVersion</key> <string>$(CURRENT_PROJECT_VERSION)</string>这样配置之后,Build 号和营销版本号的主语就从"Info.plist 文件"变成了"构建设置"。以后你在命令行里改的也是构建设置,而不是直接改 plist。验证配置是否生效:
cd /path/to/YourProject agvtool what-version agvtool what-marketing-version能正确打印出刚才填的1和1.0.0,就说明基础配置没问题。
2.2 后续日常只需要记住三条命令
配置好之后,平时的操作无非三种:Build 号自增、强行指定 Build 号、改营销版本号。
# 1. Build 号自增:1.0.0 -> 1.0.1,2.4.18 -> 2.4.19 agvtool next-version -all # 2. 强行指定 Build 号,适合 CI 里用时间戳或提交数 agvtool new-version -all 202501071200 # 3. 改营销版本号,发版时用 agvtool new-marketing-version -all 2.1.0注意next-version的行为:它是把最后一位数字加 1,不管你现在是1、1.0还是1.0.1,它都只动最后一段。所以如果你们团队习惯把 Build 号写成三段式,自增后也会保持三段式。这个细节在核对 CI 配置时容易踩,先有个印象。
2.3 为什么你把它塞进 Run Script 后第一次构建不生效
把agvtool next-version -all塞进 Run Script Phase,是最常见的做法,但很多人做完后发现:当天点的第一次构建,打出来的包 Build 号还是旧值;再构建一次,才变成新值。第一反应肯定是怀疑自己配置错了。
其实不是配置错,是 Xcode 构建系统的时序问题。Info.plist 里那两个变量在构建起步阶段就会被展开,而 Run Script Phase 里 agvtool 修改CURRENT_PROJECT_VERSION这个构建设置时,构建系统的设置值已经被读取到内存里了。于是这轮产物用的还是旧设置,被修改的 pbxproj 要等到下一轮构建才会被重新读进去。
想让它在单个构建内立刻生效,有两条可靠路线。
路线一:Scheme Pre-action。在 Xcode 里打开 Scheme 编辑器,在 Build → Pre-actions → 添加 Run Script,并在右侧勾选 Provide build settings from 你的 target,这样脚本里才能拿到${PROJECT_DIR}、${CONFIGURATION}这些变量。脚本内容:
if [ "${CONFIGURATION}" = "Release" ]; then xcrun agvtool next-version -all fi路线二:CI 里在 xcodebuild 之前单独执行:
agvtool next-version -all xcodebuild -workspace YourApp.xcworkspace -scheme YourApp -configuration Release archive自己本地打包用路线一顺手,CI 打包用路线二更直观。两条路线都能绕开 Run Script 阶段的时序坑。
2.4 多 target 场景别盲目用 -all
-all参数的意思是更新当前工程下所有 target 的版本号。如果你的工程只有主 App 一个 target,那没问题;但如果还有 Widget、Extension 这些扩展,-all能把它们的 Build 号也一起拉起来,省得扩展的构建号和宿主 App 不一致。
不过,如果某些 target 确实需要独立的版本节奏,比如内部 Framework 不跟随 App 发版,agvtool 就不太合适了——它做不到按 target 分别管理不同的取值规则。这种情况建议跳到后面 Fastlane 那一章,用循环逐 target 处理。另外还要注意,agvtool 只认它当前所在目录的工程文件,在 workspace 里用了多工程,执行前记得 cd 到正确的工程目录。
3. 自定义 Shell 脚本:不引入外部依赖的精确控制
3.1 一个最小可用脚本
如果不想被 agvtool 的时序问题困扰,也不打算引入 Fastlane,最直接的做法是在 Xcode 的 Build Phases 里加一段 Run Script,用/usr/libexec/PlistBuddy直接读写 Info.plist。
#!/bin/bash set -e plist="${PROJECT_DIR}/${INFOPLIST_FILE}" buildNumber=$(/usr/libexec/PlistBuddy -c "Print CFBundleVersion" "$plist") newBuildNumber=$((buildNumber + 1)) /usr/libexec/PlistBuddy -c "Set :CFBundleVersion $newBuildNumber" "$plist" echo "CFBundleVersion: ${buildNumber} -> ${newBuildNumber}"把它作为 Run Script Phase 加在 Build Phases 列表的最上面。set -e很关键,一旦 PlistBuddy 读取出错,整个构建直接失败,而不是带着一个坏掉的版本号继续跑。你可以在构建日志里看到 echo 出来的前后值,方便确认脚本有没有执行。
3.2 先确认 Info.plist 里存的是数字还是变量
这一步是很多人脚本写完之后报错的根源。新版 Xcode 创建的工程模板,Info.plist 里 CFBundleVersion 默认是$(CURRENT_PROJECT_VERSION)这个变量引用。你拿 PlistBuddy 的Print去读,读出来的是一串$(CURRENT_PROJECT_VERSION)的字符串,而不是数字,然后$((buildNumber + 1))直接报语法错误。
所以用这个方案之前,得先想清楚你的 Info.plist 到底存的是什么:
- 如果 CFBundleVersion 是纯数字:上面那段脚本可以正常工作。
- 如果 CFBundleVersion 是
$(CURRENT_PROJECT_VERSION):脚本会挂,而且就算不挂,改了也是改文件里的变量字符串,没有任何意义。
想继续用 Shell 脚本,就把 Info.plist 里的 CFBundleVersion 改成纯数字,同时把构建设置里的CURRENT_PROJECT_VERSION依赖去掉,保持两处口径一致。这里没有中间路线,文件里的值和构建设置引用的值只能二选一。
3.3 按构建配置过滤,避免 Debug 也自增
如果你不加条件,那每次本地编译都会把 Build 号加一,跑个测试 IDE 可能已经帮你改了好几次,git status 天天都是花的。加一行判断,让只在 Release 构建时才自增:
if [ "${CONFIGURATION}" != "Release" ]; then echo "skip build number increment for ${CONFIGURATION}" exit 0 fi这样本地调试几乎无感,只有 Archive 或 Release 上传时才动数字。打包完成后,如果想确认最终产物里的值,可以看构建出来的 App 包:
/usr/libexec/PlistBuddy -c "Print CFBundleVersion" "${TARGET_BUILD_DIR}/${INFOPLIST_PATH}"这条命令打出来的才是真正打进包里的值,比你在源码里看到的更可信。
3.4 升级 Xcode 版本后脚本突然不生效的排查链路
我见过好几个开发者反映,同一个脚本在 Xcode 12 上好使,升到 Xcode 13 之后突然没效果了。遇到这种情况,按下面顺序排查:
- 先看构建日志里有没有
skip或CFBundleVersion: x -> y这行输出。没有输出,说明 Run Script 没被执行,检查脚本是不是被勾掉了,或者脚本内容有语法错误被静默吞掉。 - 输出显示新值了,但打包产物里还是旧值。这就回到 2.3 的时序问题:构建系统在 Run Script 之前已经生成了打包用的 Info.plist,你修改的是源码文件,打出来就还是旧值。验证方法是看第 3.3 节那条命令的输出。
- 如果确认是时序问题,别在 Run Script Phase 里硬扛,直接把脚本挪到 Scheme Pre-action,或者 CI 里 xcodebuild 前先执行。脚本本身不用改,换一个执行时机就行。
这段排查链路也算是个通用经验:遇到"脚本明明在跑但结果不对"的问题,先分清楚你改的是哪个文件、构建系统什么时候读取它,再决定要不要调整执行顺序。
4. Fastlane 自增:面向 CI/CD 和团队协作的标准解
4.1 为什么团队协作时要多引入一个工具
agvtool 会直接改 project.pbxproj,Shell 脚本会改 Info.plist,这两类操作在多人协作时都会制造一种问题:本地构建一次,git 状态就脏一次,最后合并代码时全是版本号冲突。
Fastlane 解决这个问题的思路不太一样。它把"版本号自增"定义成一个 lane 里的明确动作,在调 xcodebuild 之前完成对 pbxproj 或 Info.plist 的修改,整个流程可读、可控、可复现。而且它天然是为 CI 设计的,和 GitHub Actions、Jenkins、GitLab CI 都能配合得很好。
4.2 两条核心 action 的编排逻辑
Fastlane 里跟版本号相关的两个核心 action 是increment_build_number和increment_version_number。前者管 Build 号,后者管营销版本号。下面是一个典型的 TestFlight beta lane:
lane :beta do build_number = sh("git rev-list --count HEAD").strip increment_build_number( xcodeproj: "YourApp.xcodeproj", build_number: build_number ) build_app(scheme: "YourApp", export_method: "app-store-connect") upload_to_testflight end这里用git rev-list --count HEAD拿提交数作为 Build 号。只要大家都往同一个主干或同一个远端分支提交,这个数就是严格单调递增的。如果你不想用提交数,也可以换成时间戳:
lane :release do increment_version_number( xcodeproj: "YourApp.xcodeproj", version_number: "2.1.0" ) increment_build_number( xcodeproj: "YourApp.xcodeproj", build_number: Time.now.strftime("%Y%m%d%H%M%S") ) build_app(scheme: "YourApp") upload_to_app_store end分工很明确:betalane 只动 Build 号,releaselane 同时动版本号和 Build 号。日常开发没人会去碰这些数字,只有 CI 执行 lane 时才修改。
4.3 接进 GitHub Actions 等 CI 的小例子
既然 Fastlane 本身就是命令行工具,接 CI 就非常简单。以 GitHub Actions 为例,一个最简的 beta 打包流程:
name: beta on: push: tags: ["v*"] jobs: build: runs-on: macos-14 steps: - name: Checkout uses: actions/checkout@v4 - name: Setup Ruby uses: ruby/setup-ruby@v1 with: bundler-cache: true - name: Fastlane Beta run: bundle exec fastlane beta关键在于时机:Fastlane 在 xcodebuild 之前就把版本号写好了,所以它不会遇到 Run Script Phase 那种"第一次构建不生效"的问题。这一点是它比前面两种方案省心很多的地方。
4.4 团队里构建号冲突的实际解法
用上 Fastlane 不代表就万事大吉,团队里最常见的冲突场景是:好几个人同时在各自的 feature 分支上跑 beta lane,大家 push 之后 CI 各自打包。如果 Build 号用的是git rev-list --count HEAD,两个分支的提交数完全可能一样,最后 TestFlight 上就会出现两个版本号、Build 号完全相同的包,后传的直接被拒。
解法按团队情况选:
- 建立单主干开发模式,所有测试包都从主干或 release 分支出,Build 号用提交数,天然不冲突。
- 多分支并行测试时,Build 号改用时间戳,比如
20250107153045,只要不是同一秒提交两个任务,就不会撞。 - 最严格的做法是在 Fastlane lane 里先读取最后一个已经上传成功的 Build 号,在此基础上强制加一。但读取"最后一个构建号"需要别人先帮你记录,维护成本高,一般团队用前两条就够了。
5. 自增之外必须面对的五个实战坑
5.1 TestFlight 报 "A build with the same version and build number already exists"
这个报错应该能排进 iOS 开发者高频报错前三。绝大多数情况不是你的代码出了问题,而是你重复上传了同一个版本号加 Build 号的组合。可能你上午传了 1.0.0(12),下午忘了这回事又打了一个 1.0.0(12) 传上去。
解法很简单:确认这次要传的版本号,然后用agvtool new-version -all或 Fastlane 指定一个新的 Build 号,重新打包上传。真正麻烦的是,如果这个错误是团队成员手动打包时踩的,你还得先查清楚别人是不是刚传过包,这也从侧面证明了把版本号自增收口到 CI 有多重要。
5.2 营销版本号回滚了,但 Build 号没同步
假设你们上架了 1.0.0(1),接下来要发 1.0.1,打成了 1.0.1(2)。测试一轮之后发现 1.0.1 有严重问题,决定回滚到 1.0.0 再补一版。这时候如果你手滑把 Build 号重置成 1,那 1.0.0(1) 已经被 App Store 占用了,再传 1.0.0(1) 会被直接拒绝。
我见过两种团队策略:
| 策略 | 规则 | 优点 | 风险 |
|---|---|---|---|
| 全局单调递增 | Build 号永远只加不减 | 完全规避组合冲突 | 版本号变化时数字不美观 |
| 按版本重置 | 每次升版本号,Build 号从 1 重新计 | 阅读习惯好 | 回滚场景容易撞 |
我的建议是,除非你非常确定版本号永远不会回滚,否则优先选全局单调递增。TestFlight 只关心"版本号 + Build 号"这个组合是否唯一,全局单调递增是最不用动脑子的方案。
5.3 多 target / 扩展场景下的构建号漂移
工程里只要加了 Widget、Share Extension、Notification Service Extension 这些 target,麻烦就来了。你只给主 App 做了自增,扩展的 Build 号还停在很老的数字上。等到 TestFlight 安装测试扩展功能时,扩展和宿主 App 的版本号对不上,会出现一些很隐蔽的行为差异。Apple 对 App Extension 的构建号与容器 App 保持一致这件事有明确要求,最稳妥的做法是让所有 target 共用同一个 Build 号来源。
用 agvtool 的话,直接next-version -all;用 Fastlane 的话,写个循环把每个 target 都过一遍。总之,不要只在主 target 上做文章。
5.4 商店页面版本号和包内版本号对不上
App Store Connect 提交时,你会先在"App 信息"或新版"Version"页面填一个版本号,然后再从 TestFlight 构建列表里选一个构建。如果页面版本号是 1.0.2,而你上传的包 CFBundleShortVersionString 是 1.0.3,那这个构建无论如何都选不进去。报错描述往往绕来绕去,其实就是两个版本号对不上。
这个坑靠脚本解决不了,它会发生在 Xcode 构建完成之后的提交流程里。能规避的手段只有一个:让负责提审的人先确认工程里的 Marketing Version 是多少,再决定商店页面填什么。如果有 release lane,最好在 Fastlane 的 release 流程里把 Marketing Version 一起改了,商店页面对照着填,就能少一次无谓的返工。
5.5 Build 号自增后源码不可复现的问题
最后这个坑比较隐蔽,但对线上问题排查影响很大。任何在构建过程中回写源码文件的自增方案,都会让 git 记录里的源码和实际打出来的产物不一致。Build 号不同,crash 日志、dSYM 符号表对应关系就会乱,排查线上崩溃时整个人都是懵的。
更干净的思路是:不在工程文件里改任何东西,而是通过 xcodebuild 命令行的构建设置覆盖来注入 Build 号:
xcodebuild -workspace YourApp.xcworkspace \ -scheme YourApp \ -configuration Release \ CURRENT_PROJECT_VERSION=${CI_BUILD_NUMBER} \ archive前提是 Info.plist 里 CFBundleVersion 用的是$(CURRENT_PROJECT_VERSION)。这样做之后,源码目录零改动,打包产物拿到的是 CI 注入的编号,仓库随时保持可复现状态。如果你现在准备搭新的 CI 流程,我非常推荐优先考虑这个方案。
6. 版本号与 Build 号的团队规则:一页纸说清,别临场拍脑袋
6.1 语义化版本号在 iOS 里怎么落地
版本号的自增不能纯靠脚本,因为它是产品决策。团队里最好把规则写下来,省得每次发版都要开个会讨论这次是 1.1.0 还是 2.0.0。
- 主版本号:破坏性更新,界面流程或核心功能有大的重构。
- 次版本号:向下兼容的新功能,用户能感知的新东西。
- 修订号:修复 Bug、优化细节,不改变功能边界。
特别提醒,App Store 只接受纯数字的点分版本号,不要加v前缀、不要加-beta后缀。Beta 测试的标识放到 TestFlight 的外部测试组去管理,版本号字符串保持干净。
6.2 Build 号三条路线的对比
| 路线 | 规则示例 | 优点 | 缺点 | 适合场景 |
|---|---|---|---|---|
| 纯数字连续递增 | 1、2、3 | 简单直观,好沟通 | 多人/多 CI 并发容易撞 | 单人项目、纯本地打包 |
| 日期时间 | 20250107153045 | 一眼看出构建时间 | 同一秒并发会撞,需要锁或精确到秒 | 中大型团队 |
| Git 提交数 | git rev-list --count HEAD | 与代码历史强关联 | 分支之间计数不共享 | 主干开发模式 |
选哪条没有标准答案,但至少要满足一个硬条件:在同一个营销版本号下,Build 号严格递增且不重复。其余都是团队自己的偏好。
6.3 我给小团队和大团队的两套配置参考
小团队、单 target、手动打包居多的场景,我会直接用 agvtool 加上 Scheme Pre-action,Debug 不自增,Release 自增,营销版本号手动改。这套方案零额外依赖,一个下午就能配好。
多 target、多人协作、有 CI 的场景,我会用 Fastlane 管理一切,Build 号由 CI 注入,源码零污染;营销版本号在 release lane 里手动指定,再和商店页面版本号严格对应。代码见 4.2 和 5.5,合在一起就是一个相对完整的团队流水线。
我自己现在就是这么维护的:所有 target 的 Build 号统一从 CI 注入,营销版本号只在 release 分支上手动 bump,资源库里的工程文件永远可以被克隆后原样构建。从配置完到现在,团队再也没因为版本号或者构建号的问题中断过发版。这套流程踩过的坑我都写进上面几章了,你按自己团队的规模挑着用就行。