如何将 iOS 工程打包速度提升十倍以上

96
作者 bestswifter
2017.08.27 21:54* 字数 3885

过慢的编译速度有非常明显的副作用。一方面,程序员在等待打包的过程中可能会分心,比如刷刷朋友圈,看条新闻等等。这种认知上下文的切换会带来很多隐形的时间浪费。另一方面,大部分 app 都有自己的持续集成工具,如果打包速度太慢, 会影响整个团队的开发进度。

因此,本文会分别讨论日常开发和持续集成这两种场景,分析打包速度慢的瓶颈所在,以及对应的解决方案。利用这些方案,笔者成功的把公司 app 的持续集成时间从 45 min 成功的减少到 9 min,效率提升高达 80%,理论上打包速度可以提升 10 倍以上。如果用一句话总结就是:

在绝对的实力(硬件)面前,一切技巧(软件)都是浮云

日常开发

其实日常开发的优化空间并不大,因为默认情况下 Xcode 会使用上次编译时留下的缓存,也就是所谓的增量编译。因此,日常开发的主要耗时由三部分构成:

总耗时 = 增量编译 + 链接 + 生成调试信息(dSYM)

这里的增量编译耗时比较短,即使是在我 14 年高配的 MacBook Pro(4核心,8 线程,2.5GHz i7 4870HQ,下文简称 MBP) 上,也仅仅耗时十秒上下。我们的应用代码量大约一百多万行,业内超过这个量级的应用应该不多。链接和生成调试信息各花费不到 20s,因此一次增量的编译的时间开销在半分钟到一分钟左右,我们逐个分析:

  1. 增量编译: 因为耗时较短(大概十几秒或者更少),几乎不存在优化的空间,但是非常容易恶化。因为只有头文件不变的编译单元才能被缓存,如果某个文件被 N 个文件引用,且这个文件的头文件发生了变化,那么这 N 个文件都会重编译。APP 的分层架构一般都会做,但一个典型的误区是在基础库的头文件中使用宏定义,比如定义一些全局都可以读取的常量,比如是否开启调试,服务器的地址等等。这些常量一旦改变(比如为了调试或者切换到某些分支)就会导致应用重编译。
  2. 链接:链接没有缓存,而且只能用单核进行,因此它的耗时主要取决于单核性能和磁盘读写速度。考虑到我们的目标文件一般都比较小,因此 4K 随机读写的性能应该会更重要一些。
  3. 调试信息:日常开发时,并不需要生成 dSYM 文件,这个文件主要用于崩溃时查找调用栈,方便线上应用进行调试,而开发过程中的崩溃可以直接在 Xcode 中看到,关闭这个功能 不会对开发产生任何负面影响

日常开发的优化空间不大,即使是庞大的项目,落后的机器性能,关闭 dSYM 以后也就耗时 30s 左右。相比之下,打包速度可以优化和讨论的地方就比较多了。

持续集成

在利用 Jenkins 等工具进行持续集成时,缓存不推荐被使用。这是因为苹果的缓存不够稳定,在某些情况下还存在 bug。比如明明本地已经修复了 bug,可以编译通过,但上次的编译缓存没有被正确清理,导致在打包机器上依然无法编译通过。或者本地明明写出了 bug,但同样由于缓存问题,打包机器依然可以编译通过。

因此,无论是手动删除 Derived Data 文件夹,还是调用 xcodebuild clean 命令,都会把缓存清空。或者直接使用 xcodebuild archive,会自动忽略缓存。每次都要全部重编译是导致打包速度慢的根本原因。以我们的项目为例,总计 45min 的打包时间中,有 40min 都在执行 xcodebuild 这一行命令。

使用 CCache 缓存

最自然的想法就是使用缓存了,既然苹果的缓存不靠谱,那么就找一个靠谱的缓存,比如 CCache。它是基于编译器层面的缓存,根据目前反馈的情况看,并不存在缓存不一致的问题。根据笔者的实验,使用 CCache 确实能够较大幅度的提升打包速度,删除缓存并使用 CCache 重编译后,耗时只有十几分钟。

然而,CCache 最致命的问题是不支持 PCH 文件和 Clang modules。PCH 的本意是优化编译时间,我们假设有一个头文件 A 依赖了 M 个头文件,其中每个被依赖的头文件又依赖了 N 个 头文件,如下图所示:


