怎样测量编译时间消耗
在最新版本的 Xcode 中,编译后查看 Report Navigator 面板,点击刚刚的那次编译,即可查看到整个编译流程,以及每一步的耗时。右键点击任意一个步骤,选择 Show In Timeline 可打开一个时间线面板,在实现面板中,可以查看到编译的各个步骤,包括 Prepare Packages、Plan build、Create build description 等,可以通过每个条目的长度直观的看到编译时间的长短,也可以直观的看到哪些条目是可以并行编译的。可以找出那些比较长的,或者无法并行编译的条目,做进一步分析。或者是找出那些耗时过长的步骤,比如执行某个自定义脚本的时间过长。
也有写第三方工具例如 https://github.com/RobertGummesson/BuildTimeAnalyzer-for-Xcode 可以测量并分析编译时间。
还有个更好的方法,在 Build Settings 的 Other Swift Flags 添加 -Xfrontend -warn-long-function-bodies=100
可以让 Xcode 在任何耗时超过 100ms 的方法处报告警告,添加 -Xfrontend -warn-long-expression-type-checking=100
可以让 Xcode 在任何耗时超过 100ms 的表达式处报告警告。这个方法可以直接通过警告定位到具体的代码位置,非常得方便。
通过 Xcode 设置优化编译速度
合适的 Build Settings
- Build Active Architecture Only 在 Debug 模式下设置为 YES
这个配置可以让 Xcode 在 Debug 模式下仅生成目标设备所需要的架构,而不会生成所有的架构,从而在 Debug 模式下节省编译时间。
- Build Options -> Debug Information Format 在 Debug 模式下设置为 DWARF
这个配置可以让 Xcode 在 Debug 模式下不生成 dSYM 文件从而节省 Debug 模式下的编译时间。
- 设置优化级别
确保优化级别(Optimization Level)在 Debug 模式下为 None [-O0]
,在 Release 模式下为 Fastest,Smallest [-Os]
。
对于 CocoaPods,可以在 Podfile 中添加以下配置:
post_install do |installer|
installer.pods_project.targets.each do |target|
target.build_configurations.each do |config|
if config.name == 'Debug'
config.build_settings['OTHER_SWIFT_FLAGS'] = ['$(inherited)', '-Onone']
config.build_settings['SWIFT_OPTIMIZATION_LEVEL'] = '-O'
end
end
end
end
- 设置编译模式
确保编译模式(Compilation Mode)在 Debug 模式下为 Incremental
,在 Release 模式下为 Whole Module
。
对于 CocoaPods,可以在 Podfile 中添加以下配置:
post_install do |installer|
installer.pods_project.targets.each do |target|
target.build_configurations.each do |config|
if config.name == 'Debug'
config.build_settings['SWIFT_COMPILATION_MODE'] = 'singlefile'
else
config.build_settings['SWIFT_COMPILATION_MODE'] = 'wholemodule'
end
end
end
end
给自定义脚本设置输入和输出文件
有时会给工程添加自定义脚本,用来在编译前后自动地做一些额外的事情,例如自动设置环境变量,执行一些特定的任务,生成资源文件等。在默认情况下,Xcode 会在每次编译时(包括增量编译)都去执行自定脚本。很多时候,每次都执行脚本时没有必要的,这时可以给自定义脚本设置输入和输出文件来避免每次都执行。
为了避免每次都执行自定义脚本,需要在 Xcode 的脚本配置中配置至少一个输入文件和输出文件。Xcode 会通过输入和输出文件来决定是否执行这个脚本。Xcode 会在以下任何一个条件满足时执行自定义脚本:
- 自定义脚本没有配置任何输入文件。
- 自定义脚本没有配置任何输出文件。
- 自定义脚本的输入文件被修改了。
- 自定义脚本的输出文件缺失。
在工程配置的 Build Phase 设置的 Run Script 中,可以在 Input Files 和 Output Files 部分配置文件,可以一个一个地配置单个文件,也可以配置一个以 .xcfilelist 为扩展名的文件列表文件,在这个文件中,每一行列出了一个文件。
即使自定义脚本并不需要输入和输出文件,也应该配置这些文件,对于一个不需要输入的脚本,应该配置一个永远不会被修改的输入文件,对于一个没有输出的脚本,应该配置一个静态的输入文件,这样可以让 Xcode 做一些检查工作。
让自定义脚本只在某些条件下执行
也可以让自定义脚本只在某些条件下执行,例如仅仅在 Debug 模式下执行,可以在脚本内容中添加判断:
if [ "${CONFIGURATION}" = "Debug" ]; then
"${PODS_ROOT}/SwiftLint/swiftlint"
else
echo "Not running SwiftLint/swiftlint because we are building for Release"
fi
通过项目结构组织方式来优化编译速度
模块化并开启 Clang Module
当工程非常大时,模块化有提高工程结构组织的清晰度,让你能快速找到对应文件,明确地管理依赖和 API,明确代码和业务的边界,提高团队协作效率等作用。模块化也能提高工程的编译速度。
模块化可以利用 Clang Module 的特性,即每个模块编译一次后,都会把编译结果生成一个缓存文件,下次再次遇到这个模块时,编译器不会去再次编译模块,而是从这个缓存文件中读取模块的信息供编译使用。当修改一个模块的实现时,只需要重新编译这个模块即可,其它模块不会受影响。因此可以提高编译速度。
如果想要理解 Clang Module 和 ModuleMap,可以参考这篇文章。
可以将工程分为几个模块,每个模块可以是个 framework 也可以是个库,如果要拆分的模块数量比较多,优先使用静态库,因为动态库数量如果太多了会影响 App 启动速度。
拆分模块后,确保每个模块的 Build Settings 中的 Defines Module 选项被设置为了 YES,这样 Xcode 才会为每个模块生成 modulemap 文件,确保模块被当做一个 Clang Module 编译。
在导入头文件时,如果是跨模块导入,对于 Objective-C,要确保带上模块的名称,避免使用 #import "xxx.h"
,而是要使用 #import <模块名/xxx.h>
,或者直接使用 @import 模块名;
。对于 Swift,直接 import 模块名
就好。只有在 import 后面包含了模块名,编译器才会讲这个 import 指令当做导入一个模块来处理,才能充分利用 Clang Module 的缓存特性。
如果使用了 CocoaPods 来管理第三方库,还要确保在 Podfile 文件中的头部加上 use_moduler_header!
这一指令,CocoaPods 会为所有第三方库创建 Module map 来支持 Clang Module。
模块静态化和包管理工具
当工程非常大时,将工程拆分成多个小的模块后,把其中的不经常改动的模块静态化是个能很大提升编译速度的方法。这些静态化的模块,即使 clean 后也不需要参与重新编译。一般会基于包管理工具来进行静态化。
常用的包管理工具有 CocoaPods、Carthage 和现在用的越来越多的 Swift Package Manager。
Carthage 本身就支持静态化,主工程直接引入编译好的 framework。只是 Carthage 不如 CocoaPods 好用,因此使用 Carthage 的人数不如 CocoaPods 那么多。如果要重点考虑编译时间问题,可以尽量采用 Carthage。
当用 CocoaPods 和 SPM 时,clean 后再重新编译或者执行后会导致所有第三方库重新编译,如果想避免重新编译,GitHub 上有一些基于 CocoaPods 的静态化方案,可以采用。例如 cocoapods-packager、cocoapods-binary、cocoapods-bin。
确保 target 之间的精确依赖关系
如果发现 Xcode 编译因为不能正确的识别 target 之间的依赖而导致编译顺序不正确或者没有并行编译,应该在 Build Phases 的 Target Dependencies 中明确地指定 target 之间的依赖。
如果工程依赖了另一个工程的 target,应该把另一个工程添加到当前工程中,这样 Xcode 才能识别出依赖关系并正确的处理编译顺序。
通过优化代码来提高编译速度
1. 去除不需要的代码
尽可能减少源文件中的任何代码文本的数量都可以在一定程度上帮助提高编译速度,因为代码文本越少,编译器需要做的分析工作就会就少。去除不需要的代码主要包含以下几个方面:
- 去除不再使用的类和源文件
- 去除不需要的头文件包含
- 去除一些没有意义的代码
第一条可以借助工具来查找哪些类不再被使用了,有很多类似的工具,例如 https://github.com/peripheryapp/periphery。
和第二条可以借助 AppCode 来显示哪些头文件包含是不需要的。第三条中,例如不再使用的函数,和用 Xcode 创建文件时生成的函数,如果这个函数存不存在都一样,那么去掉是最好的。例如创建 ViewController 时,Xcode 会自动创建一些样板代码,例如:
- (void)viewDidLoad {
[super viewDidLoad];
// Do any additional setup after loading the view.
}
类似这种代码,如果确实是用不上的,最好不要留在源文件中,删掉它。
3 去除不用的资源文件
编译器处理资源文件也会消耗一定的时间,去除不用的资源文件可以减少编译时间,可以借助一些工具来查找那些没有被使用的资源,例如 https://github.com/onevcat/FengNiao
4. 复用代码,不要重复
不要出现重复的代码,能复用的代码就尽量复用。
5. 尽可能使用前向声明替代头文件包含
前向声明(Forward declaration)指的是在某个符号还未定义的位置做一个这个符号的声明,告诉编译器这个符号的存在和符号的基本信息。Objective-C 里有 @class
、@protocol
这两个前向声明。用前向声明避免了把整个头文件的内容粘贴过来,由于头文件还嵌套其它头文件,这个粘贴过来的内容可能会非常大,并且不同的文件可能都会在嵌套包含路径中包含到一些相同的头文件,用前向声明代替头文件包含则大大减轻了编译器进行文本分析的工作量,并能减少很多不必要的重复工作。
6. 尽可能把 class 标记为 final
final class ViewController: UIViewController {
}
把 class 标记为 final 能让编译器做一些额外的优化,这可能会帮助提高编译速度。
7. 尽可能使用 let
原因同上条。
8. 减少在 Swift 和 Objective-C 之间共享的符号的数量
当工程中使用 Swift 和 Objective-C 混编时,两种语言之间知道的信息应该越少越好。Objective-C 的符号通过桥接头文件暴露给 Swift,Swift 的符号通过编译器生成的头文件暴露给 Objective-C。减少这两个头文件的内容可以减少编译器的工作量,这意味着编译器能更快的解析头文件,查找符号。
在 Objective-C 桥接头文件中,应该只包含需要真正用到的头文件。在这些写在桥接头文件内的头文件中,也应该只包含 Swift 需要用到的符号,哪些 Swift 不会用到的符号应该尽可能移到实现文件中,或者写在内部头文件中。
编译器会把所有 open 或 public 的,并标记为 @objc 的 Swift 符号都通过一个生成的头文件暴露给 Objective-C。遵守以下原则可以减小这个生成的头文件的大小,从而提高编译速度。
- 把 Swift 类或结构体的属性和方法标记为 private,这样就不会包含进自动生成的头文件中。
- 优先选择基于 block 的 API 而不是基于函数的 API,block 是实现的一部分,不会生成 public 的符号信息。
- 应该使用最新版本的 Swift,Swift 3 和更早的版本会把更多的符号都包含进头文件中,而新版的 Swift 包含的符号更少,减少了生成头文件的大小。
9. 避免让编译器进行复杂的类型推导
Swift 的编译器会在编译过工程中做类型推导,如果某个语句的类型推导太过复杂,这会消耗较多的编译时间,遇到这种情况,明确地指出类型是比较好的选择。如下面的例子:
struct ContrivedExample {
var bigNumber = [4, 3, 2].reduce(1) {
soFar, next in
pow(next, soFar)
}
}
为了推导出 bigNumber 的类型,编译器必须计算出 reduce 的结果,这个会花费较多的时间,因此这种情况下明确地指定类型是最佳实践。
struct ContrivedExample {
var bigNumber: Double = [4, 3, 2].reduce(1) {
soFar, next in
pow(next, soFar)
}
}
10. 避免使用 Any 开头的不明确类型
对于 Any 开头的类型,由于它的具体类型可能是各种类型,编译器不明确它的具体类型,这可能会消耗编译器较多的处理时间。
11. 清除代码中的警告
编译器在分析代码时发出警告信息理论上也会增加一点编译器时间,尽可能消除编译器报出的警告,这不仅可以减少编译时间,也会让代码质量更高,减少隐患。
12. 进行逻辑判断时,使用 ! 操作符,不要使用 == 和 !=
不好的代码:
if flag == false {
}
好的代码:
if !flag {
}
13. 酌情使用 ?? 运算符
编译处理 ?? 运算符会消耗比 if-else 更多的时间,如果对编译时间有比较高的要求,可以减少 ?? 运算符的使用。
14. 其它
还有一些代码的写法也能提高编译速度,但是会牺牲编码体验,在现在 CPU 性能比较强劲的时代下,不太有必要,但也简单列一下,可以酌情选择使用。
- 完全不使用类型推导
final class ViewController: UIViewController {
var numbers: [Int] = [1, 2, 3]
private var userNames: [String] = [“zhangsan”, “lisi", “wangwu"]
private var isUpdating: Bool = false
}
对于简单的类型,编译器推导的速度是非常快的,感觉不出来区别,因此只是理论上能提高编译速度。
- 不适用枚举或者结构体成员的简洁写法
例如把
let action = UIAlertAction(title: "title", style: .default, handler: nil)
这里的 .default 改为 UIAlertAction.Style.default
这些方法的基本理论就是让编译器需要做的事情越少,编译速度就越快。
参考资料
- https://developer.apple.com/documentation/xcode/improving-the-speed-of-incremental-builds
- https://developer.apple.com/documentation/xcode/improving-build-efficiency-with-good-coding-practices
- https://betterprogramming.pub/improve-xcode-compile-and-run-time-8b8f812c17f8
- https://holko.pl/2016/10/18/dsym-debug/
- https://ricardo-castellanos-herreros.medium.com/speeding-up-xcode-builds-97173cb1adba