---
title: iOS构建提速
date: 2020-05-29 11:28:42
categories: Tech
tags:
- iOS
- Xcode
---
随着项目越来越大,代码量越来越多,编译的时间也越来越长,每次Clean Build一次都是漫长的等待,优化编译过程被提上日程。
在优化之前,首先我们需要知道Xcode的整个编译过程的流程,以及流程中的每一部分的耗时。
在我们按下Command + B
后,Xcode会执行以下几个步骤:
- 执行当前
Schema
下的Build
Action下的Pre-actions
脚本(如果有)。 - 生成辅助文件。
2.1. 创建存放编译期间中间文件的目录
Intermediates.noindex
,通常是DerivedData/[PROJECT_NAME]-对应ID/Build/Intermediates.noindex
。 2.2. 创建Products
目录,通常是DerivedData/[PROJECT_NAME]-对应ID/Build/Products
。 2.3. 在Products
下生成对应文件架构的.app
目录。 2.4. 将Entitlement
写入build
目标下,并处理Entitlement
文件。 2.5. 生成hmap
文件 - 检查
Dependencies
,编译Dependencies
。 接下来的步骤取决于Build Phases
的顺序,我们插入的Phases
会根据它在Build Phases
中的顺序一个个执行。抛开自定义的Phases
的执行流程如下: - 编译
Compile Sources
下的所有文件。 - 链接
Link Binary With Libraries
下的framework和library。 - 编译
xib
,ImageAssets
,拷贝资源文件。 - 处理
info.plist
。 - 打包和签名。
使用命令defaults write com.apple.dt.Xcode ShowBuildOperationDuration YES
开启显示Build的总时间。
使用Product -> Perform Action -> Build With Timing Summary
编译后,在Build Log
里会显示出此次Build的耗时Summary。
�Xcode生成的Build Log
不能很好的查看每个Dependency
以及每个文件的编译时间,推荐使用XCLogParser来更好的分析Build Log
。
关于修改Build Settings来加快编译速度的文章网上也有很多,大致逃不过以下几个Settings:
Debug时不需要生成全架构,检查一下子工程(尤其开源库)有没有设置正确。
Build Settings -> Build Options -> Debug Information Format
dSYM
文件中存放着函数地址于函数名的映射,当App Crash后,可以根据堆栈地址信息反解出具体的函数栈。但是在Debug
时我们不需要它,因为Xcode会将DWARF
调试信息写入.o
文件中,所以在我们调试的时候,Xcode可以找到地址所对应的函数、方法。
Bitcode
是LLVM
使用的中间代码,有了它,LLVM
可以使用它转换成任意架构的机器码。一般的Debug下也不需要,关了它能少一点开销是一点。
这个参数告诉编译器在编译的时候是否要同时建立索引,如果关闭了,Xcode就会在空闲的时间去建立索引。
如果你的search path
被设置成了递归查找,并且search path
下的目录结构非常复杂,那么将会是一项不小的开销。尽量使用非递归的查找方式。不过如果你的目录结构本来就很简单,文件也很少,那么这项优化可能对你的编译时间提升并没有太大的作用。
避免编译器为编译swift代码做优化,设置为-Onone
加快编译速度。
Whole Module
模式会有更好的优化,但是也会增加编译时间。
正当你更改了所有可优化的设置,信心满满的Clean Build
之后,可能你会发现你的编译时间的减少微乎其微,并且可能还变的更长了。现实就是这么残酷。
模块化可以说是减少编译时间最好的实践了,前有CocoaPods
,后有Carthage
,再到现在的Swith Package Manager
。模块化是一项长期的工作,需要多方努力,但是效果显著。如果能将自己的工程拆分出一个个独立的模块那就是最好的方式了。
如果你的项目里有swift代码,并且它在整个编译过程中的耗时占了很大的比例。那么你就可以开始着手优化你的swift代码了。 BuildTimeAnalyzer-for-Xcode是一个很优秀的辅助工具,它能帮助我们统计出编译时所有耗时的项目。swift的代码的编译优化在此就不提了,避免类型推断,拆文件避免改一行重新编译一堆文件,网上也有很多优秀的总结,总之swift,�Emm,也就那样吧。
Objc编译的瓶颈最可能出现在头文件的引用上。一是移除实际未使用的头文件,二是通过前置声名来代替include/import
。如果想知道项目代码的编译时间的瓶颈到底是不是头文件引用,我们可以借助Clang
的-ftime-trace
参数来生成Clang
的编译分析报告。但是很遗憾,当前版本的Xcode(Xcode 11)所集成的Clang
还没有支持这个参数,所以我们需要让Xcode使用我们提供的Clang
来编译我们的工程。在这儿下载一个Clang-10
。然后在Xcode中Target -> Build Settings
点击Levels
页卡边上的+
,添加一个User-Defined Setting
,将名字改成CC
,再将其设置为我们的Clang
可执行文件,这样就能让Xcode使用我们指定的Clang
了。同时,在Other C Flags
里添加一项-ftime-trace
,大功告成。对于每个编译文件,都会对应生成一个.json
文件,这些文件默认是和.o
文件在同一个目录下,也就是DerivedData/[PROJECT_NAME]-对应ID/Build/Intermediates.noindex/[PROJECT_NAME].build/Debug-iphonesimulator/[TARGET_NAME].build/Objects-normal/x86_64
(以simulator为例)。
生成的.json
文件可以使用Chrome来查看,在Chrome的地址栏中输入chrome://tracing
,加载.json
文件,就能看见具体的耗时分析。如果觉得文件太多,可以写个脚本来合并这些文件,可以参考这篇文章。
如果确定了是头文件引用太多造成的编译时间太长,除了上面提到的两个解决方案,还可以视情况把常用的头文件加入到PCH
文件中,PCH
文件中引用的头文件一旦改变,就会引起重编。
移除未使用的头文件以及使用前置声名来代替include/import
看似是一件简单的事情,但是操作起来的工作量是非常大的,这篇文章提供了一个很棒的实践,修改了include-what-you-use使之支持Objc来批量修改。
在我们的项目里,由于耦合性太强,有些模块不能很好的独立拆分出去,又或需要不频繁的改动,为了调试的便利性,只能以sub-project的形式存在于工程中。每次Clean Build
的时候都需要重新编译这部分代码,而这些代码平时又不会被经常的修改,增加了编译的时间。为了尽可能的缩短编译时间,可以对这部分sub-project的lib
做缓存。
我的目标是使用缓存方案加快工程的编译速度,尽量不对工程已有的配置造成影响,并且最好适用于开发机和CI机,换句话说,可以方便的集成到CI脚本中,也能够适用于Xcode编译。
方案的大致流程是:在build时,先检查当前的sub-project有没有可用的对应版本的lib
,如果有,就使用这份lib
,如果没有,则从源码重新编译一份新的lib
,并更新cache。
为了判定缓存的命中与否,需要唯一key
对每份不同版本的lib
进行索引,而又不能使用lib
自身的哈希值,因为这样需要先编译出lib
,达不到缩短编译时间的目的。换个思路,哪些因素会影响最后lib
的结果。很显然源文件的改动会影响到最后的编译结果,另外一个不可忽视的点是Build Settings
也会对编译结果产生影响,所以选择对这些这些文件以及Build Settings
计算哈希值来作为不同版本lib
的索引。
我们要获取到所有源文件以及Build Settings
,就必须对project.pbxproj
解析。Ruby
和Python
都有相应的第三方库来解析project.pbxproj
文件。出于方便,选择了Python
来作为脚本语言实现这个缓存方案。
所以脚本所做的事情是:
- 计算版本的哈希值
- 判断是否命中cache 2.1. 如果命中,则拷贝命中的cache到目标目录下。 2.1. 如果未命中,则重新从源码编译,然后拷贝到目标目标下。
选用了MD5
作为哈希算法。如何找到所有源文件和获取Build Settings
就不在此赘述了,需要注意的是Build Settings
分为Project Level
和Target Level
的,最后的Build Settings
应为Project Build Settings
叠加Target Build Settings
,因为Target Level
的Build Settings
会覆盖Project Level
的Build Settings
。Build Settings
可以输出成一个json
字符串再做哈希(Python``pbxproj
库输出的Build Settings``json
字符串的key
是有序的)。
方便起见,直接将lib
命名为[LIB_NAME].[HASH_VALUE].[EXT]
,查找的时候直接使用哈希值就能找到目标cache。
使用xcodebuild -project [YOUR_PROJECT] -scheme [YOUR_SCHEME]
命令来编译sub-project。这儿需要注意的是我们还需要指定-arch
以及-configuration
,考虑到脚本要同时适用于CI脚本编译和Xcode编译,使用环境变量来传递这两个参数。因为在Build Phases
里的脚本能很方便的拿到此次编译的参数,而在CI脚本中,也能通过export
命令来指定参数。
另一个需要注意的点是你可能需要指定OBJROOT
参数。因为在Xcode运行脚本,脚本使用xcodebuild
命令对同一个project进行编译的时候,会发生目录冲突,我们可以为xcodebuild
命令指派另一个OBJROOT
,比如$OBJROOT/DependantBuilds
。
cache空间不能无限制的增长,在每次生成新版本cache的时候,检查一下cache数量有没有超过限制,如果有,根据LRU
策略调整cache。
现在我们有了一个能在build前为我们准备好subp-projeclib
的脚本,思考我们该如何集成到Xcode中?
首先我考虑的是复制现有的target,在上面修改并集成脚本。这样对现有的工程造成的影响最小。
我们的target是将sub-project作为dependencies
以达到每次build都使用最新源文件编译的lib
的,如果我们不想让Xcode编译sub-project,就必须将此sub-project从dependencies
里移除。
除此之外,我们是以第三方库的形式集成sub-project的lib
,所以得为target设置一个Library Search Path
,这个Search Path
下专门存放cache libs。另外,在Link Binary With Libraries
里,也要将原来sub-project的lib
换成Search Path
下的lib
。
在target中集成脚本,我们有两种选择:
- 在
Build Phases
里添加自定义的脚本。 - 在对应
Scheme
的Build
action下添加Pre-actions
。
虽然Pre-actions
里脚本运行的时机最早,可以做一些Build Phases
里脚本无法做到的事情。但是经过测试,发现脚本只要在Compile Sources Phase
之前运行,就能正常的找到lib
,在有cache的情况下避免重新编译整个sub-project。而且,Build Phases
里的脚本的输出能在Build Log
里查看,调试起来方便,而Pre-actions
里的脚本则不行。所以最后选择了在Compile Sources Phase
前插入了自定义脚本。
在测试的时候发现,在命中cache的情况下,脚本的运行时间也需要十几秒,不符合情理。调试后发现其中一个lib
的拷贝时间占去了大部分,进一步查看发现这个lib
的大小有500+MB
,于是考虑在Search Path
下使用软连接而不是直接拷贝文件。使用软连接替代直接拷贝后运行时间显著减少。
- 优化swift代码,在优化后,swift源码编译时间占到差不多30%,有优化的空间。
- 测试完善脚本,由于时间有限,只测试了Debug+Simulator环境下的执行。
- 测试并完善sub-project代码的调试。由于使用了
lib
的集成方式,可能对调试有所影响,但是从原理上来说能够解决。 - 整理头文件。