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

[TOC]程序员

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

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

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

平常开发

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

总耗时 = 增量编译 + 连接 + 生成调试信息(dSYM)性能优化

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

  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 系列,从老到新依次是 armv七、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%(预计)

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

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

相关文章
相关标签/搜索