cover_image

SoulAPP iOS端编译优化实践

Soul技术团队
2024年01月08日 02:07

经过多年的发展,SoulAPP的工程业务代码已经超过50万行,集成的pods多达290个,一次clean build需要耗费大约30分钟的时间。随着业务的不断迭代,工程代码仍在持续增长,而编译作为日常开发中频繁执行的操作,其耗时严重影响了业务的开发迭代效率。为此,Soul工程团队经历了多次优化,不断探索加速编译和打包速度的解决方案。

总结来说,SoulAPP 的工程编译优化可以分为以下几个历程。

1.单工程阶段


1.1 预编译优化

在Soul的早期阶段,工程采用的是将业务代码全部集中在主工程中,没有进行组件化区分。通过CocoaPods引入了必的第三方库,这些pods都是采用源码依赖的方式。主工程中的业务代码被管理在不同的文件夹中,但业务之间的耦合较为严重。整个编译过程都采用源码进行,而且以静态编译的方式进行。

在这个阶段,由于工程项目相对简单,且开发团队规模较小,业务迭代不太紧张,协调合作的需求也较少,因此单次编耗时可能并不是最关注的重点。尽管如此,团队仍然进行了一些简单的编调优工作。编译优化主要考虑了编译工具的特性支持,例如采用了预编译pch头文件。

预编译代码在开发周期中非常有用,能够缩短编译时间,特别是在以下情况下:

- 始终使用不常更改的一大段代码。

- 程序包含多个模块,这些模块都使用一组标准的包文件和相同的编译选项。在这种情况下,所有包含文件都可以预编译为一个预编译标头。

对于Soul而言,由于采用了单体工程的结构,在工程中添加了预编译头文件pch,将常用的公共头文件都包含在这个pch头文件中。通过实际效果的观察,将常用的UI库、通用组件和基础组件的头文件加入到pch文件中后,整体编译时间降低了3-5分钟左右。这一优化在提高开发效率方面取得了显著的成果。

1.2 Xcode Build 的流程

我们来看看我们在 Xcode 中使用 Command + BCommand + R 时,即完成了一次编译,这个过程做了哪些事情。

编译过程分为四个步骤:

  • 预编译(Pre-process):宏替换、删除注释、展开头文件,产生 .i 文件。

  • 编译(Compliling):把前面生成的 .i 文件转化为汇编语言,产生 .s 文件。

  • 汇编(Asembly):把汇编语言 .s 文件转化为机器码文件,产生 .0 文件。

  • 链接(Link):对 .o 文件中的对于其他库的引用的地方进行引用,生成最后的可执行文件。也包括多个 .o 文件进行 link。

通过解析 Xcode 编译 log,可以发现 Xcode 是根据 Target 进行编译的。我们可以通过 Xcode 中的 Build Phases、Build Settings 及 Build Rules 来控制编译过程。

  • Build Settings:这一栏下是对编译的细节进行设定,包含 build 过程的每个阶段的设置选项(包含编译、链接、代码签名、打包)。

  • Build Phases:用于控制从源文件到可执行文件的整个过程,如编译哪些文件,编译过程中执行哪些自定义脚本。例如 CocoaPods 在这里会进行相关配置。

  • Build Rules:指定了不同的文件类型该如何编译。一般我们不需要修改这里的内容。如果需要对特定类型的文件添加处理方法,可以在这里添加规则。

每个 Target 的具体编译过程也可以通过 log 日志获得。大致过程为:

  • 编译信息写入辅助文件(如Entitlements.plist),创建编译后的文件架构

  • 写入辅助信息(.hmap 文件)。将项目的文件结构对应表、将要执行的脚本、项目依赖库的文件结构对应表写成文件。

  • 运行预设的脚本。如 Cocoapods 会在 Build Phases 中预设一些脚本(CheckPods Manifest.lock)。

  • 编译 .m 文件,生成可执行文件 Mach-O。每次进行了 LLVM 的完整流程:前端(词法分析 - 语法分析 - 生成 IR)、优化器(优化 IR)、后端(生成汇编 - 生成目标文件 - 生成可执行文件)。使用 CompileCclang 命令。CompileC 是 xcodebuild 内部函数的日志记录表示形式,它是 build.log 文件中有关编译的基本信息来源。

  • 链接需要的库。如 Foundation.framework,AFNetworking.framework…

  • 拷贝资源文件到目标包

  • 编译 storyboard 文件

  • 链接 storyboard 文件

  • 编译 Asset 文件。如果使用 Asset.xcassets 来管理图片,这些图片会被编译为机器码,除了 icon 和 launchIamge。

  • 处理 infoplist

  • 执行 CocoaPods 脚本,将在编译项目前已编译好的依赖库和相关资源拷贝到包中。

  • 拷贝 Swift 标准库

  • 创建 .app 文件并对其签名


1.3 编译选项优化

  1. 将 Debug Information Format 改为 DWARF

