iOS之App启动优化
基本知识
App启动分为冷启动和热启动,启动时间由两个阶段构成:
pre-main
阶段:main()
函数之前所需要的时间main()
及main()
之后需要的时间
分析每个阶段做了做什么
App启动后,首先,系统内核(Kernel)创建一个进程。
加载可执行文件。(可执行文件是指
Mach-O
格式的文件,也就是App中所有.o
文件的集合体)这时,能获取到dyld(dyld是苹果的动态链接器)的路径。加载dyld,主要分为4步:
(1)load dylibs:这一阶段
dyld
会分析应用依赖的dylib
,找到其Mach-O
文件,打开和读取这些文件并验证其有效性,接着会找到代码签名注册到内核,最后对dylib
的每一个segment调用mmap()
(在Linux中,使用mmap()用来在进程虚拟内存地址空间中分配地址空间,创建和物理内存的映射关系)。(2)rebase/bind:进行
rebase
指针调整和bind
符号绑定。(3)ObjC setup:runtime运行时初始化。包括ObjC相关Class的注册、category注册、selector唯一性检查等。
(4)Initializers:调用每个ObjC类与分类的
+load
方法,调用attribute((constructor))
修饰的函数、创建C++静态全局变量。main函数执行后:
从 main 函数执行开始,到 appDelegate 的
didFinishLaunchingWithOptions
方法里首屏渲染相关方法执行完成。即,从main函数执行到设置self.window.rootViewController
执行完成的阶段在
dylib
的加载过程中系统为了安全考虑引入了ASLR
(Address Space Layout Randomization
)技术和代码签名。ASLR技术:镜像
Image
、可执行文件、dylib
、bundle
在加载的时候会在其指向的地址(preferred_address
)前面添加一个随机数偏差(slide
),防止应用内部地址被定位。由于ASLR的存在,镜像(Image,包括可执件文件、 dylib和bundle)会在随机的地址上加载,和之前指针指向的地址(
preferred_address
)会有个偏差(slide), dyld需要修正这个偏差,来指向正确的地址。Rebase
做的是将镜像读入内存,修正镜像内部的指针,性能消耗主要在 IO。Bind
做的是查询符号表,设置指向镜像外部的指针,性能消耗主要在CPU计算。
如何测量pre-main阶段的耗时
Xcode通过添加环境变量:DYLD_PRINT_STATISTICS,设置Value值为1
启动后就可以在Xcode控制台看到启动耗时:
如果需要获取各阶段更详细的耗时统计:添加环境变量DYLD_PRINT_STATISTICS_DETAIL,设置Value为1。
详细的耗时信息如下:
我们可以做哪些优化
- 减少动态库的加载,动态库之间通常都会有依赖关系,加载A库,A依赖B,就需要加载B,直到所有的依赖库加载完毕。Apple建议使用更少的动态库,最好能保持在除系统外6个以内;
- 减少已有的Objc类的数量,更多地使用Swift语言,Swift的struct能加载地更快;
- 尽可能少使用静态初始化器(static修饰)。比如,Swift全局变量在使用前确保被初始化但不使用初始化器,而是在需要的时候使用
dispatch_once
; - 减少
load()
方法,或者不在它内部做耗时操作,尽量放到initialize()
方法或者首屏渲染完成后再执行; - 减少加载启动后不会去使用的类或方法;少用C++全局变量;
main()
函数之后的操作中,除了首屏展示内容和必要的配置外,尽量延后其他操作。
动态库优化
因为项目中集成了Swift的三方库,而Apple没有实现构建和链接具有Swift代码的静态库,所以必须使用use_framework!
。
因为Podfile中use_framework!
指定项目强制使用动态库的形式加载所有第三方库,所以罪魁祸首就是use_framework!
。
解决方法就是将动态库转为静态库:
1 | post_install do |installer| |
但这样的强制转换很不安全,而且需要穷举所有第三方库为静态库或者动态库,每次更新podfile文件,配置也要跟着更新
所以我们找到了新的解决方法:
https://github.com/CocoaPods/CocoaPods/issues/9099
这篇文章阐述了CocoaPods
使用use_frameworks!
的痛点——只能全部使用动态库或静态库的问题,并扩展了CocoaPods支持pod使用者根据需要使用动态库或者静态库的方式——比如,我们想要的方式——除了不支持静态库方式链接的第三方库之外,其他的三方库都使用静态库。
1 | pod 'AFNetworking', '~> 1.0' |
更新:
含有Swift代码的应用程序都需要附带特定的动态库版本代码,所以一般包含Swift代码的App的包要比纯OC的包体积大。
在iOS13的系统上,App内不再包含Swift库,所以整体体积会比iOS13之前的要小。
二进制重排
二进制重排是什么
由于虚拟内存技术会产生缺页中断(Page Fault
),每页耗时有1微秒到0.8毫秒不等。由于启动时加载大量数据,如果产生大量缺页中断,时间叠加后用户会有明显感知。
二进制重排就是把所有启动时候的代码都放在连续的前几页,这样就很大程度减少了启动时的缺页中断(Page Fault
)从而优化启动速度。
它的核心思想是:重新排列方法符号的顺序,将启动相关的方法排在最前面,从而减少Page Fault
的数量。
准备知识:虚拟内存技术
系统的内存是分页管理的,映射表不是以字节为单位,而是 以页为单位。
Linux
以4K
为一页macOS
以4K
为一页iOS
以16K
一页
早期的计算机不断启动应用,到达一定数量以后会报错,应用无法正常运行,必须先关闭前面的部分应用才能继续开启。
这是因为早期计算机没有虚拟地址,一旦加载都会 全部加载到内存中 。一旦物理内存不够了,那么应用就无法继续开启。
应用在内存中的排序都是顺序排列的,这样进程只需要把自己的地址尾部往后偏移一点就能访问到别的进程中的内存地址,相当不安全。
用户使用时并不会使用到全部内存,如果 App
一启动就全部加载到内存中会浪费很多内存空间。 虚拟内存技术 的出现就是为了解决这个内存浪费问题。
App
启动后会认为自己已经获取到整个 App
运行所需的内存空间,但实际上并没有在物理内存上为他申请那么大的空间,只是生成了一张 虚拟内存和物理内存关联的表 。
地址翻译
当 App
需要使用某一块虚拟内存的地址时,会通过这张表查询该虚拟地址是否已经在物理内存中申请了空间。
- 如果已经申请了,则通过表的记录访问物理内存地址;
- 如果没有申请,则申请一块物理内存空间并记录在表中(
Page Fault
)。
这个通过进程映射表映射到不同的物理内存空间的操作叫 地址翻译 ,这个过程需要 CPU
和操作系统配合。
Page Fault
当数据未在物理内存会进行下列操作:
- 系统阻塞该进程
- 将磁盘中对应
Page
的数据加载到内存 - 把虚拟内存指向物理内存
为保证当前App
的正常使用,数据加载遵循以下原则:
- 如果有空闲内存空间就放空的内存空间中
- 如果没有就覆盖其他进程的数据
- 具体覆盖由操作系统处理
检测Page Fault
使用Xcode的Instruments
工具中的system trace模板
手机连接到电脑
在system trace中选择连好的手机,选择要检测的应用
点击左上角开始按钮,等App启动完成后,停止
在搜索框中输入main,搜索App主线程,选中Main Thread
在下方控制台中选中Narrative,在下拉列表中选中Summary: Virtual Memory,里面展示的File Backed Page In就是
Page Fault
的相关数据
怎么知道方法符号的顺序?
步骤:
在
Xcode
的配置中Target -> Build Setting -> Linking
将Write Link Map File
设置为YES
,编译项目在项目的Products下找到
.app
文件,show In Finder,回到上一级Products文件同级目录找到Products同级目录下的
intermediates.noindex—>{productName}.build —> {Scheme}-iphoneos—>{ProjectName}
目录,打开
xx.build
→{appname}-LinkMap-normal-arm64.txt
LinkMap文件最前面的部分就是文件的执行顺序:
Symbols后面是方法的执行顺序:
怎么根据符号表进行排序
Xcode
提供了排列符号的设置给开发者,设置 order_file
即可。
如何设置
order_file
- 创建
order_file.order
文件 Target
→Build setting
→linking
→order file
,设置order file路径为创建的order_file.order
的路径即可
- 创建
怎样自动生成
order_file
——编译器插桩编译器插桩生成
order_file
原理:跟踪到 每个方法的执行,从而获取到启动时 方法执行的顺序,然后再按照这个顺序去编写order file
。怎么拿到方法执行顺序呢?
LLVM具有内置的简单代码覆盖率检测工具(
SanitizerCoverage
)- 它可以在函数,块、边缘级别插入用户定义函数并提供回调
- 它可以实现简单的可视化覆盖率报告
我们可以借助这个工具对方法、block的调用进行插桩,也就是在方法调用的开始和结束位置插入特定代码,从而跟踪到启动时的方法执行顺序,根据方法执行顺序生成
order_file
。具体用法:
添加设置:
Target
→Build Setting
→Custom Complier Flags
→Other C Flags
添加-fsanitize-coverage=func,trace-pc-guard
如果工程支持Swift,需要额外在
Target
→Build Setting
→Custom Complier Flags
→Other Swift Flags
中添加-sanitize-coverage=func
和-sanitize=undefined
由于需要在所有的二进制文件添加代码,所以对使用
CocoaPods
的工程来说,也要添加额外配置:1
2
3
4
5
6
7
8post_install do |installer|
installer.pods_project.target.each do |target|
target.build_configurations.each do |config|
config.build_settings['OTHER_CFLAGS'] = '-fsanitize-coverage=func,trace-pc-guard'
config.build_settings['OTHER_SWIFT_FLAGS'] = '-sanitize-coverage=func,-sanitize=undefined'
end
end
end在AppDelegate中实现方法:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15void __sanitizer_cov_trace_pc_guard_init(uint32_t *start,
uint32_t *stop) {
static uint64_t N; // Counter for the guards.
if (start == stop || *start) return; // Initialize only once.
printf("INIT: %p %p\n", start, stop);
for (uint32_t *x = start; x < stop; x++)
*x = ++N; // Guards should start from 1.
}
void __sanitizer_cov_trace_pc_guard(uint32_t *guard) {
if (!*guard) return;
void *PC = __builtin_return_address(0);
char PcDescr[1024];
printf("guard: %p %x PC %s\n", guard, *guard, PcDescr);
}在
__sanitizer_cov_trace_pc_guard
方法内打上断点,运行项目,执行到断点时,选择Debug
→Debug Workflow
→Always Show Disassembly
(一直显示汇编)在启动时会调用到的某个方法里打上断点,会发现在汇编出被插入了
__sanitizer_cov_trace_pc_guard
;block被执行时也会被插入
__sanitizer_cov_trace_pc_guard
;验证方法和
block
都可以被插桩无误,就可以着手生成order_file
了。在启动后加载的第一个
controller
中添加生成order_file
的代码如下,在生成filePath
后加断点,等运行到这里时,即可拿到order_file
的路径。代码部分:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62void __sanitizer_cov_trace_pc_guard_init(uint32_t *start, uint32_t *stop) {
static uint64_t N; // Counter for the guards.
if (start == stop || *start) return; // Initialize only once.
printf("INIT: %p %p\n", start, stop);
for (uint32_t *x = start; x < stop; x++)
*x = ++N; // Guards should start from 1.
}
//初始化原子队列
static OSQueueHead list = OS_ATOMIC_QUEUE_INIT;
//定义节点结构体
typedef struct {
void *pc; //存下获取到的PC
void *next; //指向下一个节点
} Node;
void __sanitizer_cov_trace_pc_guard(uint32_t *guard) {
void *PC = __builtin_return_address(0);
Node *node = malloc(sizeof(Node));
*node = (Node){PC, NULL};
// offsetof() 计算出列尾,OSAtomicEnqueue() 把 node 加入 list 尾巴
OSAtomicEnqueue(&list, node, offsetof(Node, next));
}
- (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event {
NSMutableArray *arr = [NSMutableArray array];
while(1){
//有进就有出,这个方法和 OSAtomicEnqueue() 类比使用
Node *node = OSAtomicDequeue(&list, offsetof(Node, next));
//退出机制
if (node == NULL) {
break;
}
//获取函数信息
Dl_info info;
dladdr(node->pc, &info);
NSString *sname = [NSString stringWithCString:info.dli_sname encoding:NSUTF8StringEncoding];
printf("%s \n", info.dli_sname);
//处理c函数及block前缀
BOOL isObjc = [sname hasPrefix:@"+["] || [sname hasPrefix:@"-["];
//c函数及block需要在开头添加下划线
sname = isObjc ? sname: [@"_" stringByAppendingString:sname];
//去重
if (![arr containsObject:sname]) {
//因为入栈的时候是从上至下,取出的时候方向是从下至上,那么就需要倒序,直接插在数组头部即可
[arr insertObject:sname atIndex:0];
}
}
//去掉 touchesBegan 方法 启动的时候不会用到这个
[arr removeObject:[NSString stringWithFormat:@"%s",__FUNCTION__]];
//数组合成字符串
NSString * funcStr = [arr componentsJoinedByString:@"\n"];
//写入文件
NSString * filePath = [NSTemporaryDirectory() stringByAppendingPathComponent:@"link.order"];
NSData * fileContents = [funcStr dataUsingEncoding:NSUTF8StringEncoding];
NSLog(@"%@", filePath); // 打断点拿到order_file路径
[[NSFileManager defaultManager] createFileAtPath:filePath contents:fileContents attributes:nil];
}
拿到
order_file
后放到项目根目录中,在Build Setting
→Linking
→Order File
中设置order_file
路径即可。如果项目对于启动流程相关的文件方法进行调整,再重新生成
order_file
文件更新即可。小记:本次优化使用了动态库转静态库 + 二进制重排方案。最终,项目启动速度冷启动大约提升44%;热启动大约提升46%;。
优化前后对比如下: