iOS开发:Build号自动递增方案详解
2026/9/16 17:40:54 网站建设 项目流程

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_VERSIONCURRENT_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 中挂载脚本的完整步骤

  1. 打开 Xcode 工程,点击左侧导航栏里的蓝色工程文件(也就是 Project Navigator 里第一个)。
  2. 在 TARGETS 列表里选中你的主 target。
  3. 切到Build Phases标签页。
  4. 点击左上角的+,选择New Run Script Phase
  5. 把新生成的 “Run Script” 拖到Compile Sources之前(一定要在编译之前执行,因为你希望编译时 Info.plist 里已经是新数字)。
  6. 在脚本编辑框里粘贴上面的代码。
  7. 改一下名字,比如改成 “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 打包命令前,用sedPlistBuddy动态替换为实际值。例如:

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 号纹丝不动。排查思路按下面几步走:

  1. 先看 Build Phases 里 Run Script 的勾选框是不是勾上了,有时候你新建了阶段但脚本没启用。
  2. 确认脚本里日志有没有打印出来。在 Xcode 的 Report Navigator 里找到最近的 Build,点开 Run Script 的输出,看能否看到 “当前 Build 号” 输出。
  3. 检查 Info.plist 路径是否取到。某些工程里${INFOPLIST_FILE}取到的是相对路径,但前面拼接了${PROJECT_DIR}导致路径变成“目录/目录/Info.plist”的重复路径,脚本会报文件不存在的错误。
  4. 如果你只设置了 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-90125Build 号重复引用把 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 一直沿用到现在,没出过乱子,希望对你有参考价值。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询