过慢的编译速度有非常明显的副作用。一方面,程序员在等待打包的过程中可能会分心,比如刷刷朋友圈,看条新闻等等。这种认知上下文的切换会带来很多隐形的时间浪费。另一方面,大部分 app 都有自己的持续集成工具,如果打包速度太慢, 会影响整个团队的开发进度。
因此,本文会分别讨论日常开发和持续集成这两种场景,分析打包速度慢的瓶颈所在,以及对应的解决方案。利用这些方案,笔者成功的把公司 app 的持续集成时间从 45 min 成功的减少到 9 min,效率提升高达 80%,理论上打包速度可以提升 10 倍以上。如果用一句话总结就是:
在绝对的实力(硬件)面前,一切技巧(软件)都是浮云
日常开发
其实日常开发的优化空间并不大,因为默认情况下 Xcode 会使用上次编译时留下的缓存,也就是所谓的增量编译。因此,日常开发的主要耗时由三部分构成:
总耗时 = 增量编译 + 链接 + 生成调试信息(dSYM)
这里的增量编译耗时比较短,即使是在我 14 年高配的 MacBook Pro(4核心,8 线程,2.5GHz i7 4870HQ,下文简称 MBP) 上,也仅仅耗时十秒上下。我们的应用代码量大约一百多万行,业内超过这个量级的应用应该不多。链接和生成调试信息各花费不到 20s,因此一次增量的编译的时间开销在半分钟到一分钟左右,我们逐个分析:
增量编译: 因为耗时较短(大概十几秒或者更少),几乎不存在优化的空间,但是非常容易恶化。因为只有头文件不变的编译单元才能被缓存,如果某个文件被 N 个文件引用,且这个文件的头文件发生了变化,那么这 N 个文件都会重编译。APP 的分层架构一般都会做,但一个典型的误区是在基础库的头文件中使用宏定义,比如定义一些全局都可以读取的常量,比如是否开启调试,服务器的地址等等。这些常量一旦改变(比如为了调试或者切换到某些分支)就会导致应用重编译。
链接:链接没有缓存,而且只能用单核进行,因此它的耗时主要取决于单核性能和磁盘读写速度。考虑到我们的目标文件一般都比较小,因此 4K 随机读写的性能应该会更重要一些。
调试信息:日常开发时,并不需要生成 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
抛开自动链接 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编译性能优化的研究工作总结,可以说相当全面了。
经过对其中各个参数的查找资料和尝试关闭,按照提升速度的降序排列,简单整理几个:
仅支持 armv7 指令集。手机上的指令集都属于 ARM 系列,从老到新依次是 armv7、armv7s 和 arm64。新的指令集可以兼容旧的机型,但旧的机型不能兼容新的指令集。默认情况下我们打出来的包会有 armv7 和 arm64 两种指令集, 前者负责兜底,而对于支持 arm64 指令集的机型来说,使用最新的指令集可以获得更好的性能。当然代价就是生成两种指令集花费了更多时间。所以在急速打包模式下,我们只生成 armv7 这种最老的指令集,牺牲了运行时性能换取编译速度。
关闭编译优化。优化的基本原理是牺牲编译时性能,追求运行时性能。常见的优化有编译时删除无用代码,保留调试信息,函数内联等等。因此提升打包速度的秘诀就是反其道而行之,牺牲运行时性能来换取编译时性能。笔者做的两个最主要的优化是把Optimize level改成 O0,表示不做任何优化。
使用虚拟磁盘。编译过程中需要大量的磁盘 IO,这主要发生在Derived Data目录下,因此如果内存足够,可以考虑划出 4G 左右的内存,建一个虚拟磁盘,这样将会把磁盘 IO 优化为 内存 IO,从而提高速度。由于打包机器每次都会重编译,因此并不需要担心重启机器后缓存丢失的问题。
不生成 dYSM 文件,前文已经介绍过。
一些其他的选项,参考前面推荐的文章。
在以上几个操作中,精简指令集的作用最大,大约可以把编译时间从 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不常修改的文件二进制化2544.4%
2精简指令集2740%
3关闭编译优化3815.6%
4使用 Mac Pro1566.7%
5虚拟磁盘426.7%
6公司现行方案(2+3+4+5)980%
7黑苹果588.9%
8终极方案(1+2+3+5+7)4(预计)91.1%(预计)
严格意义上讲,文章有点标题党了,因为一句话来说就是:
能用硬件解决的问题,就不要用软件解决。