由于 #import 的本质就是把被依赖头文件的内容拷贝到自己的头文件中来,因此头文件 A 中实际上包含了 M * N 个头文件的内容,也就需要 M * N 次文件 IO 和相关处理。当项目中每增加一个依赖头文件 A 的文件,就会重复一次上述的 M * N 复杂度的过程。

PCH 文件的好处是,这个文件中的头文件只会被编译一次并缓存下来,然后添加到项目中 所有 的头文件中去。上述问题倒是解决了,但很智障的一点是,所有文件都会隐式的依赖所有 PCH 中的文件,而真正需要被全局依赖的文件其实非常少。因此实际开发中,更多的人会把 PCH 当成一种快速 import 的手段,而非编译性能的优化。前文解释过,PCH 文件一旦发生修改,会导致彻彻底底,完完整整的项目重编译,从而降低编译速度。正是因为 PCH 的副作用甚至抵消了它带来的优化,苹果已经默认不使用 PCH 文件了。

用来取代 PCH 的就是 Clang modules 技术,对于开启了这一选项的项目,我们可以用 @import 来替代过去的 #import,比如:

@import UIKit;

等价于

#import <UIKit/UIKit.h>

抛开自动链接 framework 这些小特性不谈,Clang modules 可以理解为模块化的 PCH,它具备了 PCH 可以缓存头文件的优点,同时提供了更细粒度的引用。

说回到 CCache,由于它不支持 PCH 和 Clang modules,导致无法在我们的项目中应用。即使可以用,也会拖累项目的技术升级,以这种代价来换取缓存,只怕是得不偿失。

distcc

distcc 是一种分布式编译工具,可以把需要被编译的文件发送到其他机器上编译,然后接收编译产物。然而,经过贴吧、贝聊、手Q 等应用的多方实验,发现并不适合 iOS 应用。它的原理是多个客户端共同编译,但是绝大多数文件其实编译时间非常短,并不值得通过网络来回传送,这种方案应该只适合单个文件体量非常大的项目。在我们的项目中,使用 distcc 大幅度 增加了打包时间,大约耗时 1 小时左右。

定位瓶颈

在寻求外部工具无果后,笔者开始尝试着对编译时间直接做优化。为了搞清楚这 40min 究竟是如何花费的,我首先对 xcodebuild 的输出结果进行详细分析。

使用过 xcodebuild 命令的人都会知道,它的输出结果对开发者并不友好,几乎没有可读性,好在还有 xcpretty 这个工具可以格式化它:

gem install xcpretty

通过 gem 安装后,只要把 xcodebuild 的输出结果通过管道传给 xcpretty 即可:

xcodebuild -scheme Release ... | xcpretty

下面是官方文档中的 Demo:


我只对其中的编译部分感兴趣,所以简单的做下过滤,我们就可以得到格式高度统一的输出:

Compiling A.m
Compiling B.m
Compiling ...
Compiling N.m

到了这一步,终于可以做最关键的计算了,我们可以通过设置定时器,计算相邻两行输出之间的间隔,这个间隔就是文件的编译时间。当然,也有类似的辅助工具做好了这个逻辑:

npm install gnomon 

简单的做一下排序,就可以看到最耗时的前 200 个文件了,还可以针对文件后缀作区分,计算总耗时等等。经过排查,我们发现一半的编译时间都花在了编译 protobuf 文件上。

工程设置

除了针对超长耗时的文件进行 case-by-case 的分析外,另一种方案是调整工程设置。一般来说,我们的持续集成工具主要是用来给产品经理或者测试人员使用,用来体验功能或者验证 Bug,除非是需要上架 App Store,否则并不需要关心运行时性能。然而在手机上使用的 Release 模式,默认会开启各种优化,这些优化都是牺牲编译性能,换取运行时速度,对于上架的包而言无可厚非,但对于那些 Daily Build 包来说,就显得得不偿失了。

因此,加速打包的思路和优化的思路是完全互逆的,我们要做的就是关闭一切可能的优化。这里推荐一篇文章:关于Xcode编译性能优化的研究工作总结,可以说相当全面了。

