Skip to content

Latest commit

 

History

History
141 lines (103 loc) · 13.3 KB

2020-05-29-iOS构建提速.md

File metadata and controls

141 lines (103 loc) · 13.3 KB
Error in user YAML: (<unknown>): found character that cannot start any token while scanning for the next token at line 5 column 1
---
title: iOS构建提速
date: 2020-05-29 11:28:42
categories: Tech
tags:
	- iOS
	- Xcode
---

随着项目越来越大,代码量越来越多,编译的时间也越来越长,每次Clean Build一次都是漫长的等待,优化编译过程被提上日程。

0x00 预备知识

在优化之前,首先我们需要知道Xcode的整个编译过程的流程,以及流程中的每一部分的耗时。

在我们按下Command + B后,Xcode会执行以下几个步骤:

  1. 执行当前Schema下的BuildAction下的Pre-actions脚本(如果有)。
  2. 生成辅助文件。 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文件
  3. 检查Dependencies,编译Dependencies。 接下来的步骤取决于Build Phases的顺序,我们插入的Phases会根据它在Build Phases中的顺序一个个执行。抛开自定义的Phases的执行流程如下:
  4. 编译Compile Sources下的所有文件。
  5. 链接Link Binary With Libraries下的framework和library。
  6. 编译xibImageAssets,拷贝资源文件。
  7. 处理info.plist
  8. 打包和签名。

使用命令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

0x01 Build Settings

关于修改Build Settings来加快编译速度的文章网上也有很多,大致逃不过以下几个Settings:

Build Active Architecture Only 改为 Yes

Debug时不需要生成全架构,检查一下子工程(尤其开源库)有没有设置正确。

Debug下不生成dSYM文件

Build Settings -> Build Options -> Debug Information Format dSYM文件中存放着函数地址于函数名的映射,当App Crash后,可以根据堆栈地址信息反解出具体的函数栈。但是在Debug时我们不需要它,因为Xcode会将DWARF调试信息写入.o文件中,所以在我们调试的时候,Xcode可以找到地址所对应的函数、方法。

Debug下关闭Bitcode

BitcodeLLVM使用的中间代码,有了它,LLVM可以使用它转换成任意架构的机器码。一般的Debug下也不需要,关了它能少一点开销是一点。

关闭Index-while-Building

这个参数告诉编译器在编译的时候是否要同时建立索引,如果关闭了,Xcode就会在空闲的时间去建立索引。

避免search paths递归查找

如果你的search path被设置成了递归查找,并且search path下的目录结构非常复杂,那么将会是一项不小的开销。尽量使用非递归的查找方式。不过如果你的目录结构本来就很简单,文件也很少,那么这项优化可能对你的编译时间提升并没有太大的作用。

Debug下设置Swift Optimization Level为None

避免编译器为编译swift代码做优化,设置为-Onone加快编译速度。

Debug下设置Compilation Mode为Incremental

Whole Module模式会有更好的优化,但是也会增加编译时间。

0x02 模块化

正当你更改了所有可优化的设置,信心满满的Clean Build之后,可能你会发现你的编译时间的减少微乎其微,并且可能还变的更长了。现实就是这么残酷。 模块化可以说是减少编译时间最好的实践了,前有CocoaPods,后有Carthage,再到现在的Swith Package Manager。模块化是一项长期的工作,需要多方努力,但是效果显著。如果能将自己的工程拆分出一个个独立的模块那就是最好的方式了。

0x03 swift代码优化

如果你的项目里有swift代码,并且它在整个编译过程中的耗时占了很大的比例。那么你就可以开始着手优化你的swift代码了。 BuildTimeAnalyzer-for-Xcode是一个很优秀的辅助工具,它能帮助我们统计出编译时所有耗时的项目。swift的代码的编译优化在此就不提了,避免类型推断,拆文件避免改一行重新编译一堆文件,网上也有很多优秀的总结,总之swift,�Emm,也就那样吧。

0x04 Objcive-C代码优化

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来批量修改。

0x05 折中的模块化

在我们的项目里,由于耦合性太强,有些模块不能很好的独立拆分出去,又或需要不频繁的改动,为了调试的便利性,只能以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解析。RubyPython都有相应的第三方库来解析project.pbxproj文件。出于方便,选择了Python来作为脚本语言实现这个缓存方案。

所以脚本所做的事情是:

  1. 计算版本的哈希值
  2. 判断是否命中cache 2.1. 如果命中,则拷贝命中的cache到目标目录下。 2.1. 如果未命中,则重新从源码编译,然后拷贝到目标目标下。

哈希计算

选用了MD5作为哈希算法。如何找到所有源文件和获取Build Settings就不在此赘述了,需要注意的是Build Settings分为Project LevelTarget Level的,最后的Build Settings应为Project Build Settings叠加Target Build Settings,因为Target LevelBuild Settings会覆盖Project LevelBuild SettingsBuild Settings可以输出成一个json字符串再做哈希(Python``pbxproj库输出的Build Settings``json字符串的key是有序的)。 方便起见,直接将lib命名为[LIB_NAME].[HASH_VALUE].[EXT],查找的时候直接使用哈希值就能找到目标cache。

xcodebuild

使用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,在上面修改并集成脚本。这样对现有的工程造成的影响最小。

移除dependencies

我们的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中集成脚本,我们有两种选择:

  1. Build Phases里添加自定义的脚本。
  2. 在对应SchemeBuild 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的集成方式,可能对调试有所影响,但是从原理上来说能够解决。
  • 整理头文件。

Reference