1. 版本号与Build号自动递增:先搞清楚这两个数字是干嘛的
做 iOS 开发的人,迟早会在 Xcode 的 General 选项卡里盯着两个字段发呆:Version 和 Build。有些团队干脆只改 Version,Build 永远停在 1,直到上架被 App Store Connect 拒了才一脸懵。我自己刚入行那会儿也干过这种事,后来被 TestFlight 的“构建版本号已存在”按在地上摩擦过几次,才老老实实把版本管理当回事。
Version(市场版本号)是给用户看的,比如 1.2.0、2.1.1,这个数字直接展示在 App Store 页面上,用户判断“我该不该升级”主要看它。Build(构建版本号)是给系统和开发流程看的,用来区分同一次对外发布之前你打了多少个内部包。每次跑测试、出 TestFlight、提审,Build 号都应该变一个。
关键区别用一句话总结:
Version 代表“这个版本叫什么”,Build 代表“这个版本改到第几次了”。
从技术角度来说,这两个字段在 Xcode 里对应 Info.plist 中的 CFBundleShortVersionString(Version)和 CFBundleVersion(Build)。Xcode 的 Build 设置里也有MARKETING_VERSION和CURRENT_PROJECT_VERSION这两个 Build Settings 变量,如果你用的是新版 Xcode,Info.plist 里通常填的是变量引用,真正改数字是在 Build Settings 里改。
为什么必须让 Build 号保持“只增不减”?因为苹果的服务器靠它区分构建。同一个 Version 下,Build 号重复了就拒绝上传;Build 号变小了也拒绝。这还不算完,排查线上 Crash 的时候,如果你 Build 号混成一团,出问题的包到底是哪一次提交编出来的,根本对不上号。所以“自动递增”不是偷懒,是为了让整个研发链路可追溯。你可以把 Version 理解为书的标题,把 Build 理解为版次,每次印刷(打包)都得有个不重复的编号,否则仓库管理就全乱了。
2. 自增方案选型:手动改、脚本改、还是交给 CI 改
2.1 手动修改:适合个人项目和 Demo
先把最简单的场景说清楚。如果你做的是个人项目、学习 Demo 或者不对外发版的工具类 App,手动改一点问题没有。步骤无非是:点击工程文件 → General → 把 Build 从 1 改成 2,完事。
但手动改有几个隐藏风险:
- 改着改着你忘了上一次 Build 是多少,随手填了个 6,结果之前 TestFlight 已有过 6,上传时报“ITMS-90125: The build version is already in use”,你得去后台确认,再来回折腾。
- 团队成员之间如果都在手动改,今天你改成 5,明天他改回 3,版本号直接倒流,后台上传直接失败。
- 版本号跟 Git 提交没有绑定关系,等你想复盘“线上这个包到底是哪次提交编出来的”,对着号码一通瞎猜,效率极低。
所以,手动方案适合“一个人、不发布、怎么都行”的场景。但凡你要出 TestFlight 包或者提审,往下看。
2.2 脚本自动递增:主流的 Xcode 本地方案
脚本方案的核心思想很简单:在 Xcode 每次构建之前自动读取当前的 Build 号,把数值加一之后写回去。这样不管你是在 Xcode 里直接点 Run,还是在 Archive,Build 号都会自动更新。
实现上通常有两种思路:
- 一种是在 Build Phases 里加一个 Run Script,直接修改 Info.plist 或项目配置文件。
- 另一种是用 xcrun agvtool 这个苹果自带工具,Xcode 原生支持,通过
agvtool next-version -all一条命令自动递增 Build 号。
两种我都用过,个人更推荐前者,也就是基于 PlistBuddy 的脚本方案。原因后面具体讲,简单说就是 agvtool 对项目和 Git 工作区的“侵入性”更强,容易顺便触发一堆文件 diff,而 PlistBuddy 只精准修改一个值。
2.3 CI 流水线注入:团队自动化的归宿
如果你的项目已经接入了 Jenkins、GitHub Actions、GitLab CI 等自动化打包流程,那就没必要在本地用脚本“每次编译都自增”了。更推荐的做法是:由 CI 在打包时动态注入一个唯一的 Build 号,比如用时间戳精确到分钟、或者用 Git 提交次数。
常见的 CI 注入方式是在构建命令里覆盖 Build 号。Xcode 在xcodebuild命令行下支持通过-workspace加-scheme指定工程,你可以用环境变量或者直接在 Info.plist 里写占位符,CI 阶段用 PlistBuddy 改掉,再开始编译。也可以参考一些团队的做法:在工程里设定 Build 号为$CI_BUILD_NUMBER这样的变量,由 CI 系统在执行 xcodebuild 前 export 一下,形成闭环。
这块我不会铺开讲太多,因为不同 CI 平台的配置差异大,你自己根据团队基建去选。核心判断标准是:如果每天至少打一个包、参与的人多,就上 CI 注入;如果只是偶尔手动 Archive,本地 Run Script 足够。
2.4 三种方案对比速览
| 方案 | 优点 | 缺点 | 适合场景 |
|---|---|---|---|
| 手动修改 | 无技术门槛,想改多少改多少 | 容易出错、不可追溯、协作冲突多 | 个人项目、Demo、临时验证 |
| Run Script 自动递增 | Xcode 内无缝生效、可控性强、可区分归档和普通编译 | 需要写脚本、会污染 Git 工作区需注意 | 中小团队、TestFlight/Ad Hoc 打包 |
| CI 注入 | 唯一可靠、与流水线绑定、一键出包 | 依赖 CI 平台、配置成本高 | 团队化、每日构建、自动发布 |
2.5 我为什么强烈推荐脚本方案作为切入点
如果是刚开始折腾版本管理,我建议先别直接上 CI。原因很实际:脚本方案在你本机就能跑通,你不需要懂 Jenkins 怎么配 node、GitHub Actions 怎么写 YAML,也不需要担心 CI 那边环境差异。你只需要在 Xcode 里加一个脚本,把 Build 号自动递增这件事跑通,就已经解决了“每次都忘了改”这个核心痛点。
而且脚本方案本身也是理解 CI 注入方案的阶梯。你在本地把 PlistBuddy 或者 agvtool 玩明白了,以后要接流水线,无非是把同一套命令搬到 CI 的 build 前置步骤里。
3. 实战:在 Xcode 中配置 Build 号自动递增脚本
3.1 主角脚本:基于 PlistBuddy 的精简实现
我最终在工程里长期使用的脚本是这样一段(放在 Build Phases 中):
#!/bin/sh # 自动递增 Build 号 # 注意:请把 "YourAppName" 替换成你的 target 名称,或者用 ${TARGET_NAME} 代替 buildNumber=$(/usr/libexec/PlistBuddy -c "Print CFBundleVersion" "${PROJECT_DIR}/${INFOPLIST_FILE}") echo "当前 Build 号: $buildNumber" newBuildNumber=$((buildNumber + 1)) /usr/libexec/PlistBuddy -c "Set :CFBundleVersion $newBuildNumber" "${PROJECT_DIR}/${INFOPLIST_FILE}" echo "新的 Build 号: $newBuildNumber"这段脚本的核心是/usr/libexec/PlistBuddy,这是 macOS 系统自带的 plist 读写工具,不需要安装任何第三方东西。Print是取值,Set是写值。这里用到的${PROJECT_DIR}和${INFOPLIST_FILE}是 Xcode 在编译时注入的环境变量,分别代表工程根目录和 Info.plist 的文件路径。
这脚本有个前提:你的 Info.plist 里的 Build 号是一个纯数字,比如 1、23、45,不能是 1.0 这种带小数点的写法。后面我会针对多 Target 工程给出增强版。
提示:从 Xcode 13 开始,新建项目的 Info.plist 里 Build 号通常存的是变量
$(CURRENT_PROJECT_VERSION),实际值在 Build Settings 中。这时候上面脚本里的${INFOPLIST_FILE}指向的 plist 文件里是个变量名,直接 Set 会把变量名改成数字,反而破坏了配置。这个问题我放在 3.4 节给出两种兼容方案。
3.2 在 Xcode 中挂载脚本的完整步骤
- 打开 Xcode 工程,点击左侧导航栏里的蓝色工程文件(也就是 Project Navigator 里第一个)。
- 在 TARGETS 列表里选中你的主 target。
- 切到Build Phases标签页。
- 点击左上角的
+,选择New Run Script Phase。 - 把新生成的 “Run Script” 拖到Compile Sources之前(一定要在编译之前执行,因为你希望编译时 Info.plist 里已经是新数字)。
- 在脚本编辑框里粘贴上面的代码。
- 改一下名字,比如改成 “Auto Increment Build Number”,方便以后一眼认出来。
关键点:脚本顺序。如果你把 Run Script 放在 Compile Sources 之后,其实也能生效,因为 Info.plist 的处理发生在 Packaging 阶段。但为了防止各种意外顺序问题,放在最前面永远是安全的。
配置完之后,直接在 Xcode 里 Run 一次,跑到脚本那一行时,Build 号就会自动加一。连续跑两次,你会发现 Build 号变成连续递增的两个数。
3.3 区分“普通编译”和“归档发布”
如果你真的严格按照上面的脚本配置,会发现一个问题:运行一次项目 Build 号就加一。平时在模拟器里跑、真机调试,Run 一下就多一个号,Git 里每天刷出一堆版本号变更。这让很多人很烦躁,因为 Build 号理应是“一包一号”,而不是“一编译就变”。
所以实际使用中,我建议给脚本加一个条件判断,只在 Archive(实际打包导出)时才自增:
#!/bin/sh # 仅当执行 Archive 操作时自动递增 Build 号 if [ "${CONFIGURATION}" = "Release" ]; then buildNumber=$(/usr/libexec/PlistBuddy -c "Print CFBundleVersion" "${PROJECT_DIR}/${INFOPLIST_FILE}") echo "当前 Build 号: $buildNumber" newBuildNumber=$((buildNumber + 1)) /usr/libexec/PlistBuddy -c "Set :CFBundleVersion $newBuildNumber" "${PROJECT_DIR}/${INFOPLIST_FILE}" echo "新的 Build 号: $newBuildNumber" else echo "非 Release 构建,跳过 Build 号自增" fi这种“只在 Release 下自增”的方式是团队项目里最常见的配置。Debug 阶段你要做的是快速验证代码,Build 号本身没有意义,它只会给你带来 Git 工作区一大堆 diff。等到你点 Archive 准备出包了,版本号自动递增才有价值。
还有一种想法是“每次编译都递增,方便 Debug 包也区分”,这种情况适合你把包发给 QA 测试的场景。如果你走的是这种路线,那就要在 Debug 配置里也允许自增,同时注意 Git 提交节奏。
3.4 兼容 Xcode 13+ 的$(CURRENT_PROJECT_VERSION)场景
前面提过,新建工程里 Info.plist 中的 Build 值往往是$(CURRENT_PROJECT_VERSION)这样的变量。针对这个情况,常用的做法有两种:
第一种是直接改 Build Settings 的CURRENT_PROJECT_VERSION。脚本相应地改为:
#!/bin/sh currentVersion=$(/usr/libexec/PlistBuddy -c "Print :CFBundleVersion" "${PRODUCT_SETTINGS_PATH}") echo "当前 Build 号: $currentVersion" newVersion=$((currentVersion + 1)) /usr/libexec/PlistBuddy -c "Set :CFBundleVersion $newVersion" "${PRODUCT_SETTINGS_PATH}"但如果你这么写了,Info.plist 里还是$(CURRENT_PROJECT_VERSION),PlistBuddy 会把变量的引用字符串强行换成一个数字,导致值生效但 Build Settings 里显示的还是旧值,下次 {} 展开时可能把数字当变量名处理,比较别扭。
更稳妥的做法是直接改工程文件里的CURRENT_PROJECT_VERSION。但工程文件是 pbxproj,结构复杂,PlistBuddy 干不了这活。而agvtool正是干这个的:
cd "${PROJECT_DIR}" xcrun agvtool next-version -all这条命令会自动找工程里的所有 target,把CURRENT_PROJECT_VERSION加一。它相当于苹果官方提供的“自动递增 Build 版本号”工具。但正如我前面提到的,agvtool 有个问题:它会把项目文件和多个 target 的配置全部改动一遍,Git diff 里经常出现一堆无关紧要的改动,多人协作时容易撞车。
所以,结合我自己的经验,我的建议是这样的:如果你的工程是新建的,Info.plist 里用的是$(CURRENT_PROJECT_VERSION),就优先用agvtool,省心;如果你的工程是历史工程,Info.plist 里是写死的数字,那就用 PlistBuddy,精准改一处。
你也可以做一个兼容性更好的版本,把两种方式结合起来:优先尝试读取 Info.plist 里的实际数字,如果读出来包含$(就改用 agvtool。不过这种脚本写起来略复杂,小团队其实没必要。
3.5 多 Target 工程的进阶脚本
如果你的 App 有多个 target,比如主 App、Today Widget、Watch App 等,或者一套代码打多个白标(不同客户、不同 Bundle ID),那每个 target 都要有自己的 Build 号。这时候脚本就不能写死在某个 target 里。
推荐的做法是:给每个 target 的 Build Phases 都加上脚本,脚本里用${TARGET_NAME}作为输出标识,在递增前分别读取各自 Info.plist。或者干脆统一把它们指向同一个“版本号源”,让所有 target 的 Build 号保持一致:
# 遍历可能的 Info.plist 路径 plistPaths=( "${PROJECT_DIR}/App/Info.plist" "${PROJECT_DIR}/Widget/Info.plist" ) for plistPath in "${plistPaths[@]}"; do if [ -f "$plistPath" ]; then buildNumber=$(/usr/libexec/PlistBuddy -c "Print CFBundleVersion" "$plistPath") newBuildNumber=$((buildNumber + 1)) /usr/libexec/PlistBuddy -c "Set :CFBundleVersion $newBuildNumber" "$plistPath" echo "${plistPath} 的新 Build 号: $newBuildNumber" fi done这里的核心思想是把所有 target 的 Info.plist 抽出来统一改。你在实际使用中要修改数组里的路径,不是每个工程的目录结构都一样的。
4. 自增之后:与整体打包发布流程的配合
4.1 苹果后台上传对版本号的硬性约束
如果你用 Xcode 直接上传到 App Store Connect,或者用 Transporter 上传 IPA,都会遇到一个问题:Build 号不具备“复用性”。在同一个 Version 下,Build 号一旦上传过,就无法再次使用。哪怕你把上传的构建删掉,那个号也基本算“用废了”。
所以从工程管理角度,Build 号自增的逻辑必须保证“永不落回旧值”。脚本自增方案天然满足这一点,因为你每次点击 Archive 的时候都往上加。
TestFlight 也有一个比较烦人的限制:同一个 Version 下的 Build 号必须大于当前已存在的最高 Build 号。举个极端例子,你的上个版本搞到了 20,这个新版本从 1 开始,上传没问题。但如果你在这个版本里用它做过一次 10,然后回滚到 8,11 之前的号很多都用不了了。
4.2 在 CI 流水线中注入 Build 号的基本姿势
如果你的团队已经走了 CI,一个常见的需求是:每次流水线构建时,让 Build 号等于 Git 提交哈希序号、日期时间戳或者流水线运行序号。这种方式比本地脚本更“大气”,因为每个构建的 Build 号都可以直接对应到 CI 记录,追溯性更强。
具体做法大概是在工程文件中把CURRENT_PROJECT_VERSION设置成一个占位符,比如:
CURRENT_PROJECT_VERSION = 0;然后在 CI 打包命令前,用sed或PlistBuddy动态替换为实际值。例如:
xcrun agvtool new-version -all "${CI_BUILD_NUMBER}"或者直接生成一个时间戳 Build 号:
buildNumber=$(date +%Y%m%d%H%M) xcrun agvtool new-version -all "$buildNumber"这种方案在 GitHub Actions 和 GitLab CI 里非常常见。它和本地 Run Script 的思路不同:本地是“边编边改”,CI 是“先改好再编”。两者不冲突,但如果你已经上了 CI 注入,本地 Run Script 就应该整个关掉,不要两套机制同时生效,否则 CI 每次会先把 Build 号加一,然后又用流水线序号覆盖掉,多绕一圈还会造成 Git 冲突。
4.3 跨平台工程(uniapp、Flutter 等)里的版本号怎么管
热搜词里能看到不少 uniapp 打包 iOS 相关的问题。如果你是做跨平台开发,最终还是要回到 Xcode 工程配置上,只是在自动递增这件事上,原理是相通的,路径稍有不同。
以 uniapp 的 iOS 打包为例,官方一般是让你用生成了的 Xcode 工程去云打包或者本地打包。云打包不需要你直接碰 Xcode,版本号在 manifest.json 里配置,由云端平台帮你处理。但如果你走本地打包,拿到的是一个完整的 Xcode 工程,这时候你同样可以在 Build Phases 里加自增脚本,配置逻辑和原生工程一模一样。
Flutter 工程则是把版本号写在 pubspec.yaml 里:
version: 1.0.0+3加号后面那个 3 就是 Build 号。Flutter 在 iOS 端构建时,会把这串配置传导到 Xcode 工程里对应的MARKETING_VERSION(1.0.0)和CURRENT_PROJECT_VERSION(3)。如果你用了 Flutter,建议版本管理优先改 pubspec.yaml,而不是直接在 Xcode 里改 Build 号,否则每次执行flutter build ipa又会被覆盖回去。
4.4 软件开发版本号命名规则参考
热搜词里也有“软件版本号命名规则参考标准”,这里顺带一提。虽然 Apple 的 Version 和 Build 在实践中语义比较固定,但整个行业里版本号命名往往遵循一套约定,很多人也拿这套约定去指导 iOS 的版本规划。
常见规则是主版本号.次版本号.修订号(如 1.2.0):主版本号在重大功能变更或破坏性更新时递增,次版本号在功能迭代、向后兼容的新增功能时递增,修订号用于 Bugfix 和小改动。Build 号则独立递增,不进入对外展示的版本号逻辑。iOS 本身没有强制你必须遵守这个规则,你写 1.2.3 还是 2024.5.18 都没问题,但团队内部一定要统一,否则用户看到版本号跳来跳去,信任感会下降。
5. 常见问题与排查技巧实录
5.1 Git 工作区污染问题
这也是脚本方案最让开发者头疼的一点。每次 Archive 之后,Info.plist 里的 Build 号变了,Git 工作区里就会出现一个文件变动。这个变动是你不想手动提交的,但如果不提交,下一次别的人拉代码时,Build 号还是旧的,容易撞号。
我的实际做法有两种,供参考:
- 方案 A:每次 Archive 完,把 Build 号的变更单独提交,提交信息就写“chore: bump build to 16”。这样虽然产生了一点噪音,但保证了仓库里的 Build 号始终是最新,不会撞号。
- 方案 B:脚本里检测当前是不是 Git 仓库,用
git diff --quiet判断有没有人动了 Info.plist,如果没有就把递增结果直接提交。这种自动化一条龙的做法效率高,但写在 Run Script 里要格外小心,别在编译过程中来回 commit,会死循环。
5.2 本地跑起来 Build 号没变
很多人配置完脚本后发现 Run 了好几次,Build 号纹丝不动。排查思路按下面几步走:
- 先看 Build Phases 里 Run Script 的勾选框是不是勾上了,有时候你新建了阶段但脚本没启用。
- 确认脚本里日志有没有打印出来。在 Xcode 的 Report Navigator 里找到最近的 Build,点开 Run Script 的输出,看能否看到 “当前 Build 号” 输出。
- 检查 Info.plist 路径是否取到。某些工程里
${INFOPLIST_FILE}取到的是相对路径,但前面拼接了${PROJECT_DIR}导致路径变成“目录/目录/Info.plist”的重复路径,脚本会报文件不存在的错误。 - 如果你只设置了 Release 才自增,但当前跑的是 Debug,自然不会变化——这是正常的。
5.3 Build 号变成负数或奇怪的数字
理论上 Build 号应该是一个纯数字,但有些人会把 Build 号写成 1.0、2.1 这种格式。$((buildNumber + 1))是做算术运算的,遇到这种带小数点的字符串,很可能算出一个 0 或者负数。所以脚本里最好有校验逻辑,至少判断读出来的值是不是整数。
5.4 上传时报 ITMS-90125 等错误
ITMS-90125 是“构建版本号已存在”的通用错误,表示你上传的ipa的 Build 号之前已经用过了。解决办法没有捷径,只能把 Build 号往上加。如果你用的是本地脚本,一般不会重复。如果是手动改配置,大概率是因为你改小了。
5.5 问题速查表
| 现象 | 原因 | 解决方案 |
|---|---|---|
| Run 后 Build 号不变 | 脚本未执行 / 非 Release 配置 | 检查 Run Script 是否勾选、Report Navigator 输出 |
| 上传报 ITMS-90125 | Build 号重复引用 | 把 Build 号递增到一个全新数值 |
| Git 有大量 Info.plist 差异 | agvtool 或脚本改动多个文件 | 改用 PlistBuddy 精准改一处 |
| Build 号被写成变量名 | Info.plist 使用 $(CURRENT_PROJECT_VERSION) | 改用 agvtool 或修改 Build Settings |
| 多 Target 只有主 App 在变 | 其他 Target 未挂脚本 | 在对应 Target 的 Build Phases 中加同样脚本 |
6. 最后分享两个很实用的增强思路
Build 号自增解决的是“每次构建有唯一身份”的问题,但实际研发流程里,版本管理还可以继续往前一步。
一个思路是结合 Git 来生成 Build 号。比如git rev-list HEAD --count可以拿到当前仓库的总提交次数,这个数字天然是递增且唯一的。在 CI 脚本里直接把 Build 号设为这个数,比日期时间戳可读性更强,也比本地累加更抗冲突。
buildNumber=$(git rev-list HEAD --count) xcrun agvtool new-version -all "$buildNumber"另一个思路是给 Build 号加“前缀语义”。比如 Debug 包用D开头,Ad Hoc 包用A开头,App Store 包用R开头。但注意:苹果官方要求上传到 App Store Connect 的 Build 号必须是数字,带字母是过不了校验的。所以这种加前缀的做法只能用来区分归档文件的目录名,不能直接塞进 CFBundleVersion。
我在实际项目里最常用的一套组合是:本地 Debug 编译完全不管 Build 号,只有在打 Ad Hoc 和提审之前用 Release 归档,脚本自动递增 Build 号,同时把 Git 短哈希塞进归档文件的名字里。这样一来,随便拿一个包出来,我都能说清楚它是哪个版本的第几次构建、对应哪次提交。这套经验从刚开始做 iOS 一直沿用到现在,没出过乱子,希望对你有参考价值。