还记得去年7月来云课堂这边实习的第一天,我把项目代码 clone 下来之后兴高采烈地点击了一下 run,然后,就没有然后了……

由于整个工程代码量较大,加之自己领到的是一台 Hackintosh,整个编译过程用了足足13分钟,满腔的热情和创造力立马就被消极等待的时间消耗殆尽。之后组里便开始使用 CocoaPods 私有库做模块化开发,每个人只需要编译自己负责的小模块,编译速度得到了一定的提升,但是一旦开始对每个模块进行集成测试或者直接在主工程里进行某个迭代的话,编译耗时过长的噩梦便又如期而至了。

上周在一次内部讲座中,我也提到了这个问题,虽然有同事说 CocoaPods 已经支持了二进制模块,但我后来没有找到这位同事的联系方式[摊手],于是只能自己回家慢慢探索。

构建过程

(觉得太长可以跳过~)

为了解决编译耗时过长这个老大难的问题,我们先可以来看看一个 iOS App 是怎样被编译、链接、装载乃至运行起来的。

我们可以打开 Xcode 左侧的 Report navigator,它记录了每次构建的详细过程,选择 All Messages 可以发现,大致可以分成三个大的过程:编译每个 pod 、编译整个 Pod 工程和编译主工程。

先来看第一个过程,Xcodeproj 工具会为每个 pod 单独创建一个 target ,具体编译过程如下:

  1. 处理 info.plist 文件
  2. clang 编译所有 .m/.c/.mm 源文件为 .o 目标文件
  3. Lb 命令将 .o 文件链接成为一个 Framework
  4. 拷贝所有 .h 头文件与 .bundle 资源文件到 Xyz.framework/Headers 目录
  5. codesign 对 Framework 进行代码签名

第二个过程比较简单,主要就是生成了一个 Pod 工程的静态库(我个人觉得也用不到)。

第三个编译主工程的过程步骤较多:

  1. 写入一些辅助脚本文件到临时文件夹
  2. 创建 app 目录
  3. 检查 Entitlements 文件
  4. 运行 CocoaPods 自定义脚本检查 Manifest.lock
  5. 编译所有 swift 文件,然后 merge 成一个 swiftmodule
  6. 拷贝 Xyz-Swift.h 头文件
  7. 编译 .xcdatamodeld 数据模型文件
  8. 编译 .m/.c/.mm 源文件
  9. 拷贝 .swiftmodule 文件
  10. 拷贝 .swiftdoc 文件
  11. 链接 .o 目标文件
  12. 用 ibtool 编译 xib/storyboard UI文件
  13. 用 actool 编译 AssetCatalog 资源文件
  14. 处理 info.plist 文件
  15. 链接 xibc/storyboardc 文件
  16. 运行 CocoaPods 自定义脚本嵌入生成的 Pod framework
  17. 运行 CocoaPods 自定义脚本拷贝 Pod 里的资源
  18. 拷贝 Swift 标准库到 App 中(包体积增大约18M)
  19. touch 生成 .app 文件
  20. 对 App 进行代码签名

从以上3个过程可以看出,第一个过程如果 pod 数量过多或者第三个过程 .m 文件过多都会致使整编译时间成线性增长O(n),仔细思考便会发现整个编译流程有许多值得优化的地方。比如不用 Swift,不用 xib/storyboard 写 UI,甚至不用 CocoaPods……(开个玩笑)

业界方案

方案一:CocoaPods 组件平滑二进制化解决方案

采用了 cocoapods-packager 和 自定义部分 shell 脚本来生成 Framework,需要开发者添加新的 target 并且手动修改 Xcode 编译选项。

方案二:CocoaPods/Rome

Rome 作为 CocoaPods 官方插件,在 pod install 过程中 hook 了 post_install 方法,从而得以将源码编译成 Framework。其设计思想值得借鉴,但这个库半年未更新,issue 过多,而且我测试也发现编译失败。

方案三:Carthage

CocoaPods 的强大的生态系统与简单易用的中心化包管理思想是 Carthage 所不具备的,目前暂不考虑切换工具。

解决方案

虽然没有找到可以直接拿过来就用的解决方案,但是通过参考业界解决相关问题一些设计思想,逐步形成了自己的一套解决方案,且接入网易云课堂 iOS 工程中测试通过。具体操作如下(隐去部分项目隐私信息):

