从第一次把 fastlane 接到项目里到现在,差不多有两年的时间。回想起来,最开始看官方文档的感觉是:我膨胀了。一条 lane 串起同步证书、构建、打包、上传,看起来就是几行 Ruby 的事。但真正落到 iOS 和 Android 双端持续集成里,从本机第一个错误提示开始,到 CI 机器上深夜的 red build,坑是一环扣一环的。这篇文章不打算重复官方教程,而是把我在 fastlane 使用过程中真实踩过、并且身边同事也反复踩过的问题,按一条相对顺的逻辑整理出来:先环境、再签名、再 Android、再双端抽象、最后是 CI 上的隐藏雷区。
1. 环境配置:Ruby 版本、gem 源与 bundle exec 的三角关系
1.1 系统 Ruby 的版本限制,比想象中更麻烦
很多刚接触 fastlane 的人习惯先gem install fastlane,在本机跑通了就觉得完事。但如果在一台刚初始化好的 macOS 机器上操作,第一道坎往往不是 fastlane 本身,而是系统自带的 Ruby。
我最初就是直接在系统 Ruby 上装的 fastlane,一切正常,直到某天执行fastlane build时报了一串莫名其妙的警告,提示某个依赖包的版本太低。查了一圈才发现,系统 Ruby 版本是 2.6,而当前 fastlane 的依赖已经很"新"了,两者之间产生了兼容性摩擦。这里有个很容易被忽略的事实:macOS 自带的 Ruby是给系统组件用的,Apple 不会为它持续升级到最新稳定版,你在上面装任何 Gem,其实都在和系统环境"抢地盘"。
后来我用rbenv单独管理 Ruby 版本,才彻底摆脱了这类问题。踩过坑之后的建议是:在项目根目录放一个.ruby-version文件,里面写上团队约定好的 Ruby 版本号,例如 3.1.2。这样不管是本地设备还是 CI 机器,只要执行rbenv install并进入目录,就能自动切换版本。
这里的核心逻辑不是"选哪个 Ruby 版本管理工具",而是"团队所有人必须跑在同一个 Ruby 版本上"。Ruby 的二进制扩展模块太多,版本漂移会引发非常隐蔽的故障——明明代码一样,你的机器能跑,同事的机器就是不行。与其出了问题再逐台排查,不如从环境这一层就统一。
1.2 gem 安装失败与源的问题
如果说 Ruby 版本是"隐雷",那 gem 源的问题就是"明雷"。gem install fastlane慢到还可以接受,真正让人血压上升的是下面这类报错:
ERROR: While executing gem ... (Gem::FilePermissionError) You don't have write permissions for the /Library/Ruby/Gems/2.6.0 directory.这个报错直接指向系统目录没有写权限。不少人的第一反应是用sudo gem install,我可以直说:这是个很糟糕的习惯。用 sudo 把 Gem 装进系统目录,等于之后每一次卸载、升级、切换版本都在和系统权限较劲,而且很多 CI 环境根本不给你 sudo 机会。如果你已经切换到 rbenv 管理,这类权限问题基本不会再出现,因为 Gem 会装进用户目录而不是系统目录。
另一个相关问题是镜像源。如果你所在的网络访问默认源不稳定,gem install会反复失败,这种现象在团队协作里特别分散精力。比较通用的做法是把源切到国内可访问的镜像,然后用bundle来锁定依赖。给你一个我常用的切换方式:
gem sources --remove https://rubygems.org/ gem sources -a https://gems.example.com/ gem sources -l源只是解决"能不能装"的问题,而锁版本解决的是"装出来是不是同一个东西"的问题。这两件事分开看待,排查时会轻松很多。
1.3 用不用 bundle exec:这不是个人偏好
刚用 fastlane 的时候,我习惯全局执行fastlane beta,确实也能跑。直到有一次,团队里有人升级了 fastlane,别的 lane 都正常,唯独其中一个 lane 在调用某个插件时报了语法错误。我一看,插件 API 在新版本里改了签名。这让我意识到:不锁版本的 fastlane,本质上是一个"每次执行结果都可能不同"的构建工具。
对比一下常见的三种 fastlane 使用方式:
| 使用方式 | 优点 | 典型问题 |
|---|---|---|
| 全局 gem 安装,直接执行 | 上手快,命令短 | 版本漂移,团队不一致 |
| Gemfile + bundle exec | 版本锁定,高度可复现 | 首次安装稍慢,需要习惯敲 bundle exec |
| 二进制安装 fastlane | 独立于 Ruby 环境 | 目录不同,容易忘记更新 |
我现在的做法是在工程目录维护一份Gemfile,统一列出 fastlane、插件和 Ruby 依赖,然后全部通过bundle exec fastlane <lane>执行。这个习惯刚开始会有点麻烦,但它带来的稳定收益是巨大的。记住一个原则:持续集成最怕的不是"慢",而是"不确定"。今天能过、明天不能过、这台机器能过、那台机器不能过,这些问题绝大多数都可以靠锁版本和统一执行入口消解掉。
2. iOS 签名配置:match 仓库、证书轮换与导出方式的连环坑
2.1 match 仓库初始化与 clone 方式
如果说环境层只是开胃菜,那 iOS 签名就是正菜里的主菜。iOS 开发者普遍熟悉手动到开发者网站下载证书、双击安装、然后在 Xcode 里勾选描述文件的操作流程,但这套流程放进自动化里根本走不通。原因很简单:自动化构建机器上不会有"人工点击 Target 去勾选签名"的步骤,证书与描述文件必须提前准备好,而且最好能通过命令管理。
match就是 fastlane 为解决这个痛点提供的方案。它的核心思路是:把证书和描述文件加密后放进一个独立的 Git 仓库,本机和 CI 构建时自动从这个仓库拉取并安装到当前系统。听起来很顺,但我在第一次初始化时就踩了坑。
一个非常典型的翻车场景是:match init之后,生成的Matchfile里默认用的是 HTTPS 地址访问仓库。如果你在需要输入 Git 凭据的机器上跑fastlane match development,可能直接就卡在认证环节。而且 HTTPS 方式每次拉取都要处理凭据,不把凭据配好就很难受。我当时的解决方式是改成 SSH 地址,把部署机器的公钥加到 Git 托管平台的账号里,这样 CI 机器上也能无交互访问。
另一个坑是 match 仓库的目录结构。它默认会以证书类型建目录,比如certs/development、profiles/development。如果你在团队里同时维护多个应用,记得在设计仓库时就考虑好目录划分,否则后面match会把所有应用的证书交错放一起,一旦把某个证书删掉,可能影响好几个项目的自动签名。
2.2 证书过期与描述文件失效的"更新幻觉"
证书总会过期,这一点大家都有预期。真正让人头疼的是:你在本地手动更新了证书,但 match 仓库里的描述文件并不会自动跟着变。
第一次遇到这个问题,我是在本机从开发者中心下载了新的证书并安装好,然后直接跑match,以为万事大吉。结果 CI 上构建还是报签名错误。后来才明白,match 做的事是"同步仓库里已存在的证书和描述文件到本机",而不是反过来。也就是说,如果你只在本地更新了证书,仓库里还是旧的那一套,CI 拉下来自然又是旧的。
正确的做法是,每次证书需要更新时,执行带force参数的 match 命令来强制刷新:
bundle exec fastlane match development --force这样 match 会重新生成并上传新的证书与描述文件,替换掉仓库中过期的内容。这里还需要注意一个问题:开发者中心里一个 App ID 只能关联有限数量的描述文件,如果一直用--force而不清理旧记录,开发者中心会积累大量废弃 profile,总有一天会碰到"描述文件数量达到上限"的报错。所以每隔一两个季度,手动登录开发者中心清理一下旧的 profile 是值得保留的运维习惯。
另外提醒一点:不要试图直接去 GitHub 仓库里手动改加密后的证书文件,那只会让事情更乱。match 仓库里的所有内容都是加密的,符合预期的操作方式是"通过 match 命令去改",而不是直接动仓库文件。
2.3 export_method 与导出方式的选择
还有一个我反复踩的坑是export_method配置。fastlane 的gym或者build_app会读取输出类型,常见的有app-store、ad-hoc、development和enterprise。选错类型最直接的后果是:构建成功的包安装不到测试设备上,或者提审时被提示签名类型不正确。
有一次我为了图省事,把所有内部测试包都写成ad-hoc,结果有一批内测设备 UDID 不在描述文件列表里,怎么都装不上。这个问题的根因是不同导出方式对应不同的设备授权逻辑:
| export_method | 适用场景 | 设备限制 |
|---|---|---|
| app-store | App Store / TestFlight 上架审核 | 不限制,需 App Store 审核 |
| ad-hoc | 极少数人内测 | 必须把设备 UDID 加入描述文件 |
| development | 开发阶段调试 | 设备要求与开发证书一致 |
| enterprise | 企业内部分发 | 不限制设备,但需要企业证书 |
我的建议是,把 lane 按使用场景明确划分开。比如一个betalane 专门用ad-hoc,一个releaselane 指定app-store,不要用一个动态参数去应付所有场景。符号越清晰,后面出问题时排查起来越快。
3. Android 构建:签名文件泄漏、任务名错位与参数传递
3.1 keystore 放仓库,迟早要出事
Android 侧最大的问题不是签名技术复杂,而是签名文件的管理方式太随意。我在不少工程里见过把.jks或.keystore直接提交到源码仓库的,理由通常是"方便团队共享"。但只要你稍微想象一下仓库泄露或者离职员工带着密钥副本离开的场景,就知道这个做法有多危险。Android 的签名密钥一旦丢失或被恶意持有,你根本无法证明"商店里那个应用是自己的"。
正规的做法是把签名文件移出代码仓库,放到专门的密钥管理服务里,或者在 CI 后台以加密文件的形式保存。本地开发和正式构建用不同的签名方式,正式构建前由 CI 在构建任务里把解密后的密钥文件放到临时目录,构建完成后自动清理。这里我实际用的方案是:
- 把 keystore 加密后放到 CI 平台的私密配置项中;
- Fastfile 里通过
ENV['ANDROID_KEYSTORE_FILE']等环境变量读取文件路径; - 密码也全部走环境变量,不落到任何硬编码文件。
还要注意,明文密码一旦进了日志就等于泄露。fastlane 的UI.message如果不注意,会把环境变量内容打到构建日志里。CI 平台通常有日志脱敏功能,建议把密码相关的输出统一遮蔽,或者干脆不在日志层打印任何签名相关参数。
3.2 gradle 任务名与 Fastfile 对不上
Android 构建通常依赖 gradle 任务,而 fastlane 的gradleaction 默认会拼接任务名。最常出现的报错是任务名不匹配,比如你心里想着执行./gradlew assembleRelease,但实际上 fastlane 执行的是assembleRelease或bundleRelease,两者存在一字之差,在开启混淆的工程中往往意味着生成物完全不同。
我自己踩过的一个具体例子:为了减少交付体积,我决定改用 AAB 格式提交商店包,于是在 Fastfile 里写了task: "bundle",却没注意到 gradle 实际的任务名是bundleRelease。构建时一路绿灯,但产物目录里根本没有app-release.aab。查了半天才发现是任务名少了一个变体后缀。
建议你在 Fastfile 里把 task 写全,并加上变体信息:
gradle( task: "bundle", build_type: "Release", flavor: "prod", print_command: true )这样会执行bundleProdRelease,而不是一个"看起来差不多"的任务。任何模糊的任务名都值得警惕,毕竟构建工具只认精确匹配。
另外,如果你的工程定义了多个 flavor,比如dev、prod,还要提前想清楚flavor和build_type在任务名里的排列顺序。gradle 默认会拼成assemble<Variant>或bundle<Variant>,你可以在工程根目录用./gradlew tasks查看所有可执行任务名,这一步不要跳过。
3.3 向 gradle 传参的正确姿势
Android 构建经常需要把一些编译参数传给 gradle,比如渠道号、服务器地址、版本后缀。最普遍的做法是环境变量,有时候也会有人把参数硬编码在gradle.properties里。我的建议是区分清楚"哪些是可变的、哪些是固定的"。
固定参数可以直接写在gradle.properties,比如签名相关配置(现在更建议只写占位,实际值从环境变量读)。可变参数应该通过 fastlane 的gradleaction 透传:
gradle( task: "assemble", build_type: "Release", flavor: "prod", properties: { "CHANNEL" => options[:channel], "ENV" => options[:env] } )然后在 Android 工程里用BuildConfig或System.getProperty读取。这里有个注意点:传参时-P和直接在 gradle 命令后面加“key=value”有时会被环境解析接管,尤其是在 CI 的 shell 脚本里,引号转义特别容易出错。我的经验是尽量让 fastlane 的 properties 参数接管,少手动拼接命令字符串。
以上两个平台的坑其实可以归纳为一句话:构建脚本里的每一个"隐式拼接"都是潜在的炸弹,显式写清楚任务名、参数名和产物路径,能避免一大半问题。
4. 一套 Fastfile 管双端:抽象带来的便利与坑
4.1 lane 抽象粒度:太粗和太细都不行
当你的工程同时包含 iOS 和 Android 时,你大概率会想写一个统一的入口,比如fastlane beta,然后内部根据平台路由。这个想法很好,但抽象粒度很讲究。
我见过太粗的 Fastfile,一个 lane 里从上到下写了同步证书、构建 iOS、构建 Android、上传测试平台、发通知,看起来挺美,实际上运行到一半出问题时,日志长到你根本判断不出是哪个环节失败的。也见过太细的 Fastfile,每个小步骤都要拆成一个 lane,导致 lane 之间互相调用要传一堆参数,写起来像在搭积木。
比较稳妥的分层方式是:
- 顶层 lane 只做流程编排:拉取依赖、构建、收集产物、上传;
- 具体能力封装成
private_lane,例如build_ios_app、build_android_app; - 平台特有逻辑写在各自
platform :ios/platform :android代码块里。
这样做的好处是,任何一个环节出问题,你能立刻定位到对应的 lane,并且能单独执行fastlane build_android_app去复现问题,不必每次都跑完整流程。
4.2 双端条件分支的取舍
有不少人会在一个 lane 里写if platform == :ios之类的分支。小工程这么做没毛病,但分支一旦多起来,Fastfile 的可读性会快速下降。更糟糕的是,有些分支逻辑在本地测试时根本测不到,只在 CI 上触发,排查起来特别痛苦。
我的建议是:能靠命名分离的,就不要靠运行时分支。iOS 和 Android 的构建流程差异本来就大,与其在同一个 lane 里铺满if,不如在 lane 层面分开。比如:
fastlane ios dev走 iOS 的开发构建;fastlane android dev走 Android 的开发构建。
只在真正需要"一套命令同时处理双端"的场景(比如发版)才写一个装配层 lane,内部只是依次调用两个平台的私有 lane,不掺入平台判断逻辑。这样每个 lane 都保持"单一职责",出问题不用去猜它走的是哪条分支。
4.3 版本号同步:双端共有的头疼事
双端持续集成还有一个绕不开的点:版本号同步。iOS 需要维护CFBundleShortVersionString和CFBundleVersion,Android 需要维护versionName和versionCode,两边模式还不一样。
fastlane 提供了increment_build_number来改 iOS 版本号,Android 则通常通过 gradle 参数去覆盖。如果你没有一个统一的后端或脚本管理这两套数值,发版时很容易出现"iOS 是 1.2.0(5),Android 是 1.2.0(4)"这种对不上的情况。
我的做法是:在 CI 里用一个固定的版本号源,例如从 Git Tag 或 CI 平台的环境变量中读取,然后分别传给 iOS 和 Android 的构建命令。iOS 侧在构建前执行版本号更新,Android 侧在 gradle 配置里直接用参数覆盖versionCode。至于手动维护两个文件?我建议尽早放弃,因为人工同步版本号一定是持续集成里最不值得踩的坑。
5. 上了 CI 才暴露的问题:密钥托管、缓存与无头环境
5.1 密钥放在哪里才算安全
本地跑 fastlane 和 CI 上跑 fastlane 是两种体验。本地机器上你可能已经通过match把证书拉下来了,密钥文件也放在 home 目录下,一切都很顺。可 CI 节点是"一次性"的,每次构建都可能运行在一台全新虚拟机上,签名文件、密码、环境变量都要重新准备。
密钥托管这块没有太多新鲜方案:优先把密钥放到 CI 平台的 secrets 里,构建前用脚本解密到临时目录,跑完清理。这里想特别提醒的是,不要图省事把密钥写进 Dockerfile 或镜像里。镜像一旦被 push 到共享仓库,密钥就暴露了。密钥文件的生命周期越短、使用范围越窄,就越安全。
5.2 缓存目录让"构建一次"变成了"构建一小时"
持续集成的新手最容易忽视的是缓存配置。没有缓存时,每次构建都要重新下载依赖。iOS 的 Pods、Android 的 Gradle 依赖、Node 系列的 npm 模块,这些加起来的下载量非常可观。刚开始的时候,我们的 CI 配置完全没设缓存,一次 iOS 构建从拉依赖到出包能跑 40 分钟,都是耗在这些重复劳动上。
配置缓存时要特别注意路径不能配错。比如 Android 的 gradle 缓存通常在~/.gradle/caches,iOS 的 CocoaPods 缓存在~/Library/Caches/CocoaPods,如果你只缓存了工程目录,根本没有效果。另外还要区分"该缓存的"和"不该缓存的":构建产物、临时文件不能进缓存,否则 CI 的历史垃圾会越积越多。
5.3 无头环境下的模拟器与执行权限
CI 服务器通常没有显示器,这对跑 UI 测试是个大问题。如果你直接在 CI 上执行scan,大概率会遇到模拟器无法启动或者权限不足的问题。解决思路是先创建一个无窗口模式的模拟器,再在构建脚本里指定device参数。这里有一个容易忽略的细节:模拟器名称要和 Xcode 里可用的设备类型完全匹配,否则 fastlane 会提示找不到设备。
还有一类问题只会在 CI 上出现:证书导入到钥匙串后没有设置访问控制,导致后续构建工具读取证书时被系统拒绝。本地机器你可能会手动点击"始终允许",CI 上没有这一步。所以上 CI 之后,建议在 match 或构建前显式执行一次security set-key-partition-list操作,确保证书能被命令行工具无交互读取。
这些坑有一个共同特点,就是它们不太可能在"你的 Mac 上"复现,而只在干净环境里出现。所以我的一个习惯是:新接一个项目时,一定找一台尽量干净的机器完整跑一遍流程,把所有"本地能过、干净机器不能过"的问题提前消灭掉。这一步省下的时间,远比你想象的要多。
6. 给刚开始接触持续集成的人:一条更省心的上手路径
如果你正准备在项目里引入 fastlane,或者已经开了个头但在双端配置里转悠,建议按下面这个顺序推进,能少走很多弯路。
第一步,先让本机能用一条命令打出 iOS 包和 Android 包,不要急着加插件、加上传逻辑。命令越少,问题越少。第二步,锁版本、统一执行入口,团队的 Ruby 和 fastlane 版本必须一致,这一步不以人的意志为转移。第三步,处理好签名和密钥,iOS 用 match 管理,Android 把 keystore 数字化托管,杜绝任何人手动拷密钥。第四步,再上 CI,先把构建跑绿,再逐步加入通知、上传、版本号更新这些锦上添花的能力。
说实话,fastlane 本身没有多难,难的是它把移动开发里"最需要确定性"的那些环节(签名、证书、版本、密钥)全暴露在了脚本层。它的每一条 lane 看起来都是糖,但糖底下藏着的坑,往往只有在你真正需要稳定产出包的时候才浮出来。
这套系列的第二篇,我大概率会整理一下双端提审与上架发布那一侧的场景:TestFlight 分发、商店内测、发布说明同步之类的问题。如果你在踩坑过程中遇到过上面没有覆盖到的场景,也欢迎在评论区留言,我会尽量在后续文章里补上。