经过对其中各个参数的查找资料和尝试关闭,按照提升速度的降序排列,简单整理几个:

  1. 仅支持 armv7 指令集。手机上的指令集都属于 ARM 系列,从老到新依次是 armv7、armv7s 和 arm64。新的指令集可以兼容旧的机型,但旧的机型不能兼容新的指令集。默认情况下我们打出来的包会有 armv7 和 arm64 两种指令集, 前者负责兜底,而对于支持 arm64 指令集的机型来说,使用最新的指令集可以获得更好的性能。当然代价就是生成两种指令集花费了更多时间。所以在急速打包模式下,我们只生成 armv7 这种最老的指令集,牺牲了运行时性能换取编译速度。
  2. 关闭编译优化。优化的基本原理是牺牲编译时性能,追求运行时性能。常见的优化有编译时删除无用代码,保留调试信息,函数内联等等。因此提升打包速度的秘诀就是反其道而行之,牺牲运行时性能来换取编译时性能。笔者做的两个最主要的优化是把 Optimize level 改成 O0,表示不做任何优化。
  3. 使用虚拟磁盘。编译过程中需要大量的磁盘 IO,这主要发生在 Derived Data 目录下,因此如果内存足够,可以考虑划出 4G 左右的内存,建一个虚拟磁盘,这样将会把磁盘 IO 优化为 内存 IO,从而提高速度。由于打包机器每次都会重编译,因此并不需要担心重启机器后缓存丢失的问题。
  4. 不生成 dYSM 文件,前文已经介绍过。
  5. 一些其他的选项,参考前面推荐的文章。

在以上几个操作中,精简指令集的作用最大,大约可以把编译时间从 45 min 减少到 30min 以内,配合关闭编译优化,可以进一步把打包时间减少到 20min。虚拟磁盘大约可以减少两三分钟的编译时间,dSYM 耗时大约二十秒,其它选项的优化程度更低,大约在几秒左右,没有精确测算。

因此,一般来说 只要精简指令集并关闭优化即可,有条件的机器可以使用虚拟磁盘,不建议再做其它修改。

二进制化

二进制化主要指的是利静态库代替源码,避免编译。前文已经介绍过如何分析文件的耗时,因此二进制化的收益非常容易计算出来。由于团队分工问题,笔者没有什么二进制化的经验,一般来说这个优化比较适合基础架构组去实施。

硬件加速

以上主要是通过修改软件的方式来加速打包,自从公司申请了 2013 年款 Mac Pro(Xeon-E5 1630 6 核 12 线程,16G 内存,256G SSD 标配,下文简称 Mac Pro)后,不需要修改任何配置,仅仅是简单的迁移打包机器,就可以把打包时间降低到 15 min,配和上一节中的前三条优化,最终的打包时间大概在 10min 以内。

在我的黑苹果(i7 7820x 8 核 16 线程,16G 内存,三星 PM 961 512G SSD,下文简称黑苹果)上,即使不开启任何优化,从零开始编译也仅需 5min。如果将 protobuf 文件二进制化,再配合一些工程设置的优化,我不敢想象需要花多长时间,预计在 4min 左右吧,速度提升了大概 11 倍。

编译是一个考验多核性能的操作,在我的黑苹果上,编译时可以看到 8 个 CPU 的负载都达到了 100%,因此在一定范围内(比如 10 核以内),提升 CPU 核数远比提升单核主频对编译速度的影响大。至于某些 20 核以上、单核性能较低的 CPU 编译性能如何,希望有经验的读者给予反馈。

优化点总结

下表总结了文章中提到的各种优化手段带来的速度提升,参考原始时间均为 45 min(打包机器:13 寸 MacBook Pro):

方案序号 优化方案 优化后耗时 (min) 时间减少百分比
1 不常修改的文件二进制化 25 44.4%
2 精简指令集 27 40%
3 关闭编译优化 38 15.6%
4 使用 Mac Pro 15 66.7%
5 虚拟磁盘 42 6.7%
6 公司现行方案(2+3+4+5) 9 80%
7 黑苹果 5 88.9%
8 终极方案(1+2+3+5+7) 4(预计) 91.1%(预计)

严格意义上讲,文章有点标题党了,因为一句话来说就是:

能用硬件解决的问题,就不要用软件解决。

iOS开发