[贝聊科技]如何将 iOS 项目的编译速度提升5倍

前言

贝聊目前开发的两款App分别是贝聊家长版和贝聊老师版,最近由于在快速迭代开发新功能,项目规模急速增加,单个端业务代码约23万行,私有库约6万行,第三方库代码约15万行,单个客户端的代码行数约60万。如今打包一次耗时须要11~12分钟。虽然还远远比不上 Facebook 的40分钟,可是咱们在内测的时候,常常一天要发布内测版两到三次。打包时CPU占用基本上是百分百的,由于没有专门的 CI 机器,对负责打包的同事(其实就是我本身)的工做时间占用比较多,因此最近一直在寻找加快打包速度的方案。html

目前的项目架构

咱们的项目使用 CocoaPods 来管理第三方库和私有库的依赖,对大部分项目来讲应该是标配了。目前仍是纯 Objective-C 的项目,没有引入 Swift。git

调研过的方案

下面列出我研究过的一些主流方案以及我最后没有采用的缘由,这些方案有各自的局限性,可是也给了我很多启发,思考过程跟最终方案同样有价值。程序员

cocoapods-packager

cocoapods-packager 能够将任意的 pod 打包成 Static Library,省去重复编译的时间,必定程度上能够加快编译时间,可是也有自身的缺点:github

  1. 优化不完全,只能优化第三方和私有 Pod 的编译速度,对于其余改动频繁的业务代码无能为力
  2. 私有库和第三方库的后续更新很麻烦,当有源码修改后,须要从新打包上传到内部的 Git 仓库
  3. 过多的二进制文件会拖慢 Git 的操做速度(目前还没部署 Git 的 LFS
  4. 难以调试源码

Carthage

这个方案跟 cocoapods-packager 比较相似,优缺点都差很少,但 Carthage 能够比较方便地调试源码。由于咱们目前已经大规模使用 CocoaPods,转用 Carthage 来作包管理须要作大量的转换工做,因此不考虑这个方案了。xcode

Buck

Buck 是一套通用的构建系统,由 Facebook 开源。最大的特点是智能的增量编译能够极大地提升构建速度。最先据说 Buck 的时候,它还只能用在安卓上,如今已经适配了 iOS。缓存

它能增快构建速度的主要缘由是缓存了编译结果,经过持续监视项目目录的文件变化,每次编译时只编译有改动的文件。另一个让我很受启发的功能是 HTTP Cache Server,经过一台缓存文件服务器来保存你们的编译结果,这样只要团队里其中一人编译过的文件,其余人就不用再编译了,直接下载就行。ruby

Buck 是个至关完备的解决方案,不少国外的大公司例如 Uber 都已经用上。我也花了不少时间来研究,最终仍是认为对咱们的项目和团队来讲,目前并非很适合,主要缘由是:bash

  1. Buck 抛弃了 Xcode 的项目文件,须要手工编写配置文件来指定编译规则,这要对现有项目做出大幅度的调整。咱们目前还在快速迭代新功能,没有余暇和人手来实施。
  2. 开发和调试的流程都得作出很大的改变。由于 Buck 接管了项目编译的过程,想调试项目不能简单地在 Xcode 里面 ⌘+R 了,得先反过来让 Buck 生成 Xcode 的项目文件。Uber 的工程师甚至推荐使用 Nuclide 来代替 Xcode 做为开发环境。虽然原理上是可行的,可是团队须要花很多时间来适应,短时间内效率下降无可避免。
  3. 用 Xcode 调试代码享受不到加快编译速度的好处。虽然能够用 buck 命令启动 App,而后在命令行里启动 lldb 来调试,但那就没法使用 Xcode 的调试工具 例如 View Debugging 和 Memory Graph Debugger。

Bazel

Bazel 跟 Buck 很类似,是 Google 开源的,优缺点跟 Buck 都差很少,再也不详细说了。服务器

distcc 分布式编译

原理是把一部分须要编译的文件发送到服务器上,服务器编译完成后把编译产物传回来。我尝试了一下比较出名的 distcc,搭建过程比较简单,最后也能成功地把编译任务分派到内网的多台服务器上。可是其余编译服务器的 CPU 占用老是很低,只有 20% 左右;也就是说分派任务的速度甚至还赶不上服务器编译的速度,分派任务而后回传编译产物这个过程所耗费的时间超过了本地直接编译。不停调整参数反复试验了不少次,最后发现编译时间彻底没有变快,甚至还有点变慢了。可能以咱们目前项目的规模并不适合使用分布式编译。架构

最终方案:CCache

先来看看我对于解决方案的诉求:

  1. 能大幅度地提高编译速度,起码要减小掉 50% 的编译时间
  2. 不须要对项目做出重大调整
  3. 不须要改变开发工具链

CCache 是一个可以把编译的中间产物缓存起来的工具,在其余领域已经有很多应用,只是在 iOS 界的实践比较少。通过个人实践,它可以知足我前面的三点要求。我最先认识到它是搜到了这篇文章:pspdfkit.com/blog/2015/c…

若是你不使用 CocoaPods,参照上面的文章便可。由于针对 CocoaPods 须要做出一些额外的调整,因此仍是说明一下。下面就来讲说要怎样把 CCache 应用在用 CocoaPods 做为包管理工具的 iOS 项目中。

安装步骤:

注意:项目路径不能有中文,不然会影响 CCache 的正常工做

安装 CCache

首先你须要在电脑上安装 Homebrew,对使用 macOS 的程序员来讲应该是标配,略过。

经过 Homebrew 安装 CCache, 在命令行中执行
$ brew install ccache

命令跑完后即安装成功。

建立 CCache 编译脚本

为了能让 CCache 介入到整个编译的过程,咱们要把 CCache 做为项目的 C 编译器,当 CCache 找不到编译缓存时,它会再把编译指令传递给真正的编译器 clang。

新建一个文件命名为ccache-clang, 内容为下面这段脚本,放到你的项目里

ccache-clang

#!/bin/sh
if type -p ccache >/dev/null 2>&1; then
  export CCACHE_MAXSIZE=10G
  export CCACHE_CPP2=true
  export CCACHE_HARDLINK=true
  export CCACHE_SLOPPINESS=file_macro,time_macros,include_file_mtime,include_file_ctime,file_stat_matches

  # 指定日志文件路径到桌面,等下排查集成问题有用,集成成功后删除,不然很占磁盘空间
  export CCACHE_LOGFILE='~/Desktop/CCache.log'
  exec ccache /usr/bin/clang "$@"
else
  exec clang "$@"
fi复制代码

在命令行中,cd 到 ccache-clang 文件的目录,把它的权限改为可执行文件
$ chmod 777 ccache-clang

若是你的代码或者是第三方库的代码用到了C++,则把ccache-clang这个文件复制一份,重命名成ccache-clang++。相应的对clang的调用也要改为clang++,不然 CCache 不会应用在 C++ 的代码上。

ccache-clang++

#!/bin/sh
if type -p ccache >/dev/null 2>&1; then
  export CCACHE_MAXSIZE=10G
  export CCACHE_CPP2=true
  export CCACHE_HARDLINK=true
  export CCACHE_SLOPPINESS=file_macro,time_macros,include_file_mtime,include_file_ctime,file_stat_matches

  # 指定日志文件路径到桌面,等下排查集成问题有用,集成成功后删除,不然很占磁盘空间
  export CCACHE_LOGFILE='~/Desktop/CCache.log'
  exec ccache /usr/bin/clang++ "$@"
else
  exec clang++ "$@"
fi复制代码

完成后项目中应该有这两个文件

scripts
scripts

Xcode 项目的调整

定义CC常量

在你项目的构建设置(Build Settings)中,添加一个常量CC,这个值会让 Xcode 在编译时把执行路径的可执行文件当作 C 编译器。

user-defined-build-settings
user-defined-build-settings

CC
CC

CC常量的值为 $(SRCROOT)/ccache-clang,若是你的脚本不是放在项目根目录,则自行调整路径。若是一运行项目就报错,检查下路径是否是填错了。

关闭 Clang Modules

由于 CCache 不支持 Clang Modules,因此须要把 Enable Modules 的选项关掉。这个问题在 CocoaPods 上如何处理,后面会讲。

enable-modules
enable-modules

关闭了 Enable Modules 后须要做出的调整

由于关闭了 Enable Modules,因此必须删除全部的 @import语句,替换为#import的语法
例如将 @import UIKit 替换为 #import <UIKit/UIKit.h>。以后,若是你用到了其余的系统框架例如 AVFoundation、CoreLocation等,如今 Xcode 不会再帮你自动引入了,你得要在项目 Target 的 Build Phrase -> Link Binary With Libraries 里面本身手动引入。

测试效果

尝试编译一遍,而后在命令行里输入 ccache -s 就能看见相似下面的 ccache 运行状况统计:

cache directory                     /Users/mac/.ccache
primary config                      /Users/mac/.ccache/ccache.conf
secondary config      (readonly)    /usr/local/Cellar/ccache/3.3.4_1/etc/ccache.conf
cache hit (direct)                 14378
cache hit (preprocessed)            1029
cache miss                          7875
cache hit rate                     66.18 %
called for link                       61
called for preprocessing              48
compile failed                         2
preprocessor error                     4
can't use precompiled header          70
unsupported compiler option         2332
no input file                         11
cleanups performed                     0
files in cache                     35495
cache size                           1.3 GB
max cache size                       5.0 GB复制代码

若是成功接入,就能看见 cache miss 不为0。由于第一次编译没有缓存,确定是全 miss 的。接着编译第二遍,若是能看见 cache hit 的数字开始飙升,恭喜你,接入成功了。

CocoaPods 的 处理

若是你的项目不用 CocoaPods 来作包管理,那你已经彻底接入成功了,不用执行下面的操做。

由于 CocoaPods 会单独把第三方库打包成一个 Static Library(或者是Dynamic Framework,若是用了 use_frameworks!选项),因此 CocoaPods 生成的 Static Library 也须要把 Enable Modules 选项给关掉。可是由于 CocoaPods 每次执行 pod update 的时候都会把 Pods 项目从新生成一遍,若是直接在 Xcode 里面修改 Pods 项目里面的 Enable Modules 选项,下次执行pod update的时候又会被改回来。咱们须要在 Podfile 里面加入下面的代码,让生成的项目关闭 Enable Modules 选项,同时加入 CC 参数,不然 pod 在编译的时候就没法使用 CCache 加速:

post_install do |installer_representation|
  installer_representation.pods_project.targets.each do |target|
    target.build_configurations.each do |config|
      #关闭 Enable Modules
      config.build_settings['CLANG_ENABLE_MODULES'] = 'NO'

      # 在生成的 Pods 项目文件中加入 CC 参数,路径的值根据你本身的项目来修改
      config.build_settings['CC'] = '$(PODS_ROOT)/../ccache-clang' 
    end
  end
end复制代码

须要注意的是,若是你使用的某个 Pod 引用了系统框架,例如AFNetworking引用了System Configuration,你须要在你本身项目的Build Phrase -> Link Binary With Libraries里面代为引入,不然你编译时可能会收到 Undefined symbols xxx for architecture yyy一类的错误。有点回到了原始时代的感受,但考虑到编译速度的极大提高,这一点代价能够接受。

集成问题排查

重点关注日志文件的输出和ccache -s 命令的统计,若是在日志中看到了 unsupported compiler option -fmodules 这样的字眼,就是你的 Enable Modules 没有关掉了,根据前面的步骤仔细检查。其余问题,参考官方文档的 Troubleshooting

进一步的优化

移除 Precompiled Header File

PCH 的内容会被附加在每一个文件前面,而 CCache 是根据文件内容的 MD4 摘要来查找缓存的,所以当你修改了 PCH 或者 PCH 引用到的头文件的内容时,会形成所有缓存失效,只能全体从新编译。CCache 在首次编译的时候由于须要更新缓存,会形成编译时间变长,对贝聊的项目来讲变长了差很少一倍。所以若是 PCH 或者 PCH 引入的文件被频繁修改的话,缓存就会频繁地 miss,这种状况下还不如不用 CCache。

为了不以上这种状况,我建议在 PCH 里面尽可能少引入头文件,只保留比较少更改的系统框架和第三方类库的头文件。最好是把 PCH 完全删除,反正苹果如今也不建议使用 PCH 了,Xcode 新建的项目默认都是不带 PCH 的。

在团队内部共享缓存文件夹

这个优化方式我尝试过,最终效果不是很好,所以没有采用。CCache 的官方文档中有一段关于共享缓存文件夹的说明,描述了如何修改 CCache 的配置,让编译缓存可以在多台电脑之间公用,理论上只要其中一我的编译过的文件其余人就能直接下载到了,节约了整个团队的时间。由于 Buck 也有相似的机制,我以为值得尝试一下,便在公司局域网内搭建了一个 OwnCloud 网盘,让你们把本身电脑上的 CCache 缓存目录放上去共享。虽然试验是成功了,可是实际效果并很差。由于同步在多台电脑上大小达到几个G的缓存目录,须要在后台进行不少文件的对比和传输的工做,在编译的同时进行这些操做会耗费很多计算资源,反而会拖慢编译速度。加上移除掉 PCH 后,其实缓存的命中率已经至关可观了,不太须要经过共享缓存来进一步提升缓存命中率,因此我最后放弃了共享缓存这个想法。若是你对缓存命中率仍是不满意的话,能够考虑往这个方向尝试一下。

总结

经过集成 CCache,咱们的项目在 Xcode 里面的打包(在菜单里面选择 Product -> Archive)时间从 11~12分钟减小到了 130 秒,大概有五倍的提高,成果喜人。集成的过程其实很简单,我从开始尝试到集成成功总共就花了两个小时。若是你也被过长的编译时间困扰,建议尝试一下。

文章同步发布在 zhuanlan.zhihu.com/p/27584726