1. 为什么我最终选择 fastlane 来串起两端打包流程
1.1 持续集成里真正难管的不是构建命令
做移动端持续集成做久了你会发现,xcodebuild、gradle assembleRelease这种构建命令本身并不难,难的是围绕构建前后那一堆杂事:证书从哪里来、描述文件有没有过期、版本号怎么递增、打出来的包放哪、上传给谁、失败了怎么通知到人。这些事如果全部靠脚本一个个拼,一开始还能撑,等团队一多、机型一多、渠道一多,脚本就开始互相打架。
fastlane 的价值恰恰在这里,它把 iOS 和 Android 两端的这些杂事抽象成了统一的 lane(车道)。我可以在同一套 Fastfile 里定义一个叫release的 lane,里面 iOS 走的是match + gym + pilot,Android 走的是gradle + upload_to_play,两侧写出来的流程结构几乎一致。团队新同学接手时,不需要分别看懂两套完全不同的打包脚本,只要理解 lane 的编排顺序,就能知道整个发布流程在干什么。
1.2 lane 是两端唯一能统一起来的东西
不要指望 iOS 和 Android 的底层操作能统一,签名机制不同、构建系统不同、上传通道不同,硬统一只会增加复杂度。我实际的做法是:把 lane 名统一,把 lane 内部按平台拆分。比如build_release这个 lane,在 iOS 上调用gym,在 Android 上调用gradle,但 lane 的入参、产物命名规则、版本号来源、通知方式都用同一套约定。
这里有一个很关键的心得:lane 的命名要能表达业务意图,而不是表达技术动作。我见过有人把 lane 命名为just_build、just_upload,听起来很清楚,但团队问“我想打一个给测试同学验证的包”时,没人知道该跑哪个 lane。更好的命名是beta_for_qa、release_to_store、build_debug_apk,这样别人一看 Fastfile 就知道该跑哪个。
1.3 先画一个足够简单的 Fastfile 骨架
第一次搭的时候不要贪多,不要每个功能都往 Fastfile 里塞。我的建议是先维护一个最小可用的骨架,后续遇到问题再逐步加。下面这个结构比较接近我在两类项目里的通用起点:
# Fastfile platform :ios do desc "构建 iOS 测试包并上传" lane :beta do match(type: "development") gym(scheme: "App", export_method: "development") upload_to_testflight end end platform :android do desc "构建 Android 测试包" lane :beta do gradle(task: "assembleDebug") end end这个骨架有几个好处:第一,新同学能一眼看懂;第二,环境问题暴露得早,不用等全套流程写完才发现证书有问题;第三,后续加通知、加版本号递增、加产物整理,都是在这个骨架上做增量,不会推倒重来。踩了一圈坑之后我对 fastlane 的定位就是一句话:它是一层胶水,真正干活的是 Xcode、Gradle 和各种后台服务,fastlane 负责把它们的输入输出理顺。
2. 环境准备阶段,先把 Ruby 和依赖关系理顺
2.1 系统自带 Ruby 是第一个大坑
fastlane 是 Ruby 写的,所以跑 fastlane 之前 Ruby 环境必须干净。很多开发机或者 CI 镜像里带的是系统 Ruby,版本老、权限乱,动不动就报Permission denied或者Gem::InstallError。我一开始图省事,直接在系统 Ruby 上sudo gem install fastlane,后来发现每次升级 macOS 系统都会把这份环境弄坏一次,CI 镜像重做之后还得重新装一遍。
后来我改成用rbenv管理 Ruby 版本,项目里放一个.ruby-version,团队所有人包括 CI 都用同一个 Ruby 版本。这个改动看起来多了一步,但收益非常大:第一,fastlane 依赖的很多 gem 对不同 Ruby 版本敏感,版本统一可以避免“本地能跑 CI 不能跑”的经典问题;第二,不会污染系统环境,换项目、换镜像都干净。
如果你用的是 Docker 镜像,更要在镜像构建阶段就装好 Ruby 和 fastlane,不要等运行时再装。CI 上的网络环境往往不稳定,临时安装很容易超时,而且每次跑任务都重新装一次依赖,时间成本高得离谱。
2.2 Gemfile 固定版本比想象中重要
很多团队装 fastlane 时直接执行gem install fastlane,拿到的永远是最新版。这在小团队自己玩没问题,但一进持续集成就麻烦:某天 fastlane 大版本升级,某个 action 的参数变了,你的 Fastfile 突然跑挂了。关键是这种挂掉往往不是你的代码出了问题,而是上游工具变了。
我的做法是每个项目根目录放一个Gemfile:
source "https://rubygems.org" gem "fastlane", "2.220.0" gem "cocoapods", "1.15.2"然后统一用bundle install && bundle exec fastlane来执行所有命令。bundle exec的意义在于强制使用 Gemfile 里锁定的版本,避免系统里装了别的版本被误用。这里有一个很隐蔽的坑:如果你在 CI 的全局环境里也装了 fastlane,但跑命令时忘了加bundle exec,系统可能会优先用全局版本,导致你锁的版本根本没生效。所以我要求团队所有 fastlane 相关命令都写成bundle exec fastlane,并在 CI 脚本里直接写死。
2.3 第一次跑 fastlane doctor 就要全绿
环境配好之后,第一件事不是急着写 Fastfile,而是先跑一遍fastlane doctor。这个命令会检查 Ruby 版本、Xcode 环境、Android SDK、Java 版本、账号登录状态等。很多人忽略这一步,结果跑到后面报一堆莫名其妙的错,最后回头看都是环境没对齐。
我踩过的一个比较典型的坑是 Java 版本。Android 构建任务在 CI 上一直报Unsupported class file major version,排查了半天,发现 CI 镜像默认的 Java 版本太高,Gradle 版本不兼容。后来我把 Java 版本固定,同时在fastlane doctor输出里确认 Android SDK 路径正确,这类问题就很少再出现了。
fastlane doctor还有一个作用:它会列出当前 fastlane 版本和所有插件版本,方便你确认 CI 和本地环境差异。我每次升级 fastlane 后都会在本地和 CI 各跑一次,输出对比一下,再决定是否更新 Fastfile。
2.4 缓存目录处理不好,CI 一天跑八遍全量下载
fastlane 在运行过程中会产生不少依赖和缓存文件。最常见的是bundle install下载的 gem 包,以及 CocoaPods 的Pods目录。如果 CI 每次构建都不做缓存,一个小项目构建一次可能还好,项目一大人数一多,光是下载依赖的时间就够受的。
我给 CI 流水线配了缓存目录,重点缓存三个位置:vendor/bundle、Pods、~/Library/Caches/fastlane对应的缓存目录。但缓存也有坑:缓存了不代表永远对。我碰到过 CocoaPods 依赖更新后 CI 还在用旧缓存,导致编译时找不到新 API。后来我定的策略是:开发分支可以放心用缓存,正式发布的分支强制清理缓存重新拉依赖。发布流程慢几分钟可以接受,但带着旧依赖发布出去是要出事故的。
3. iOS 证书签名相关,这是踩坑重灾区
3.1 match 管理证书的基本原理
iOS 的持续集成里,证书和描述文件是最容易让人崩溃的部分。证书的私钥要装进 CI 机器的钥匙串,描述文件要放到~/Library/MobileDevice/Provisioning Profiles,而且这些文件还会过期、会被 revoke。手动管理的结果就是:某天早上发布,突然报No provisioning profile found。
fastlane 的match解决的是证书和描述文件的集中存储问题。原理很简单:你把所有证书和描述文件加密保存到一个 git 仓库里,然后每台 CI 机器通过match拉取并安装到本地。所有机器的证书都从同一个仓库拿,自然不会出现“这台机器有证书、那台没有”的问题。
我第一次接 match 时犯了一个低级错误:直接把私钥文件和描述文件提交到了项目代码仓库里。这不是 match 的玩法,match 是把文件加密后提交到独立仓库的。用 match 之后,证书私钥不会以明文形式出现在 CI 的构建目录里,拉取到本地后也是装进钥匙串。
3.2 至少遇见过的三个 match 权限问题
第一个权限问题是 git 仓库访问权限。match 仓库通常和代码仓库分开放,CI 需要一把能读这个仓库的钥匙。如果 CI 用的访问令牌权限只给了代码仓库,没给 match 仓库,就会报类似Permission denied (publickey)。解决办法是给 CI 单独配一个只读访问令牌,只授权 match 仓库,别复用个人账号。
第二个权限问题是 match 的加密密码。match 仓库里的内容是用match的密码加密的,CI 上必须通过环境变量MATCH_PASSWORD传入。我见过团队把密码硬编码在 Fastfile 里,然后不小心提交到代码仓库,这等于把证书仓库的钥匙贴在了门口。密码一定要放 CI 的 secret 配置里,本地开发时再从~/.netrc或环境文件读取。
第三个权限问题是证书本身的权限。CI 机器上如果之前用老方式装过证书,新证书装进去后可能因为钥匙串访问控制出问题,导致 Xcode 在签名时找不到私钥。我遇到过一次,match明明显示安装成功,但gym打包时就是报无法访问私钥。后来用security set-key-partition-list把私钥的访问权限放开,问题才解决。这条命令在正常开发环境不常用,但在 CI 上几乎是标配。
3.3 CI 机器上证书装好了但 Xcode 不认
证书“装好了但 Xcode 不认”是 iOS 构建最玄学的问题之一。后来我梳理出几个常见原因。
第一,钥匙串选错了。match默认会把证书装进登录钥匙串(login keychain),但 CI 上跑构建任务的进程未必能访问登录钥匙串,有时它会默认使用空钥匙串。解决办法是显式指定钥匙串路径,并在执行match前先解锁钥匙串。
第二,没有解锁钥匙串。CI 机器通常是无交互的,钥匙串处于锁定状态。我在 CI 脚本里固定加上下面这条:
security unlock-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH"第三,描述文件装到了当前用户目录,但构建进程用的是另一个用户。如果你用 CI 平台自带的 runner,注意确认 runner 跑任务时的用户身份。我踩过一次,本地终端装描述文件没问题,但 CI 任务里用户不同,路径自然不同,导致一直找不到描述文件。
3.4 描述文件缓存导致的诡异问题
描述文件有一个很烦人的特性:同一个 bundle id 下可以有多个描述文件,而且 Xcode 选择描述文件时并不总是按文件名选择,它可能拿旧的缓存文件来用。CI 机器长时间跑任务,~/Library/MobileDevice/Provisioning Profiles里会堆积很多过期文件。
我遇到过这样的场景:match更新了描述文件,打包时却还是用的旧内容,构建产物装到真机上直接弹“描述文件无效”。刚开始以为是 match 没生效,后来发现是旧描述文件还在缓存目录里,构建工具优先读了旧文件。
解决办法比较暴力但很有效:在 CI 的关键 lane 里先清掉描述文件缓存目录,再执行match。也可以给match加上--force参数,强制重新安装。不过--force会使远端仓库产生新提交,如果你用的是只读令牌,--force会失败,所以更推荐手动清理本地缓存目录。
3.5 导出方式和描述文件不匹配
gym打包时有一个export_method参数,常见值是app-store、ad-hoc、development。这个参数必须和你的描述文件类型匹配。我在 CI 上遇到过exportArchive: exportEnvironment not found这种报错,看起来像 Xcode 导出环节出问题,实际上就是export_method传错了。
比如你要出 TestFlight 的包,描述文件是 App Store 类型,但gym里写成了ad-hoc,导出时会报错或者生成错误类型的产物。这个参数建议不要由人每次手动传,而是在 lane 里根据当前分支或当前 lane 名称固定好。发布 lane 固定app-store,测试 lane 固定development,减少人为出错。
另外一个小提醒:gym的export_options里如果手动指定了provisioningProfiles,里面的 bundle id 和描述文件 UUID 必须和 match 仓库里的一致,否则会出现“明明有描述文件,但 Xcode 说找不到匹配的 profile”。
4. Android 侧看着简单,坑都在细节里
4.1 签名信息不能写死在 Fastfile
Android 侧的签名坑和 iOS 不同,主要在于签名文件(keystore)和密钥信息的管理。很多项目把 keystore 文件放在仓库里,甚至把storePassword、keyPassword直接写在build.gradle里。这种做法在持续集成里特别危险,因为任何能访问代码仓库的人都能拿到签名密码。
我的做法是:keystore 文件不进仓库,放 CI 的 secret 文件存储里,构建前通过环境变量把文件路径和密码传给构建过程。Gradle 侧通过环境变量读取签名配置:
signingConfigs { release { storeFile file(System.getenv("KEYSTORE_FILE") ?: "debug.keystore") storePassword System.getenv("KEYSTORE_PASSWORD") keyAlias System.getenv("KEY_ALIAS") keyPassword System.getenv("KEY_PASSWORD") } }这样本地构建时如果没有这些环境变量,会退化到调试签名;CI 上只要正确配置 secret,就能打出正式签名的包。这个方案很土,但很稳,而且对任何 CI 平台都适用。
4.2 gradle 任务命名和产物路径
Android 侧的 fastlane 核心是调用gradle这个 action,但 gradle 任务名经常写错。比如assembleRelease没问题,但如果你开了 flavor(渠道变种),任务名会变成assemble{Flavor}Release。我见过gradle(task: "assembleRelease")在普通项目没问题,在 flavor 项目里直接报Task 'assembleRelease' not found in project。
检查任务名有个很实用的方法:先跑./gradlew tasks看任务列表,确认你需要的任务全名。另外,多模块项目里任务名前要带模块路径,比如:app:assembleRelease,不要写裸任务名。
产物路径同样容易出问题。默认情况下 Android 的产物在app/build/outputs/apk/...下面,但如果项目有多个模块、多个 flavor,产物目录会多一层。我在写上传逻辑时习惯用通配符或者让 Gradle 在构建完成后把产物复制到一个统一目录。
gradle(task: "assembleRelease") apk_path = Dir.glob("app/build/outputs/apk/release/*.apk").first这里有个坑:如果一次任务构建出多个 APK,Dir.glob().first可能拿错文件。更稳妥的做法是构建前确定产物命名规则,或者构建后按修改时间过滤。
4.3 versionCode 自动递增,别只想着加一
Android 的版本号有两个:versionCode是整数,必须递增;versionName是给用户看的字符串。持续集成里每次构建都应该自动递增versionCode,否则上传到应用市场或者测试平台时会提示版本号重复。
fastlane 有现成的increment_version_codeaction,但我不推荐直接用默认行为,因为它默认是在build.gradle里找旧的versionCode然后加一。这个机制在本地单机跑没问题,但在 CI 上会有并发问题:两个任务同时读到同一个旧版本号,同时加一,然后一个成功一个失败。
我的做法是让版本号来源和 CI 的构建序号挂钩,比如把 CI 每次构建的流水线 ID 或者时间戳作为versionCode的增量基准,或者直接维护一个远程版本号文件。简单一点的做法是:
new_code = ENV["CI_PIPELINE_ID"].to_i % 10_0000 || increment_version_code这样做的好处是每次构建的版本号天然唯一,不会因为两个任务同时跑而撞号。当然这只是其中一个思路,具体策略要和你们应用市场的要求对齐。
4.4 多渠道打包产物要整理干净
团队做 Android 发布时经常需要同时出多个渠道包。fastlane 里可以用一个循环处理所有 flavor,但要注意每个 flavor 的签名配置、产物路径都可能不同。我在 Fastfile 里写过类似这样的逻辑:
lane :build_flavors do flavors = ["xiaomi", "huawei", "general"] flavors.each do |flavor| gradle(task: "assemble#{flavor.capitalize}Release") # 复制产物到统一输出目录 Dir.glob("app/build/outputs/apk/#{flavor}/release/*.apk").each do |apk| FileUtils.cp(apk, "dist/#{flavor}.apk") end end end如果构建之前产物目录里有旧文件,最好先清理再构建。CI 上因为磁盘空间不足导致构建失败的情况不少,根因就是每次构建的中间产物和上次的堆在一起,越积越多。我在 fastlane 的 lane 里加了一个简单的清理步骤,只保留最近几次构建产物,其余删除。
5. 上传、通知与第三方平台对接
5.1 TestFlight 上传前需要先构建
第一次用 fastlane 上传 TestFlight 时,我直接写了一个pilot的 lane,上传前没有重新构建,结果上传了一个过期的包。虽然 TestFlight 本身会校验版本号,但如果你本地的产物目录里有旧包,pilot会直接把它传上去,不会帮你确认是不是最新代码构建的。
后来我在 lane 里严格遵循“构建 -> 导出 -> 上传”的顺序,不允许从 CI 的缓存产物直接上传。另外,TestFlight 对版本号有严格要求:build number 必须比上次上传的大,且版本号不能和已经存在的构建重复。如果构建号冲突,上传会报错。解决办法是给每次 CI 构建都生成一个递增的 build number,并且导入到 Info.plist 里。
还有一个容易被忽略的点:上传完成不代表分发完成。上传成功后 TestFlight 后台还要做处理,这个处理有时需要几分钟。如果流程里马上要用这个构建让测试同学下载,需要做一步等待和轮询。fastlane 的pilot有一个wait_for_build_processing参数,设置为true后会自动等待,但超时时间要设得足够长,不然会提前退出。
5.2 用 API 方式上传,不要用账号密码
iOS 上传到 TestFlight 时,fastlane 提供了两种认证方式:一种是用开发者账号的账号密码登录,另一种是用 API Key。账号密码方式在本地可能没问题,但在 CI 上就会遇到双重认证的问题,每次构建都会卡在验证码。后来我改成 API Key 方式,把密钥文件放进 CI 的 secret 配置,通过环境变量传给 fastlane,上传流程再也没有需要人工干预的情况。
Android 上传到 Google Play 也是类似思路,使用服务账号的 JSON 密钥文件。密钥文件同样放到 CI secret 里,不要提交到仓库。我见过团队把服务账号密钥提交到代码仓库,几分钟后就会出现一堆恶意刷量下载,这是很真实的教训。
5.3 上传之后的等待逻辑
这里重点说说第三方分发平台。很多团队会用内测分发平台来分发 APK 给测试同学,这些平台的上传接口通常不会同步返回安装链接,需要轮询或者下载回调。我在对接时踩过的一个坑是:上传接口返回 200 后立刻去取下载链接,结果链接还没生成。后来我加了重试机制,隔几秒查一次,最多查十次。
还有一类平台上传前要做包名和签名校验,如果你传的包是 debug 签名,平台可能直接拒绝。这种情况在 Fastfile 里报错信息不一定明确,很容易让人误以为是网络问题。遇到上传接口报错时,我习惯先本地把 APK 下载下来用apksigner验证一下签名,排除包本身的问题,再去查网络和接口参数。
5.4 通知消息别把敏感信息打进去
构建完成后通知团队是持续集成的常规操作,但通知内容里很容易夹带敏感信息。我见过有人的 Fastfile 在通知消息里把签名密码、API Key 都打进去了,虽然是在内网群,但日志一保留就是长期风险。
我的习惯是通知消息只放三种信息:构建结果、产物下载地址、错误摘要。错误摘要不要贴完整的证书信息、完整的环境变量,只贴错误类型和出错的 lane 名。如果用到 IM 群机器人,消息模板里不要直接展示环境变量对象,要显式挑选字段。这里还有一个细节:如果构建失败,通知里应该带上对应的日志链接或者日志文件,而不是把一堆日志内容直接甩到群里,消息太长反而没人看。
6. 接进 CI 流水线之后才暴露出来的问题
6.1 并发任务和证书锁
单机手动跑 fastlane 时,你很少会考虑并发问题。但接进 CI 流水线后,同一个项目可能同时有多个分支在跑构建,如果没有限制,就会发生两个 release 任务同时执行match去写同一个证书仓库或者同时操作同一个描述文件目录。
我遇到过一次:两个任务同时跑 release,其中一个在签名时突然报找不到描述文件,另一个任务却正常。排查后发现,两个任务在同时安装描述文件,互相覆盖了。后来我在 CI 配置里给 release 类任务加了一个互斥锁,同一时间只允许一个发布任务运行。测试类的构建可以并行,但发布任务必须串行。
另外,match 仓库本身的并发也有问题。如果多个任务同时用非只读模式拉取仓库,可能产生 git 冲突。我让 CI 上所有 match 操作都使用只读模式,证书更新由人工单独跑一个 lane 完成,不要交给 CI 自动改仓库。
6.2 环境变量在 CI 和本地的差异
Fastfile 里读环境变量的方式很随意,常常直接写ENV["KEY"],但 CI 上如果没有配置这个变量,Ruby 不会报错,只会返回 nil。如果你把这个 nil 拼进某个参数里,最后报出来的错误会非常难懂。
我的习惯是做一个统一的环境变量检查:
def require_env(key) value = ENV[key] UI.user_error!("缺少环境变量 #{key}") if value.nil? || value.empty? value end在 lane 开头把所有必需的变量先检查一遍,缺什么第一时间告诉使用者,而不是等构建到一半才报错。还有一个很常见的坑是:本地.env文件和 CI secret 的内容不一致,导致本地跑得好好的,CI 上就炸。我要求所有环境变量都从 CI 平台上统一维护,本地的.env只放非敏感、不影响构建结果的变量。
6.3 超时和资源限制
大项目的编译时间很容易超过 CI 任务的默认超时时间。尤其是 iOS 项目,第一次在 CI 上跑没有缓存的时候,gym可能跑半小时以上。如果 CI 任务设置了 20 分钟超时,那基本必挂。
我处理这类问题的思路是分两步:第一步,给构建任务设置足够长的超时时间,iOS 大型项目直接放宽到 60 分钟;第二步,尽量在 CI 上开启 Xcode 的增量构建和缓存。长期跑下来,真正要优化的不是超时时间,而是缓存命中率。我见过团队成员来回改超时时间,但问题根源是缓存没做好,每次都是全量编译。
Android 侧的资源问题主要是内存。Gradle 构建默认会按机器内存调整编译进程,如果 CI 机器内存小,同时在跑多个构建任务,就可能 OOM。我给 CI 机器设置一个固定任务并发数,避免同一时间跑太多 Android 构建。
6.4 错误日志怎么看
CI 上的错误日志长得像天书,尤其是xcodebuild的日志,几万行输出里只有个别几行是有用的。我总结了一个排查顺序:
第一,先看 fastlane 自己打印的ERROR行和[!]开头的行,fastlane 会把异常原因缩成一句话,这句话通常指明了方向。
第二,找不到有效信息时,再看xcodebuild的错误。在日志里搜error:,比如error: Signing for "App" requires a development team,这说明签名配置问题。
第三,如果日志什么关键信息都没有,就要考虑是否是环境问题。此时重新在本地跑同一个 lane,加上--verbose参数,fastlane 会输出每一步的详细命令和输出,对比本地和 CI 的差异。
我一直建议团队在 Fastfile 里用UI.message多打几行过程日志,比如当前 lane、当前构建目录、当前版本号。CI 日志是事后排查的唯一线索,宁可多打不要少打。
7. 高频问题速查表与排查思路
7.1 每次处理故障的固定套路
快速定位问题的套路其实可以固化成一套流程。遇到构建失败,不要先急着改代码,按下顺序一步步来:
- 第一步:把错误日志截图或保存,确定是哪个 lane 挂的。
- 第二步:确认最近的变更,是代码变更、fastlane 版本变更,还是 CI 配置变更。
- 第三步:在本地最小环境里复现,本地能跑通再对比 CI 环境差异。
- 第四步:如果本地也复现不了,重点排查 CI 独有的东西,比如 secret 配置、缓存目录、并发锁。
这套流程看起来很简单,但我发现团队里效率差异最大就是在这里。有人看到error:就去搜解决方案,结果搜了一小时,回过头来才发现是这个 lane 压根没读到环境变量。
下面是我整理过的一个高频问题速查表,基本都是这两年被问过或者自己踩过的:
| 现象 | 可能原因 | 解决思路 |
|---|---|---|
bundle install很慢或超时 | gem 源不稳定 | 使用固定版本的 bundle 缓存,或在构建前预热依赖 |
Permission denied (publickey) | match 仓库访问权限不足 | 给 CI 单独配置只读访问令牌 |
No provisioning profile found | 描述文件缓存或缺失 | 清理描述文件缓存目录,重新执行 match |
exportArchive: exportEnvironment not found | export_method 与描述文件不匹配 | 检查 gym 的 export_method 参数 |
Task 'assembleRelease' not found | flavor 或模块任务名不对 | 用./gradlew tasks确认任务全名 |
Unauthorized | API Key 无效或过期 | 重新生成密钥文件,确认环境变量已更新 |
| build number 重复 | 多个任务并发或版本号来源冲突 | 用 CI 流水线 ID 或时间戳作为版本号 |
| 上传成功但下载链接无效 | 平台处理有延迟 | 增加轮询等待逻辑 |
xcodebuild退出码 65 | 编译错误或签名问题 | 搜日志error:定位具体错误 |
| 本地能跑 CI 不能跑 | 环境变量或缓存目录不一致 | 对比 env、清理缓存后重跑 |
7.2 几个值得固化的团队习惯
光有速查表还不够,团队协作里的几个习惯能帮你少踩一半坑。
第一个习惯:Fastfile 改动必须走评审。fastlane 和普通代码一样需要审查,尤其是证书相关、上传相关、环境变量相关的改动,一定要有人检查敏感信息是否泄漏、路径是否正确。这个习惯帮我挡住过一次事故,有人把 API Key 路径写错了,如果没被发现,线上发布会拿错密钥。
第二个习惯:每次升级 fastlane 版本后,先在一个专门的分支上跑一遍全量 lane。不要趁发版的时候顺便升级,这个我吃过亏。升级 fastlane 后,某些 action 的参数名变了,当时如果你是在发版窗口发现的,会非常被动。
第三个习惯:给每个 lane 都写desc描述。fastlane 的desc不是摆设,执行fastlane lanes时它会展示出来,对整个团队理解流程非常有用。我之前在一个项目里看到十来个个 lane 没有描述,新同学根本不知道该跑哪个。
8. 我沉淀下来的检查清单,以及一个小技巧
做完这系列踩坑记录,我现在每接一个新项目都会跑一遍自己的检查清单:
- Ruby 版本是否统一,是否使用
bundle exec。 fastlane doctor是否全绿。- 证书是否由 match 管理,CI 钥匙串是否正确解锁。
- Android 签名是否从环境变量读取,keystore 是否在 secret 里。
- 版本号是否唯一且递增,是否与 CI 构建序号关联。
- 产物命名和路径是否统一,是否清理了旧产物。
- 上传平台是否使用 API Key,是否配置了等待和轮询。
- 通知消息是否只包含必要信息。
- CI 发布任务是否加了互斥锁,是否限制了并发。
- 缓存目录是否配置正确,发布分支是否强制清理缓存。
这套清单每次能帮我省下至少半小时的排查时间。
最后分享一个小技巧:如果你只想调试 Fastfile 里的某一个 action,不需要每次跑完整个 lane,可以先执行bundle exec fastlane run 某个_action 参数,这个方法会直接执行单个 action 而不会跑你 lane 里别的内容。比如我想确认当前描述文件状态,直接跑bundle exec fastlane run match type:development readonly:true,非常快,非常适合在排查问题时定位到底哪个环节出了状况。
fastlane 这个东西,用久了你会发现,真正让它变复杂的不是它本身,而是它连接的那些系统的状态。只要环境干净、流程单一、版本固定,它就是一个很听话的工具。希望这份踩坑记录能让你少走几趟弯路。