本文要点
- 本地快,是因为有缓存、增量编、常跑 Debug;CI 慢,是因为常做 Release 全量编。
- 慢的不只是
xcodebuild本身——前面的pod install也会吃掉大量时间。 - 想让 CI 接近本地速度:留住 DerivedData 和 Pods 目录,别每次清空重来。
先对齐:你比的是不是同一件事
很多人吐槽「CI 慢」,其实对比并不公平。本地开发时,你多半是这样:
- 改几行 Dart,Xcode 只增量编译动到的文件;
- 昨天编过的原生依赖还在 DerivedData 里,不用重编;
- 跑的是 Debug,优化级别低、出包快。
而 CI 流水线里,典型做法是:
- 每次 checkout 一份干净的工作区;
- 跑
flutter build ipa或xcodebuild archive,走 Release; - 托管 Runner 上缓存往往存不住,等于每次从零编。
所以「本地 5 分钟 vs CI 15 分钟」并不反常——2–3 倍差距在 Flutter iOS 团队里非常普遍。问题不是「谁对谁错」,而是搞清楚慢在哪一段,再对症下药。
CI 比本地慢的 5 个常见原因
按出现频率排序,通常是下面五条(你可以当成「嫌疑人名单」):
- 没有编译缓存(DerivedData 冷启动)——本地机器上攒了几天的编译产物,CI 每次清空,所有 .m / .swift 文件从头编。
- Release 全量编——CI 要上架或出 IPA,必须 Release;比本地 Debug 慢一截是正常的。
pod install每次都重装——日志里一片Installing …,几分钟就没了,还没轮到 xcodebuild。- 机器内存不够触发 swap——16GB 跑大型 Flutter 工程,xcodebuild 峰值吃满内存,磁盘 swap 会把时间拉爆。
- 排队和多余步骤——单台 Mac Runner 上多条 Job 排队;或 workflow 里每次都
brew install、flutter upgrade。
注意:前三条加起来,往往就能解释「为什么慢 2–3 倍」;后两条是「明明缓存配好了还是慢」时再查。
打开日志:时间到底花在哪
别只看 Actions 页面右上角的总时长。点进 build-ios Job,看每个 step 的耗时,心里可以画一条简单时间线:
| 步骤 | 第一次(冷) | 第二次(热) | 本地开发对比 |
|---|---|---|---|
flutter pub get | 1–3 分钟 | 十几秒 | 本地也有缓存,差不多 |
pod install | 4–9 分钟 | 半分钟–2 分钟 | 本地很少每次重装 |
xcodebuild / build ipa | 8–15 分钟 | 4–8 分钟 | 本地 Debug 增量常 <5 分钟 |
| 签名上传 | 1–3 分钟 | 1–2 分钟 | 本地手动 archive 也有 |
一眼判断法:如果 pod install alone 就超过 5 分钟,先别怪 xcodebuild,去修 Pods 缓存;如果 pod 很快但 xcodebuild 仍超 10 分钟,重点查 DerivedData 有没有命中。更系统的缓存模型可以看姊妹文 为什么 Flutter iOS CI 在云 Mac 上变慢。
xcodebuild 之前:pod install 这条坑
不少同学盯着 xcodebuild,却忽略了它前面还有一道关。CocoaPods 在 CI 里慢,通常就三个原因:
- 没提交
Podfile.lock——每次重新算依赖,时间不可控。 - 没缓存
ios/Pods目录——托管 Runner 跑完就删,下次又从 CDN 拉。 - 多跑了
pod repo update——CI 里一般不需要,锁文件已经钉死版本。
改法很具体:CI 里用 pod install --deployment(只按 lock 文件装,不重新解析),并把 Pods 目录缓存在同一台机器的 SSD 上。自托管 Mac mini 比 GitHub 托管 Runner 省事——缓存直接落本地盘,不用先打包上传到 GitHub 再下载(那一步本身就要几分钟)。
xcodebuild 本身:DerivedData 有没有留住
这是「比本地慢 2–3 倍」的头号原因。本地 Xcode 会把编译中间产物放在 DerivedData 里,你改一行代码,只重新编译受影响的目标;CI 如果每次 Job 都把 DerivedData 指到临时目录,或者干脆 flutter clean,就等于强迫全量重编。
怎么确认缓存没命中?看 xcodebuild 日志里有没有大量 CompileC、SwiftCompile 针对你没改过的 Pod。如果 Firebase、某地图 SDK 每次都被重编,说明 DerivedData 没留住。
实用做法(自托管 Runner 上):
- 把
DERIVED_DATA_PATH固定到用户目录下某个路径,不要在 step 之间删; - 用
Podfile.lock的 hash 做子目录名,换依赖时才换缓存桶; - CI 里不要随便
flutter clean,除非你真的换了 Flutter 大版本。
托管 macos-latest 也能用 actions/cache 存 DerivedData,但大包上传下载很慢,且容易超大小限制——所以很多团队迁到自托管后,xcodebuild 时间能直接砍半,靠的就是「缓存留在机器上」。Runner 怎么搭、缓存目录放哪,见 Mac mini 自托管 Runner 搭建指南。
能立刻见效的改法(按优先级)
不用一次改完,按下面顺序做,通常每一步都能看见收益:
- workflow 里加
concurrency——同一分支新 push 取消旧构建,少排队、少重复编。(见下方代码片段) - 测试和分析放 Linux——
flutter test别占 Mac 时间,失败了也别启动 xcodebuild。 - 持久化 Pods + DerivedData——自托管机器上固定路径;托管 Runner 用
actions/cache并缩小缓存键粒度。 - CI 只编要发的那个 flavor——别在流水线里扫 6 个 flavor,实际只上传 1 个包。
- 固定 Xcode / Flutter 版本——别在 CI 里
brew upgrade或flutter upgrade,避免缓存整体失效。
# 放在 workflow 顶部,减少排队和重复构建
concurrency:
group: ios-${{ github.ref }}
cancel-in-progress: true
# build-ios Job 里:尽量别 clean,固定 DerivedData
env:
FLUTTER_BUILD_MODE: release
# 自托管示例:DerivedData 持久路径(按你机器目录调整)
DERIVED_DATA_PATH: /Users/ci/ci-cache/DerivedData/${{ hashFiles('ios/Podfile.lock') }}
更完整的 Job 拆分(Linux 测、Mac 编)和标签路由,见专题 Hub Flutter + GitHub Actions + Mac mini 架构总览。官方对 workflow 的说明见 GitHub Actions 文档。
对照清单:你的 CI 中了哪几条
对着自己的仓库打勾,勾得越多,xcodebuild 越可能比本地慢 2–3 倍:
- ☐ 每次 CI 都跑 Release / IPA,本地对比的是 Debug
- ☐ Job 里写过
flutter clean或删 DerivedData - ☐
pod install日志里每次大量 Installing - ☐ 使用 GitHub 托管 Runner,没用本地 SSD 缓存
- ☐ 16GB 机器同时跑多条 Job 或模拟器
- ☐ 改 README 也触发了 iOS 全量构建
- ☐ Xcode 版本和本地不一致
勾了前三条,优先修缓存;勾了后两条,优先修 workflow 触发条件和机器规格。磁盘满了也会导致「越编越慢」,可参考 Runner 磁盘清理。
常见问题
本地 5 分钟、CI 15 分钟,正常吗?
在没做缓存、CI 跑 Release 的前提下,正常。目标不是和本地 Debug 一样快,而是让第二次 CI 构建接近你本地改一行代码的速度——热缓存到位后,8–10 分钟以内很常见。
换更快的 Mac 芯片能缩短 xcodebuild 吗?
有帮助,但不如缓存来得猛。M4 比旧 Intel 快一截,但如果每次冷编,照样 15 分钟;留住 DerivedData,往往比换芯片多省 5–8 分钟。
actions/cache 够了,还要自托管吗?
小项目可以。Flutter + 多 Pod 的工程,DerivedData 经常几个 GB,cache 上传下载反而添乱。独占一台 Mac mini、缓存放本地盘,维护成本更低,xcodebuild 也更稳定。
怎么判断优化生效了?
同一 commit 连续跑两次 CI:第二次 pod install 应明显变短,xcodebuild 日志里「重编未改动的 Pod」应消失。总时长从 20+ 分钟掉到 10 分钟以内,说明路走对了。
想让 CI 里的 xcodebuild 接近本地?
关键不是买「最快的云」,而是缓存能留在机器上。kvmboot 云端 Mac mini M4 提供独占硬盘和固定 Xcode 环境,适合当 Flutter CI 的自托管 Runner——日租跑两轮对比冷/热构建,看 xcodebuild 能否从 15 分钟压到 8 分钟,再决定是否月租。