Debug 时是不需要生成符号表,可以检查一下子工程(尤其开源库)有没有设置正确。

  1. 将 Build Active Architecture Only 改为 Yes

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

  1. 优化头文件搜索路径

避免工程 Header Search Paths 设置了路径递归引用:

图片

Xcode 编译源文件时,会根据 Header Search Paths 自动添加 -I 参数,如果递归引用的路径下子目录越多,-I 参数也越多,编译器预处理头文件效率就越低,所以不能简单的设置路径递归引用。同样 Framework Search Paths 也类似处理。

  1. 将SWIFT COMPILATION MODE设置为Incremental

此设置决定了 swift 源文件被重新编译的策略。在 Debug 配置下设置为 Incremental, 只重新编译 “过期” 的 swift 源文件。在 Release 配置下设置为 Whole Module, 编译所有 swift 源文件以应用某些代码优化。

  1. 将OPTIMIZATION LEVEL设置为No Optimization [-O0]

优化级别设置定义了优化构建的方式。由于优化过程涉及额外的工作,因此代码优化会导致构建时间变慢。Debug 配置应设置为“No Optimization”,因为我们需要快速的编译时间。

  1. Enable Index-While-Building Functionality设置为NO

默认开启,Xcode 编译时会建立代码索引,影响编译速度;关闭后,在编译时就不会进行索引,而是在空闲时间建立代码索引(自动补全Code、查找定义)。

设置完这些参数后,大约减少了2分钟的编译时间。其中优化头文件搜索路径效果是最好的,大约减少了1分钟时间。

1.4 编译效能量化工具

为了衡量编译优化的具体任务时间,也方便统计优化的收益,需要对编译任务进行量化分析。以下是我们用于分析编译的一些工具:

1.Xcode构建时间摘要 (Xcode build timing summary)

Xcode Build Timing Summary是Xcode10中加入的用于查看获取构建时间和发现用时瓶颈方面的最有利工具。可以通过Product->Perform Action->Build With Timing Summary来开启:这样在 Build Log 的末尾就会添加 Timing Summary Log。我们可以通过这个 log 看到哪个阶段是耗时的,便于我们进行优化。

图片

2.Xcode Build Timeline

Build Timeline 是Xcode14中的新功能,可以非常便捷的看出编译过程中任意阶段的耗费时间。

图片

3.XCLogParser

https://github.com/MobileNativeFoundation/XCLogParser

XCLogParser可以详细列出各个Target和内部每个文件的编译耗时,对我们分析编译时间瓶颈非常有帮助,它的工作原理主要是作为解析器,通过解析xcode编译生成的xcactivitylog日志来记录。

图片

