一到发版日,团队里就会不自觉地弥漫一种紧张感:客户端开发盯着打包进度条,测试在旧包和新包之间来回切换,运营在群里一遍遍确认应用商店后台的审核状态——明明只是一次普通的 App 发布,却常常从早忙到晚,还可能在下班前踩出一个幺蛾子。我在这几年负责团队移动端工程化,帮团队把 App 发布的这条路一步步理顺,从一套满是手工操作、文档备忘和临时救火的流程,改造成了一条只要推一个 tag 就能自动构建、签名、上传、通知的流水线。这篇文章就来聊聊我在这个过程中的完整思路、技术选型和踩过的坑,适合被发版流程折磨的移动端开发、技术负责人和 DevOps 同学参考。
1. 发布链路的成本账:为什么"点几下"的活总要从早忙到晚
一个 App 的发布,从用户视角看就是"商店里出现了一个新版本",但从工程视角看,它是一条很长的链路。以我团队之前的 iOS / Android 双端发布为例,一次普通发版需要完成十几个动作:合并发布分支、锁定依赖版本、构建 release 包、生成多套渠道包、上传内部分发平台、通知 QA 验收、配置商店素材、填写隐私合规问卷、提交审核、等待审核结果、设置灰度比例、观察崩溃指标、全量放开。这里面的每一件事,只要有一个步骤靠人工记忆去完成,就多一分不确定性。
1.1 一个典型发版日的完整时间线
我记录过团队手工发版的真实时间线,大家可以对照一下自己经历过几个:
- 10:00 代码冻结,开发停止合入新功能,准备发布分支。
- 10:30 打包同学开始手工构建,发现本机钥匙串里证书没更新,提示签名失败。
- 11:00 找到证书文件,重新导入,继续构建。
- 11:30 构建完成,上传到内部分发平台,把链接发到测试群。
- 12:00 QA 开始验收,发现一个线上回归 bug,开发定位到是某个依赖版本不一致导致的。
- 14:00 修复完成,重新构建、分发。
- 15:30 QA 通过,提交流程进入商店审核,结果后台提示"版本号已存在"。
- 16:00 修改构建号,重新提交。
- 18:00 提交成功,进入等待审核队列。
- 21:00 审核通过,负责发布的人远程操作,手工放量 10%。
- 第二天 10:00 观察了一晚上数据,确认没问题,再手工全量。
这还是一次顺利的发布。不顺利的时候,比如证书过期、隐私问卷被打回、某个市场要求补充资质,整个链路会拉长到两三天。而手工发布最大的问题不是慢,是每一次都像是在重新发明一遍流程——上次谁更新过证书、哪个市场需要特殊截图、灰度比例填多少,全都靠群聊天记录和个人记忆,操作的人一换,整个流程就可能断掉。
1.2 三条隐性成本:时间、一致性和可追溯性
手工发布看起来是"点几下"的活,实际上的成本分散在三个地方。
第一是时间成本。一次发布往往要占用一个客户端开发和一个测试的大半天时间,而且这些时间都是碎片化的:等构建、等审核、等测试。如果团队一个月发两次版,每个月都有两天在"等"和"催"中间消耗掉。
第二是一致性成本。依赖环境不同、本机缓存不同、证书配置不同,会导致同一个分支在不同人手里打出不同的包。最典型的就是"本地构建没问题,CI 上挂了"或者"我本地打的包能装,你打的装不上"。这类问题在团队里几乎每周都能听到一次。
第三是可追溯性成本。一旦线上出了问题,团队要回答的问题是:这个包到底是哪个分支构建的?对应的 commit 是什么?用的哪套证书?手工流程里这些问题要翻聊天记录才能回答,运气不好时根本说不清楚,线上事故就变成了悬案。
1.3 为什么这条链路值得被改造成自动化
你可能会问,我们团队规模不大,一个月就发一两次版,有必要上自动化吗?我的回答是:发布自动化的目的不只是省时间,而是把"不可控"变成"可控"。发布这件事的频率越低,越应该自动化,因为频率低意味着每次重新执行时细节忘得越干净,越容易出错。自动化之后,发版变成了一个可以重复执行的流程,而不是一个依赖运气和记忆的事件。
2. 构建与打包自动化:把"手工流水线"改成"一条命令"
真正开始改造,是从构建打包这一步入手的。原因很简单:构建是整条发布链路的起点,如果构建还是手工操作,后面的签名、上传、通知做得再自动化也白搭。
2.1 工具选型:为什么用 Fastlane + 云 CI 的组合
当时我在评估方案时主要看了三个工具:Fastlane、Jenkins、纯手写 shell 脚本。
- Fastlane:专门面向移动端发布的工具链,生态里有很多现成的 action,比如自动增加构建号、上传 TestFlight、上传应用商店、同步证书描述文件,都是别人踩过坑之后封装的。
- Jenkins:老牌 CI 系统,插件丰富,但本身偏重,而且维护成本不低;如果团队已经有运维同学打理,可以考虑,否则对于移动端团队来说有点杀鸡用牛刀。
- 纯 shell 脚本:最灵活,但很多东西要自己造轮子。比如调用 App Store Connect API 上传、处理签名、动态设置版本号,手写一遍的成本远高于直接用 Fastlane 的现有能力。
我最终选了 Fastlane 负责移动端构建和上传的编排,CI 用云服务(GitHub Actions 或 GitLab CI 视仓库托管位置而定),触发方式统一走 Git tag。这个组合的思路是:CI 负责"什么时候跑",Fastlane 负责"跑什么",两边职责分开,出了问题也好排查。
2.2 最小可用配置:一条 lane 打通构建上传
Fastlane 的核心概念是 lane(车道),一个 lane 就是一条发布流水线。我最早搭的最小配置大概长这样:
# fastlane/Fastfile default_platform(:ios) lane :beta do increment_build_number(xcodeproj: "MyApp.xcodeproj") build_app(scheme: "MyApp", export_method: "app-store") upload_to_testflight(api_key_path: "PathToApiKey.json") end这段配置做了三件事:自动把构建号加一,用 app-store 方式构建,上传到 TestFlight。对应的 CI 配置(GitHub Actions)长这样:
# .github/workflows/release.yml name: Release iOS Beta on: push: tags: - 'v*' jobs: build: runs-on: macos-14 steps: - name: Checkout uses: actions/checkout@v4 - name: Setup run: bundle install - name: Run Fastlane run: bundle exec fastlane beta env: APP_STORE_CONNECT_API_KEY: ${{ secrets.APP_STORE_CONNECT_API_KEY }}注意几个关键点:CI 机器用 macOS runner 是因为 iOS 构建必须在 macOS 上;bundle install用来安装 Fastlane 依赖,保证本地和 CI 的 Fastlane 版本一致;App Store Connect 的 API Key 通过 CI 的 secrets 注入,而不是写死在仓库里。如果你用的是 Android 项目,直接把 lane 换成gradle assembleRelease,runner 换成 ubuntu 就行,整体思路完全一致。
2.3 构建缓存与依赖锁定:让构建更快更稳
自动化之后,构建时间会直接影响发布效率。我在实践中体会到两个原则:一是锁定依赖版本,二是缓存依赖产物。
依赖版本锁定方面,iOS 项目用 Gemfile.lock + Podfile.lock(或 Package.resolved),Android 项目用 Gradle 的版本目录,Node 项目用 package-lock.json。锁定的意义不是"不能升级",而是保证任何人、任何机器在同一时间构建,拿到的依赖是一致的。
依赖缓存方面,CI 一般内置了缓存机制,比如 GitHub Actions 的actions/cache。比如把 Gradle 的~/.gradle/caches、CocoaPods 的Pods目录缓存起来,一次全量构建可能需要十几分钟,命中缓存后往往能省掉一半以上时间。不要小看这十几分钟,发布场景里每一分钟都是团队在等的。
2.4 治理铁律:正式发布物只认 CI 产物
这是我在自动化之后立的第一条规矩:所有要进应用商店的包,必须是从 CI 流水线构建出来的,不允许任何人从自己电脑上打出正式包上传商店。这条规矩听起来严格,但它能消灭一大类问题:本地环境差异导致的包行为不一致、本地不小心打了带测试代码的包、上传之后找不到对应构建产物等等。用户可以自己打包做验证,但"发布"这个动作只认流水线的产物。
提示:如果团队里有人真的需要本地出正式包,比如做紧急线下验证,建议把本地的构建命令也统一封装成 lane,而不是直接点 Xcode 的 Archive 按钮。连命令都统一了,出问题的概率才可能降下来。
3. 签名证书:发布流程里最"玄学"的一环
构建自动化之后,下一个卡住很多团队的环节就是签名证书。它平时不声不响,一到发版日就集中爆发,而且报错信息五花八门,非常难排查。
3.1 证书、描述文件和 keystore,它们在发布时各管什么
先说清楚这几个概念,因为很多发布事故都源于概念没对齐。
iOS 侧有两种东西:一个是证书(Certificate),用来证明"这个 App 是你这个开发者账号打包的",相当于 App 的身份证;另一个是描述文件(Provisioning Profile),它记录了"这个 App 在哪些设备上可以安装、能用哪些能力",相当于门禁卡。身份证和门禁卡缺一不可,而且都有有效期。
Android 侧的签名文件是 keystore,一个以.jks或.keystore结尾的密钥库文件,它承担的身份认证职责比 iOS 更重:Android 的 keystore 一旦丢失,已发布的应用就无法用同一个身份升级,只能换包名重新上架。这个后果是几乎所有安卓团队都该写进交接文档的。
3.2 iOS 证书管理:match 帮你把"玄学"变成仓库里的文件
iOS 证书管理最头疼的是它依赖 Apple 开发者后台、本地钥匙串和描述文件三者的配合。团队里一旦有人离职或换电脑,证书同步就成了一场事故预演。
我现在的做法是用 Fastlane 的 match 工具,把证书和描述文件加密后放到一个独立私有仓库,团队成员和 CI 通过 match 拉取。典型的配置流程:
# 初始化 match,指定证书仓库 git 地址 fastlane match init # 拉取或创建所有签名文件 fastlane match appstore fastlane match developmentmatch 会把证书和描述文件同步到本机,同时自动续期快到期的描述文件。配合 CI 使用后,新同事入职不再需要找老同事要证书导出文件,只要bundle install && fastlane match development就能拿到全套签名文件。这个改变把"证书交接"从玄学变成了普通操作。
3.3 Android 签名与市场签名方案
Android 侧我建议至少做三件事:第一,keystore 文件放入密码管理工具备份,并确保两个人以上知道访问方式;第二,在 build.gradle 里配置签名信息时用环境变量引用密码,不写死明文;第三,认真评估各安卓市场提供的签名方案。
举例来说,Google Play 有 Play App Signing,市场上很多安卓渠道也提供类似的"上传密钥"机制:开发者用上传密钥提交,商店用自己的密钥对分发包重新签名。这样一来,即使上传密钥泄露,也可以申请更换而不影响线上用户。国内一些大市场也有对应的接入方式,上架时按后台指引开启就好。
3.4 多环境签名隔离与过期预警
一个健康的发布工程,签名配置应该和构建环境一一对应:Debug 包、企业分发包、商店包用的是不同的签名。我在 CI 配置里做了一个检查步骤,在打好包之后自动跑一遍签名校验,确保包里的签名确实是预期的那一个。
# Android 查看 APK 签名信息 keytool -printcert -jarfile app-release.apk # iOS 查看描述文件过期时间 security cms -D -i MyProfile.mobileprovision | plutil -p - | grep ExpirationDate另外,我加了一个每月定时任务,检查证书和描述文件的剩余有效期,剩余不足 14 天就在群里发预警。这个定时任务本身只是一个脚本加一个 cron,但避免了"证书在发版当天过期"这种最尴尬的情况。
4. 版本号管理:看不见摸不着,却能上头条的"事故源"
版本号是发布链路里最不起眼的环节,但它一旦出错,轻则上架被拒,重则用户无法升级、线上事故扩大。而且版本号问题经常是延迟暴露的,你发布当天可能看不出任何异常,直到用户反馈"更新不了"才发现。
4.1 版本号和构建号:用户看到的和工程师用到的为什么不能混为一谈
版本号(Version)是给用户看的,通常遵循三段式语义化版本号,比如 1.4.0,表示一个对外发布的功能版本。构建号(Build Number)是给系统识别的,它是一个单调递增的数字,用来区分同一版本的不同构建。用户更新 App 时,商店比较的是 version + build 的组合,如果两个构建的 version 相同但 build 更小,甚至可能被系统认为是旧版本。
把这两个数混为一谈的后果很常见:有人手动改了 version 但忘了改 build,商店提示"版本号已存在";有人上线后需要紧急修 bug,结果 build 号比线上还小,用户怎么刷新都等不到更新。
4.2 让 Git tag 成为版本号的唯一事实来源
我采用了一套很简单的规则:Git tag 是版本号的唯一事实来源,所有构建的版本号都从 tag 里读取,而不是从某个配置文件里读。发布同学要发版时,只需要打一个v1.4.0的 tag,CI 拿到 tag 后自动解析出版本号 1.4.0,同时用 CI 的构建序列号或 commit 数生成构建号。
这里给一个简单的解析脚本思路(bash):
# 从 tag 中提取版本号:refs/tags/v1.4.0 -> 1.4.0 VERSION=${GITHUB_REF_NAME#v*} # 用 commit 数作为连续递增的构建号 BUILD_NUMBER=$(git rev-list --count HEAD)然后把这两个值通过 Fastlane 或 Gradle 参数传入构建流程。这样做的好处是:版本号永远与人为主观臆造无关,CI 每次构建都会得到一个合理递增的构建号,不会出现"重复"或"回退"。
4.3 双端版本一致性怎么落地
如果团队同时维护 iOS 和 Android,我还建议让双端读同一个版本来源。最简单的方式是在仓库根目录放一个version.txt,iOS 和 Android 的构建脚本都读它;或者直接用同一个 Git tag 做触发,iOS 和 Android 的 CI job 各自从 tag 解析版本号。
这里有个很容易忽略的细节:商店后台要求每个版本提交的版本号字符串必须唯一。如果你在 TestFlight 已经传过一个 1.4.0 的 build 90,后面又想重新提一个 1.4.0 的 build 90,是会被拒绝的。所以版本号生成规则里,build 号最好取一个全局递增的值,而不是"每个 lane 自己从 1 开始数"。
4.4 版本号相关事故的常见模式
我见过和版本号相关的事故基本可以归纳成三种模式:一是版本号被手工改重,二是构建号回退导致用户无法升级,三是多端版本不一致导致后端配置和客户端版本匹配不上。这三类问题,用自动化生成版本号 + 双端一致读源基本都能规避掉。具体事故的排查过程,我会在第 6 节详细讲。
5. 发布后的回旋余地:灰度、回滚与实时监控
发布完成不代表事情结束了。真正让团队安心的,是发布之后有预案、有监控、有止损手段。很多团队把精力全花在"让包过审"上,等包上了却没有任何保护措施,一旦出事只能干着急。
5.1 灰度发布不是"放一点流量"那么简单
灰度发布(Staged Rollout)是让新版本先覆盖一小部分用户,观察稳定后再逐步放量的策略。很多人以为灰度就是把比例改成 5%、20%、50%,但在实际操作里,有三个问题必须提前想清楚。
第一是灰度维度。是按用户 ID 的哈希取模,按设备型号,按地区,还是按渠道?不同维度适用的场景完全不同。如果你想验证新版本在老机型上的兼容性,就按设备型号灰度;如果你想验证服务器容量,就按地区灰度。第二是灰度节奏。每个阶段观察多久、看哪些指标、达到什么条件才能进入下一阶段,这需要在发布前定好,而不是发布后拍脑袋。第三是灰度期间发现问题怎么办。是直接回滚,还是通过服务端开关把有问题的新功能关掉?这个预案决定了你是"5 分钟止血"还是"40 分钟救火"。
5.2 三条回滚路径与各自的适用边界
发布后的回滚预案通常有三条路径:
- 商店版本回退:App Store 和 Google Play 都允许开发者选择"保留旧版本"或者在后台把某个版本下架,但商店回滚有一定延迟,而且不能保证所有已更新的用户都能自动回到旧版本。
- 服务端配置开关:在客户端代码里埋好 feature flag,新功能不可用时通过服务端配置实时关闭。这条路径最推荐,因为改动即时生效,不需要用户做任何操作。
- 动态化方案:部分跨端框架支持不发布 App 包就更新业务逻辑,但这类方案的合规边界和安全性需要