1.在工程目录下创建一个 BinaryPods.podspec 文件,大概长这样

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
module BinaryPods
NAME = 'BinaryPods'
LIST = [
'PureLayout',
'libextobjc'
]
end
Pod::Spec.new do |s|
s.name = BinaryPods::NAME
s.version = "0.1.0"
s.author = { "Zhihui" => "zhihui.me@gmail.com" }
s.homepage = 'https://huizi.tech'
BinaryPods::LIST.each do |pod|
s.subspec pod do |sub_spec|
sub_spec.vendored_frameworks = "Pods/Carthage/Build/iOS/#{pod}.framework"
end
end
end

其中 module 中存放了需要 Framework 化的 pod,然后在 subspec 中遍历设置 vendored_frameworks

2.在 Podfile 中添加一些代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
load './BinaryPods.podspec'
def develop_pods
if ENV['POD_TYPE'] == 'binary'
BinaryPods::LIST.each do |binary_pod|
pod "#{BinaryPods::NAME}/#{binary_pod}", :path => "."
end
else
pod 'PureLayout'
pod 'libextobjc/EXTScope'
end
# 使用源码调试的 pod
pod 'MJRefresh'
end
target 'testci-dev' do
develop_pods
end
post_install do |installer|
if ENV['POD_TYPE'] == 'build_frameworks'
Pod::UI.puts "Building pod frameworks"
project = installer.pods_project
project.recreate_user_schemes(:visible => true)
project.save
project.targets.each do |target|
Xcodeproj::XCScheme.share_scheme(installer.sandbox.project_path, target)
end
Pod::UI.puts `carthage build --no-skip-current --cache-builds --color always --configuration Debug --platform iOS --project-directory #{installer.sandbox.root}`
end
end

PodfileBinaryPods.podspec 本质上都是用 Ruby 定义的 DSL 语法规则,所以我们完全可以在里面编写 Ruby 代码。首先我们先导入 BinaryPods.podspec,方便共享同一份需要 Framework 化的 pod 数组;之后我们使用环境变量来判断到底是使用 Framework 还是源码;最后我们采用 Rome 的思想,hook post_install 这个方法,可以在 CocoaPods 安装完成后将部分 pod 编译成 framework,编译工具有很多,xcodebuild/xctool/fastlane gym 都可以满足需求,但我找到一个更方便的,那就是 Carthage 。

3.用 fastlane 设置环境变量(可选)

做好前两个步骤已经差不多了,但是有时候就是不太想敲那么长的命令,所以我们可以结合 fastlane 来偷懒。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
desc "生成 Framework "
lane :pod_install_build do |options|
ENV['POD_TYPE'] = 'build_frameworks'
cocoapods
pod_install_binary
end
desc "使用 Framework 编译"
lane :pod_install_binary do |options|
ENV['POD_TYPE'] = 'binary'
cocoapods
end
desc "使用源码编译"
lane :pod_install_source do |options|
ENV['POD_TYPE'] = ''
cocoapods
end

我们可以来看看这3步完成后的效果(使用不同的 fastlane 命令便可一键切换源码<=>Framework):

fastlane pod_install_binary fastlane pod_install_source

说了那么多我们来看看这个方案真正解决的问题:编译耗时过长。在终端里输入 defaults write com.apple.dt.Xcode ShowBuildOperationDuration YES 便可让 Xcode 显示每次编译所花的时间。

我的电脑配置:

  1. Retina MacBook Pro 13年末
  2. CPU 2.4 GHz Intel Core i5 双核
  3. 内存 8GB,SSD 256GB
  4. 设置开启5个线程编译

测试25个 pod 全部采用源码编译:

xx

测试25个 pod 全部采用 Framework 编译:

xx

对比:

xx

我们可以看到将 Pod 源码 Framework 化的巨大好处,编译时间一下子降到了O(1)的复杂度。

接下来

此方案目前仍然还不完善,其暂时只能将部分没有其他依赖的 Pod 二进制化。对于一些拥有复杂依赖的私有库还不能直接打包成 Framework,这也是我接下来需要去解决的问题,不过应用目前的方案已经足以替换项目中绝大部分库,快让你的编译体验飞起来吧!


阅读