此外,还有其他工具例如 Spotify 的 XCMetircs,基于XCLogParser,可以系统性地集成并且监测所有开发者的编译情况。还可以利用 clang 的 -ftime-trace 参数,编译时生成 Chrome(chrome://tracing) JSON 格式的耗时报告,列出所有阶段的耗时。

图片


2.组件化工程阶段

Soul 的 iOS 代码经历较长时间的演化,由于早期的架构设计和实现上的问题,加上业务迭代中快速堆叠了代码,导致业务耦合度大,不同业务代码边界不清晰,重复代码和遗留代码多,缺乏统一,清晰可复用的良好组件。

2.1 组件化改造的目标和收益

  • 解耦合,避免一个模块的改动影响到其它模块的功能

  • 加快编译速度,每个模块可以单独编译,改动一个模块的功能,不用全量编译,节省开发时间

  • 便于分工合作:拆分完后,每个模块的边界分明

  • 使工程目录变得可维护,避免所有代码都在一个模块

  • 便于组件功能的复用

组件化架构设计

以下为组件总体架构设计图,其中:

  • 技术组件为业务无关

  • 业务组件为目前的业,以领域进行划分

  • 业务组件部分依赖其他技术组件

图片

在单工程阶段,我们一个业务模块编译的时候可能同时包含了其他业务模块代码/底层组件代码等任务的依赖同时编译,导致很多编译任务无法并行执行。通过组件化的改造后,我们梳理了模块的依赖关系,模块依赖从乱序的图关系变成了树状关系,在编译执行时底层组件库和业务模块之间都可以并行执行,缩短了编译时间。

下图为在组件化完成改造后,通过Xcode Build Timeline查看到各模块的编译过程,可以看到组件模块编译几乎都是并行执行的,充分利用了多核和多线程。

图片

2.2 二进制编译优化

为了进一步提升编译速度,我们决定对部分组件进行二进制化。市面上存在一些方案,如cocoapods-binary等,这些方案的原理主要是对二进制产物进行缓存,以更快速地进行编译。甚至可以将这些缓存存放于服务器上。尽管这些方案基本上可以满足提升编译速度的需求,但在实际使用中却存在一些问题。例如,切换源码/二进制不顺畅,可能导致cocoapods缓存池的污染,同时问题难以排查,从而影响到研发体验和效率。

Soul团队也采用了二进制编译优化的方案,并开发了自动化脚本用于对组件进行二进制打包。通过将大部分组件进行二进制化,App的编译时间大幅度缩短,减少了约20分钟的编译时间。

为了解决二进制化后调试源码不方便以及需要修改源码等问题,Soul团队基于内部的研发流程,推出了研发测试一体化的伴系统,并定制了cocoapods插件。安装了插件后,组件依将不再从Podfile中读取,而是从特定的JSON文件中读取。这个JSON文件与研发系统相连,代码集成后构建组件的二进制版本号会写到JSON文件中。此外,JSON文件中还可以轻松地切换源码和二进制,提供了更加灵活的选择。这一研发流程的优化使得在二进制编译的同时仍然能够方便地进行源码调试和修改。

改造后的流程如下:

图片


3.工程全源码编译优化

在项目中,由于一些代码出于安全性考虑需要进行源码编译。为了优化这些源码的编译过程,我们基于开源项目Rugby进行了一些定制化的优化。在CI构建中,如果命中缓存,编译时间可以缩短到2-3分钟,基本上省去了大部分编译时间,只剩下链接时间和导出时间。

在调研全源码编译优化方案时,我们首先关注了Google的Bazel方案(https://github.com/bazelbuild/bazel)。然而,在调研Bazel编译存方案时,我们发现实施上存在一些比较大的困难:

  1. bazel的配置和工具链是一个新的体系,目前团队中对bazel工具链基本没有接触

  2. Bazel的配置比较复杂,需要专人配置,目前了解的bilibili在使用,也是有专人来负责修改配置,协作效率较低

  3. 现有的cocoapods迁移到bazel的工作量大,目前的插件和一些开源工具项目不具备直接转换为bazel项目的能力,需要大量的手动定制和配置。当前项目工程的一些pod仓库定制了编译参数和脚本,使得迁移工作量更大

  4. 在bazel和Xcode开发工具切换存在较多不便,bazel配置的工程需要转换为Xcode项目才能比较好的开发和调试,添加修改又需要同步到Bazel配置脚本。

在我们一筹莫展的时候,我们发现了Rugby项目:https://github.com/swiftyfinch/Rugby。Rugby 是基于 cocoapods 改造的预编译二进制缓存方案,解决了 Xcode Index 时间过长、卡顿的问题,减少了Xcode 的编译时间,并且有基于网络的二进制存储方案。Rugby的原理类似Carthage,将cocoapods的模块都编译为Framework,转换后项目工程直接使用Framework来进行链接,大大减少了编译源码的时间。但相对于Cathage来说,Rugby适配了常用的cocoapods工具链,降低了转换的成本。

Rugby的使用可以参考: 

https://github.com/swiftyfinch/Rugby/blob/main/Docs/commands-help/README.md

在pod install执行后,就可以使用Rugby来优化工程了,使用 Rugby 对工程进行二进制缓存:

rugby build --arch arm64 --sdk ios

对于某些不需要进行二进制缓存的:

rugby build --arch arm64 --sdk ios -e RangersAppLog

这里做一下使用 Rugby,全组件源码编译的一个对比,设备性能是 Intel,16GB内存。


编译模式
用时
不使用 Rugby1141
使用 Rugby全量
980
不使用 Rugby 切换组件源码/二进制
1150
使用 Rugby 切换组件源码/二进制
150

可以看出,使用 Rugby 关键的提升点在于,切换组件的源码/二进制后重新编译,不会引起 pod install 带来的全量编译,仅需将切换的组件编译一次,再进行 link,节约了大量时间。

图片


4.题外

从编译流程图看下来,不难发现一个简单的道理,那就是任何编译优化都不如不编译。项目存在时间越久,无用的类、资源文件(图片、配置文件、音乐/视频文件)、脚本就越多,并且由于错综复杂的历史原因,单纯的搜索文件名查看是否使用是无法看出文件有没有被实际使用的。

对于这种情况,我们开发了一套内部工具,原理上基于正则匹配和 LinkMap 文件,用于检测、归纳、跟踪用资源和代码,推荐可以参考我们的另一篇文章:Soul-iOS包大小防劣化实践 第三节我们会每周执行一次,确保每个迭代新增资源得到控制。

示例图:

图片

5.总结

针对项目的不同阶段,采取不同的编译优化策略是非常明智的做法。SoulAPP从最初的单一工程逐步演变为组件化工程,其编译优化工作也经历了从配置层面的优化到深入研究Xcode编译系统的阶段。在这个过程中,团队克服了许多工程问题,并取得了显著的收益。以上是我们iOS编译优化的一些工作,其中的一些项目经验希望可以为大家提供一些借鉴。


继续滑动看下一个
Soul技术团队
向上滑动看下一个