把老项目从 CocoaPods 迁到 SPM,然后又迁了回去
我有迁移计划,却还是提了一个改动 972 个文件的 commit,资深同事要求回退。这篇讲迁过去和迁回来分别做了什么,以及“迁移计划”和“落地计划”的区别。
我之前维护着一个老 UIKit 项目,所有的第三方依赖都在 CocoaPods 上,包括 Firebase、Google Maps、RxSwift、键盘库和日历组件等。工程里配了四个 scheme,用来对应不同的环境。
当时我想把依赖换到 Swift Package Manager。毕竟 CocoaPods 已经进入维护状态,对 Xcode 新工程格式的支持也逐渐滞后。后来我确实完成了迁移,本地也能编过,但过了大概七周,我又把这一切全改了回去。
我确实做了计划,但最后还是提了一个超大 commit
动代码之前,我先整理了一份基准信息,列出了每个依赖库、当时解析出的具体版本号以及迁移策略。方案里有些原则是合理的,比如先平移到同版本,绝不把依赖迁移和版本升级混在一起;像 Firebase 和 Rx 这种全家桶库整套一起迁,不单个单独换;也预估了部分库可能需要调研替代方案或者做改造。
实际动手大概花了两天,结果最大的一个 commit 改了 972 个文件,新增了 104,895 行代码。其中有 103,679 行是第三方源码:这些库没有合适的 SPM 版本,我把它们放进了本地 package。
所以这份方案只考虑了迁什么、怎么对齐版本,却完全没考虑改动量有多大、别人能不能正常 review。现在回头看,这是我最大的疏漏。
迁移过程具体做了什么
第一轮改动后,工程处于混用状态。Firebase 迁到了 SPM,大部分库切到了 SPM 或者把源码放进了本地 package,还有一个本地 package 把第三方依赖集中管起来,替代以前 Podfile 的作用,方便查看项目依赖了哪些库以及对应的版本。Google Maps 则保留在 CocoaPods。因为 Google Maps 的 SPM 版本看起来要求最低 iOS 15,超过了 App 当前支持的最低系统版本,而提升最低系统版本这种改动,显然不适合夹带在构建系统的改造里一起发。
几个小时后,我彻底移除了 CocoaPods。紧接着花了一个晚上修各种报错,包括 SwiftGen、日历组件、复选框库、键盘库以及 Crashlytics 的 build script。第二天上午,我手动把 Google Maps 的 framework 引进了工程。
为什么又退了回去
一位资深同事看了这次改动,觉得风险很大。一次性把所有依赖全切到 SPM,很可能会引入没人能察觉到的隐患。简单说就是改动面太大了,对方要求回退。
如果仔细审视包管理工具涉及的环节,这种担心是站得住脚的。
- 能编过不代表能正常跑。 就算编译成功,资源文件也可能没打进 bundle,构建脚本可能没执行,不同 scheme 下 target 的 membership 也可能对不上。
- 出问题很难定位根因。 改动了将近一千个文件,后续哪怕出现别的问题,也很难讲到底是不是这次迁移引发的。
- 打包环境不一样。 签名和出包都在打包机上走 Jenkins 和 Fastlane,本地机器编译成功并不能说明太多问题。
- 用户端没有任何感知。 风险全部由团队承担,收益却基本只在开发侧。
回退本身也花了一个下午
这次回退没法直接用 git revert,因为那会儿分支上已经合入了其他工作。我把 Podfile、lockfile 和 workspace 重新复原,找回了 CI 和 Fastlane 的配置,把 App 重新接回 CocoaPods,并且给 CocoaPods 无法识别的新工程格式打了补丁。
require 'xcodeproj'
# Remove once CocoaPods understands objectVersion 70.
versions = Xcodeproj::Constants::COMPATIBILITY_VERSION_BY_OBJECT_VERSION
unless versions.key?(70)
Xcodeproj::Constants.send(:remove_const, :COMPATIBILITY_VERSION_BY_OBJECT_VERSION)
Xcodeproj::Constants.const_set(
:COMPATIBILITY_VERSION_BY_OBJECT_VERSION,
versions.merge(70 => 'Xcode 16.0').freeze
)
end
紧接着代码又因为别的原因编不过了。那个键盘库通过不同渠道引入时,module 名字和 API 都不一样,所以我对代码做了兼容,让两套配置都能编译通过。
#if canImport(IQKeyboardManagerSwift)
import IQKeyboardManagerSwift
#elseif canImport(IQKeyboardManager)
import IQKeyboardManager
#endif
func enableKeyboardManager() {
#if canImport(IQKeyboardManagerSwift)
IQKeyboardManager.shared.enable = true
#elseif canImport(IQKeyboardManager)
IQKeyboardManager.shared().isEnabled = true
#endif
}
下次尝试迁移时我依然会保留这种写法,这样在过渡期间无论哪套环境都能正常编译。
我自己写的几个本地 package 则保留了下来,包括 mock 工具和 alert queue。它们本来就不依赖这次迁移,而且已经在项目里用起来了。
事后重新整理的方案
当天下午,我给后续接手的人重新写了一份方案。核心思路就一条:不要把出错机制完全不同的改动混到一起。之前我同时在重叠的分支上做依赖迁移、转 folder-synchronized groups 以及接入 SwiftLint。这些改动要是合在一起上线,根本没法判断是谁引出的 bug,也没法单独回滚某一部分。
调整后的顺序如下:
- 保证 CocoaPods 基线随时可以正常编译。
- 引入 SwiftLint,先用保守的规则,不阻塞存量老代码。
- 挑一小部分依赖,尝试用统一的 SPM 方式管理。
- 明确对 Google Maps 做出取舍决定。
- 四个 scheme 全部验证通过之后,再扩大 SPM 的迁移范围。
- 把选好的目录转成 folder-synchronized groups。
- 单独清理重复的编译警告。
方案里还定义了什么才算真正完成:四个 scheme 都能编译,Fastlane 能打出带签名的包,Jenkins 流水线顺利跑通,SwiftGen 和多语言本地化工作正常,Google Maps 有了明确方案,工程文件的 diff 有人 review 过,并且提前写清楚回滚方案。这些都是留给后续的验收标准,并不是当时我已经完成的事情。
回头来看
第一次尝试让我明白了,“怎么做迁移”和“怎么把改动安全落地”完全是两回事。我最开始的计划里清楚列出了每一个库和版本,却压根没想过同事该怎么 review 这么大的改动,也没想过如果一个月后线上出问题该怎么排查。而同事当时问的,就是这些问题。