引言
开发效率的提升,是开发者关注的一个永恒的话题。对于 iOS 而言,编译速度一直是影响 iOS 开发和集成测试效率关键的一环。
携程旅行 App iOS 工程编译,经历了从全源码编译到工程组件化,细分 Bundle,再到细分 Bundle 基础上的进一步优化四个阶段。每次的优化改造都是不断结合业务反馈,深入了解 xcode 编译过程后的成果。
iOS开发交流技术群:563513413,不管你是大牛还是小白都欢迎入驻 ,分享BAT,阿里面试题、面试经验,讨论技术, 大家一起交流学习成长!
一、背景
简单回顾一下在做 Bundle 拆分之前的情况,当时整个 iOS 工程的所有代码都在一起,并未做工程拆分和解耦,编译时全都是源码编译,数百万行代码全部编译完成要将近一个小时。所有的开发人员都在一个工程里开发,如果因为某个人提交的代码有问题(这是常常会发生的),导致编译了很长时间之后才报错,更是耽误时间,严重影响开发效率。对于测试人员来说,每次需要验证一个功能时打包测试都需要至少等待几十分钟,这是极大的资源浪费。
这个时候的 Build 过程是全源码 complie,几千上万个文件都需要编译、链接,效率可想而知。
携程旅行App iOS工程编译优化实践
所以为了提高开发和测试的效率,提高 iOS 工程的编译速度刻不容缓。
二、优化方案
2.1 工程组件化
第一个优化是把整个工程的编译过程打散,把代码按照业务线拆分成一个个独立的子工程,每个子工程的编译过程都是独立的。每个子工程只需要保证自己工程的源码能够编译成功,对外输出统一的静态库和资源文件包的产物。这个产物我们叫做 Bundle。
单个业务工程(Bundle):
携程旅行App iOS工程编译优化实践
App Build:
携程旅行App iOS工程编译优化实践
对于单个业务来说,编译时间大大缩短,整个 Build 过程变成单工程 complie,多工程 link,极大减少了 Build 过程中的 complie 花费的时间。
这样有两个好处:
1)对于开发人员,每个业务开发只需要把自己这个子工程切为源码引用,把其他非自己模块的子工程全部用静态库依赖,本地编译也只需要编译自己的子工程,可以大大提升本地开发编译速度。
2)对于测试人员,打包过程就变成了把所有已经编译好的子 Bundle 静态库链接到一个壳工程里,不需要对每个文件进行编译,可以很快的打包测试验证。
2.2 增量编译
在工程组件化之后,在持续集成平台上单个 Bundle 的打包时间还是过长。因此框架团队开始研究单个 Bundle 在持续集成平台上增量编译的可能性。
经过调研,最终选定 CCache 做为解决方案。CCache 是一个编译工具,可以将 Xcode 编译文件缓存起来,从而达到编译提速。
针对本地开发该方案具有优势,但是在结合自研的移动发布平台 MCD(Mobile Continuous Delivery)(后面简称【发布平台】)上使用时效果并没有达到预期,主要有两点原因:
1)同一 Bundle 多分支共存 :App 会存在大小版本同时开发的情况,在发布平台中也就会存在不同版本、不同分支的情况。
2)缓存管理不便 :发布平台打包机器通常仅有 250G 磁盘空间,当面临磁盘压力时,需要灵活的清理策略。
最终框架团队采用了自管理,能做到缓存物理隔离,同时也就省去了环境配置的步骤。
增量编译具体实现:
1)合并有变动的文件
- 打包任务会根据新的 commitId 下载一份代码副本,不能直接使用该副本,因为代码文件内容没有变动,仅仅是文件属性的变动也会导致 xcodebuild 缓存不生效。因此需要副本和工作区内的源码做 diff,仅仅合并内容有变动的文件。
- 使用 python 的 filecmp 实现合并代码逻辑,并且支持配置 ignore。
- xcodebuild 指定 -derivedDataPath 设置缓存路径,并将该目录配置到 diff ignore 中。
2)提供清除缓存的功能
- xcodebuild 的缓存有时候会出问题,比如修改了 c++ 文件后有时并不会生效,这种需要提供清除缓存的功能,可以由开发自由选择使用。
截止到以上两步,Native 已经基本实现了增量编译,但是实际使用还不够。因为打包主要是在集成系统平台上面完成的,集成平台打包有多台机器。
携程旅行 App 的打包 Jenkins 采用的是 master-slave 模式,一个 Job 下会有多个节点,Job 是随机抽取的节点。为了提高增量编译的命中率,必须要让 Bundle 和节点关联起来。比如:有 ABCD 四个节点,HotelBundle 每次都落到 A 节点,这样才能保证 A 节点中 HotelBundle 的 xcodebuild 缓存有效,并且代码 diff 差异最小。
具体实现:
1)保留 Jenkins Job 的工作区
该步骤是在 Jenkins Job 的配置中操作,取消勾选下图中的 Delete workspace before build starts
携程旅行App iOS工程编译优化实践
2)使用 Jenkins 插件建立 Bundle 和节点的关联
基于 Jenkins Label Parameter Plugin,并做改造,实现伪随机,以保证关联的节点下线之后,能使用候补节点正常工作。
发布平台前端提供关联配置,业务可以按需选择使用。
携程旅行App iOS工程编译优化实践
通过以上步骤就实现了增量编译,但是该方案针对 swift 不生效。swift 在 Release 模式采用的全量编译(如下图), 做整体优化。不过 swift Bundle 可以采用上述 Bundle 拆分的方案。
携程旅行App iOS工程编译优化实践
以某一个编译源码文件 197 个、资源文件 142 个的 Bundle 为例看下效果。
携程旅行App iOS工程编译优化实践
采用增量编译后,Bundle 编译耗时由 116s 降为 9s。
携程旅行App iOS工程编译优化实践
2.3 Bundle 细分
最初携程旅行 App 的 Bundle 都是按照业务来拆分的,比如:酒店就一个 Hotel Bundle,在当时编译速度已经不慢了。但是随着业务的发展,单个 Bundle 中业务代码越来越多,文件越来越多,导致编译又会变慢。这时,可以将单个 Bundle 按照功能做更细粒度的拆分,比如酒店拆分出了酒店主工程、酒店基础工程。
更细粒度的 Bundle 拆分还能带来以下其他收益:
- 加快本地开发编译:某个功能的开发人员只需要将自己这个功能模块切为源码,其他模块全用静态库,提高本地开发编译效率。
- 为其他独立 app 提供更细粒度的模块功能支持:我厂的很多独立 App 都是共用一套框架和基础组件的,按功能模块细粒度的拆分出独立的模块 Bundle 后,可以使独立 app 在选择基础组件时按需选择。
2.4 合理设置头文件搜索路径
业务工程往往会大量依赖基础库代码,在本工程编译过程中,也需要查找到引用的基础代码的头文件。
因为代码还是在同一个仓库里,之前的方案是头文件搜索设置还是指向本地的基础框架代码,使用循环搜索的方式。
这样的好处是任何一个头文件的修改,使用方可以马上感知到。
缺点就是头文件没有特意为方便调用进行组织,搜索起来特别费时。
经过统计,Hotel 一个文件的编译往往都是秒级别。一整个工程编译下来就是十几分钟。
因此框架团队意识到必须要和第三方库一样,在目前的.a 和资源文件之外,提交 include 目录包含所有会被外部使用的头文件。
同时,考虑到 iOS 开发向 Swift 转型的需要,如果在 include 目录的基础上,还能够提供一份基于 include 里头文件的 module.mapmodule 文件。将方便后期业务方向 Swift 的迁移。
具体方法是:
1)首先框架的 Bundle,在工程设置中点击工程的 Target→Build Phases→Copy Files 点击 +,输入.h 把需要暴露的头文件都添加上。
这样会在输出产物的 Build 目录下,多一个 include 目录,再通过脚本去把这个目录里面的所有文件复制出来,同时生成 module.mapmodule。
2)使用的时候,将头文件搜索路径设置到 include 目录,并且设置为非递归搜索。
携程旅行App iOS工程编译优化实践
验证下来,Hotel 工程修改之后的 Build 时间为 7 分钟,相比修改之前的 19 分钟,时间减少了 63%。
2.5 建立中央缓存
费雷德里克·布鲁克斯说软件工程领域没有银弹。通过以上优化后,减少了编译时间,提升了开发和集成测试的效率,但这也不是解决编译速度问题的银弹。随着业务的不断使用,又出现了新的问题:Bundle 拉取时间过长。
Bundle 化方案各个业务的静态库生成都是在发布平台上编译的,业务在本地开发的时候再使用框架的脚本拉取 bundle 到本地。发布平台上打测试包的时候也是需要拉取所有 Bundle。
发布平台打包过程如下:
1)初始化 Jenkins 工作区,下载代码副本
2)下载 Bundle
3)使用 xcodebuild 生成 ipa
4)上传 ipa 和符号表
5)Job 状态回调
整个过程共耗时 7 分钟,目前携程旅行 App iOS 最新的版本的上线 Bundle 将近 70 个,每个 Bundle 的静态库支持 arm64、x84_64 等指令集,所有 Bundle 加起来有 4G 大小,即使在内网全量下载耗时也要 2~3 分钟。
比如酒店某一 Bundle:
携程旅行App iOS工程编译优化实践
所有 Bundle 全量更新一次耗时:
携程旅行App iOS工程编译优化实践
针对这个问题,解决方案是建立中央缓存。
在用户根目录下,建立一个隐藏的目录.iOSBundleRepo,按照 Bundle 的版本号存储,同一 Bundle 可存在多个版本。工具下载 Bundle 时优先判断缓存,未命中时才开始下载并且缓存到 repo 中。
建立中央缓存还能带来其他好处:在发布平台做预缓存,使用定时任务更新中央缓存,进一步节省下载耗时。
该方案实际上采用的是空间换时间的策略,随着时间推移,将会带来磁盘不足的问题,所以必须要实现清理机制。
针对不同使用场景需要采用不同的缓存清理策略,具体如下:
- 本地开发:该模式下,开发可以自由选择更新最新 Bundle 和仅更新配置,缓存使用不频繁。所以将同一 Bundle 版本个数调低,缓存有效期拉长。
- 持续集成:发布平台打包较为频繁,缓存使用比较频繁,并且 Bundle 版本变动较快,所以将同一 Bundle 个数调高,缓存过期时间设置为一天。
最终,打包耗时由原来的 7 分钟降为 5 分钟。
三、存在的问题和思考
软件开发工程没有银弹,大家都是在焦油坑里挣扎。
Bundle 的方案节省了编译的时间,提高了开发的效率,方便了持续集成和测试。
为了提高单 Bundle 编译速度而导出头文件的方案,牺牲了一定的灵活性换来了编译速度的提高。头文件没有了代码中的直接搜索,框架开发人员从共同开发者真正变成了库提供者,这就要求每一次都接口的修改都要及时更新并导出。
任何一个技术方案肯定是在权衡各方面之后做出取舍的结果。框架团队为了提高 iOS Build 速度,通过自研的方案,做了拆分 Bundle,优化头文件搜索路径,增量编译,建立中央缓存等步骤,基本上满足了现有我厂各业务线的日常开发需求。