App 启动优化
作者丨Potato_土豆
转自丨掘金
链接:
https://juejin.cn/post/6965495023924330504#heading-4
App的启动过程
App的启动一般是指从用户点击App开始到AppDelegate
的didFinishLaunching
方法执行完成为止,一般又将启动分为冷启动和热启动。
-
冷启动
冷启动: 是指App启动前它的进程不在系统里,需要系统分配一个进程给它启动的情况,这是一次完成的启动(一般启动优化都是优化冷启动的过程) -
热启动
热启动: 是指App在冷启动后将App退后台,App的进程还在系统里,内存中海油App的数据的情况下,再次启动App的过程,这个过程做的事情也非常少
App启动优化
main
函数执行之前和main
函数执行之后
-
main
函数执行之前操作系统加载App可执行文件到内存,执行一系列的加载&链接工作,可以通过添加添加环境变量
DYLD_PRINT_STATISTICS
来查看main
函数执行之前都做了什么,同时也可以看出对应消耗的时间
不难发现main
函数执行之前主要做了以下几种事情
动态库的加载
对应的是`dylib loading time`可以发现加载时间为48.41毫秒
优化建议 :
这里主要的优化建议是减少动态库的加载,苹果公司建议更少的使用动态库,并且建议动态库的数量较多的时候,尽量将多个动态库合并,数量上苹果公司最多支持6个非系统动态库的合并偏移修正和符号绑定
对应的是rebase/binding time
,耗时9.18毫秒偏移修正
任何App生成的二进制文件中的方法、函数都会有个地址,而这个地址是相对于当前二进制文件中的偏移地址,但是到了运行时系统会随机生成一个数值添加到二进制文件的头部(ASLR
安全机制下文中会有讲解),所以此时函数、方法的地址就是 随机分配的数值+偏移地址 这个过程就是偏移修正符号绑定
动态库不像是静态库,静态库实在编译时期就将对应使用到的代码一起打包生成了mach-o
文件,所以此时使用到的静态库的方法、函数其实就和自定义的方法、函数差不多了,能够直接获取到对应的地址,
但是动态库在编译阶段是不会被打包进mach-o
文件的,但是此时又用到了动态库中的方法,例如用到了NSLog
方法,此时就会生成一个!NSLog
符号此时这个符号会随机指向一个地址,
当运行时,此时动态库被加载到内存,此时就可以拿到动态库对应的方法、函数的地址,所以此时就需要将!NSLog
这个符号绑定到相应的地址上去(dyld
做的),这个过程就叫做符号绑定-
类的注册
对应的是ObjC setup time
,耗时10.86毫秒
优化建议
删除启动后不会去使用的类 执行load和构造函数
对应的是initializer time
,耗时110.79毫秒
优化建议
减少使用load
方法相应的可以将load
中的实现放在+initialize()
方法中去,应为一般一个load
方法的执行需要耗时4毫秒,而且如果类中实现了load
那么相对应类的加载就要提前到read_image
方法中去执行,如果没有实现load
类的加载则会方法第一次发送消息的时候加载,-
main
函数执行之后这个阶段主要是指main函数执行开始到首屏渲染完成方法执行完毕。这个阶段主要做的工作包括:
-
第三方SDK初始化 -
自定义工具类初始化 -
首屏数据的加载 -
首屏渲染的一些计算 这个地方的优化建议主要有一下几点
-
只处理首屏渲染相关的任务,其他非首屏的业务例如初始化、注册监听、配置文件的读取等等都放在首页渲染完成之后去做,当然也可以开辟一个线程去处理这些事情。尽量不要占用主线程 -
自己的业务逻辑的优化,已经废弃的不需要用的逻辑代码、方法、函数都删除掉,减少每个流程的耗时 -
启动时期的页面尽量避免使用xib、storyboard(中间会有个转换的过程也是需要耗时的)UI的主框架尽量使用纯代码
二进制重排基础知识
上文主要是针对特定的阶段做一些优化处理,除了删除的优化方案还有一种优化,就是二进制重排,在讲解二进制重排之前先将几个概念性的东西:
-
物理内存
就是运行内存,是指计算机上安装的内存,通俗的将其实就是内存条的大小。
早期的操作系统没有虚拟内存,程序寻址用的都是物理地址,所以没启动一个程序开辟一个进程都要相应的分配一段物理内存给这个程序,这就造成了如下几个问题: -
当物理内存被分配完成的时候此时其他程序就不能再被加载到内存(也就是不能运行),此时就需要等待其他程序退出释放内存,此时才能运行新的程序 -
程序指令都是在物理内存上操作的,那么我这个进程就可以修改其他进程的数据,甚至会修改内核地址空间的数据 针对以上的问题也就引出了虚拟内存 -
虚拟内存
指的是把硬盘中的一部分空间用来当做内存使用
进程和物理内存之间增加一个中间层,这个中间层就是所谓的虚拟内存,主要用于解决当多个进程同时存在时,对物理内存的管理。提高了CPU的利用率,使多个进程可以同时、按需加载。
所以虚拟内存其本质就是一张虚拟地址和物理地址对应关系的映射表.每个进程都有一个独立的虚拟内存,其地址都是从0开始,大小是4G固定的。
进程开始要访问一个地址,它可能会经历下面的过程:
-
每次我要访问地址空间上的某一个地址,但是进程间是无法互相访问的,保证了进程间数据的安全(一个进程只能访问给定的这篇虚拟内存的地址)。都需要把地址翻译为实际物理内存地址 -
所有进程共享这整一块物理内存,每个进程只把自己目前需要的虚拟地址空间映射到物理内存上 -
每个虚拟内存会划分一个一个页存储(页的大小在iOS中是16K,其他的是4K),进程需要知道哪些地址空间上的数据在物理内存上,哪些不在(可能这部分存储在磁盘上),还有在物理内存上的哪里,这就需要通过页表来记录 -
页表的每一个表项分两部分,第一部分记录此页是否在物理内存上,第二部分记录物理内存页的地址(如果在的话) -
当进程访问某个虚拟地址的时候,就会先去看页表,如果发现对应的数据不在物理内存上,就会发生缺页异常 缺页异常的处理过程,操作系统立即阻塞该进程,并将硬盘里对应的页换入内存,然后使该进程就绪,如果内存已经满了,没有空地方了,那就找一个页覆盖,至于具体覆盖的哪个页,就需要看操作系统的页面置换算法是怎么设计的了。
3. ASLR
应为虚拟内存的起始地址与大小都是固定的,这意味着,当我们访问时,其数据的地址也是固定的,这会导致我们的数据非常容易被破解,
为了解决这个问题,所以苹果为了解决这个问题,在iOS4.3开始引入了ASLR技术,其实现原理就是在虚拟内存的头部随机加上一块地址,这样每次启动时虚拟地址的其实址就不一样,所以在程序启动的时候需要做偏移修正。
二进制重排原因
从上文的知识中可以知道,ios程序在加载到虚拟内存的时候会被分成很多很多页,如果此时访问的虚拟地址的一个page,对应的物理地址不存在,则会缺页异常,此时会阻塞进程将这一页加载到物理内存然后在访问。
这里可以通过instruments
的System Trace
来查看你的项目的缺页异常的数量如下: 步骤:先点击启动->首页加载完成后暂停->然后找到你的项目找到主线程 发现启动之前有两百多个缺页异常,此时我们再看项目在编译时期的默认排列顺序,此时我们写一个简单的demo如下图:就是写了几个简单的方法,然后项目中选择Build-setting
搜索link map
然后配置此时会发现对应配置的文件夹中生成了对应的link-map
文件,发现方法、函数等都是按照在文件中的实现顺序来的,而文件的顺序是按照comple source
中的顺序来的如图:这种情况就造成了每个页有可能只有一个方法是有用的,其他方法、函数等都不是在启动阶段调用的,这就造成了在启动时期缺页异常的数量会很多,也就造成了启动时间变长的情况。
这也就是需要进行二进制重排的原因
二进制重排原理
上文分析了二进制重排的原因,就是应为页中空间的浪费没有充分利用每个页的空间造成缺页异常数量增多,二进制重排的原理其实就是将启动阶段用到的方法、函数全部排在最前面,这样就能充分利用每个页的空间,与此同时也降低了缺页异常的数量。如下图所示:
明显减少了一大半的缺页异常的数量
二进制重排实践
通过上面的原理分析可以知道,如果做二进制重排只需要改变编译时期方法、函数等的排列顺序就行。其本质就是就是对启动加载的符号进行重新排列。
-
修改排列顺序的方法 Xcode
是用的链接器叫做ld
,ld
有一个参数叫Order File
, 我们可以通过这个参数配置一个order
文件的路径 .
我们可以通过在Build Settings
->Order File
配置一个后缀为order
的文件路径。在这个order
文件中,将所需要的符号按照顺序写在里面,在项目编译时,会按照这个文件的顺序进行加载,以此来达到我们的优化,所以二进制重排的关键点在于Order File
文件的生成 -
获取 Order File
文件的方法
1. 如果项目不大的情况下自己也可以根据项目自己找到启动阶段要运行的方法、函数,自己编写Order File
文件。
2.hook
objc_msgSend
,但是由于objc_msgSend
的参数是可变的,需要通过汇编获取,使用门槛比较高。而且也只能拿到OC和swift中@objc
后的方法
3. 静态扫描:扫描Mach-O
特定段和节里面所存储的符号以及函数数据
4.Clang
插桩:即批量hook,可以实现100%符号覆盖,即完全获取swift、OC、C、block
函数 -
Clang
插桩llvm
内置了一个简单的代码覆盖率检测(SanitizerCoverage
)。它在函数级、基本块级和边缘级插入对用户定义函数的调用,相应文档
具体步骤:
-
配置开启 SanitizerCoverage
,在build setting
中搜索Other C Flags
,如下图如果是OC项目则添加 -fsanitize-coverage=func,trace-pc-guard
,如果是swift项目则添加-sanitize-coverage=func
和-sanitize=undefined
-
添加 hook
方法
void __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) {
//guard 是一个哨兵,告诉我们是第几个被调用的
// 这个地方 是过滤掉了load方法,所以这里需要注释掉
if (!*guard) return;
/*
- PC 当前函数返回上一个调用的地址
- 0 当前这个函数地址,即当前函数的返回地址
- 1 当前函数调用者的地址,即上一个函数的返回地址
*/
void *PC = __builtin_return_address(0);
char PcDescr[1024];
printf("guard: %p %x PC %s\n", guard, *guard, PcDescr);
}
主要的方法在于__sanitizer_cov_trace_pc_guard
方法,在这里我们可以取到对应方法的地址,为什么方法执行之前会先调用__sanitizer_cov_trace_pc_guard
方法呢,可通过断点调试查看,在一个方法或者函数的起始处大断点,再看汇编代码如下图:发现在方法执行之前插入了__sanitizer_cov_trace_pc_guard
方法,所有的函数执行都会限制性__sanitizer_cov_trace_pc_guard
方法,在block前面也打个断点发现block
执行前也会被插入__sanitizer_cov_trace_pc_guard
方法,继续查看swift-oc
混编是swift
方法是否会被hook
也会被hook
,所以也验证了clang插桩的方法能覆盖所有方法、函数。
3. 获取符号 上述hook
方法中我们知道可以拿到当前方法或者函数的地址,拿到地址之后我们可以通过dladdr
方法去除对应方法或者函数的信息具体代码如下图:发现dli_sname
就是我们想要的符号,接下来的操作主要就是把这些符号存储下来然后生成order然后工程再配置对应的Order file
就算完成了。
4. 输出order文件
上文中已经可以拿到符号了,最后的工作就是输出order文件。
具体思路:我们可以在__sanitizer_cov_trace_pc_guard
将函数地址信息存储下来然后给app
添加一个点击屏幕的监听事件,等到首屏加载完毕说明启动完成所有所需要加载的方法也就加载完成,此时我们再在这个方法遍历地址信息,输出符号。
我这里借用的链表存储,所以先要建立一个节点如下图: 然后再通过OSQueueHead
创建原子队列,其目的是保证读写安全。
通过OSAtomicEnqueue
方法将node
入队,通过链表的next
指针可以访问下一个符号此刻地址的储存完成下一步就是读取写入order文件:具体代码如下
-(void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event
{
//定义数组
NSMutableArray<NSString *> * symbolNames = [NSMutableArray array];
while (YES) {//一次循环!也会被HOOK一次!!
SYNode * node = OSAtomicDequeue(&symbolList, offsetof(SYNode, next));
if (node == NULL) {
break;
}
Dl_info info = {0};
dladdr(node->pc, &info);
// printf("%s \n",info.dli_sname);
NSString * name = @(info.dli_sname);
free(node);
BOOL isObjc = [name hasPrefix:@"+["]||[name hasPrefix:@"-["];
//需要注意如果不是OC方法需要添加下划线
NSString * symbolName = isObjc ? name : [@"_" stringByAppendingString:name];
[symbolNames addObject:symbolName];
}
//反向数组
NSEnumerator * enumerator = [symbolNames reverseObjectEnumerator];
//创建一个新数组
NSMutableArray * funcs = [NSMutableArray arrayWithCapacity:symbolNames.count];
NSString * name;
//去重!
while (name = [enumerator nextObject]) {
if (![funcs containsObject:name]) {//数组中不包含name
[funcs addObject:name];
}
}
[funcs removeObject:[NSString stringWithFormat:@"%s",__FUNCTION__]];
//数组转成字符串
NSString * funcStr = [funcs componentsJoinedByString:@"\n"];
//字符串写入文件
//文件路径
NSString * filePath = [NSTemporaryDirectory() stringByAppendingPathComponent:@"tudou.order"];
//文件内容
NSData * fileContents = [funcStr dataUsingEncoding:NSUTF8StringEncoding];
[[NSFileManager defaultManager] createFileAtPath:filePath contents:fileContents attributes:nil];
}
运行完成发现生成了order文件5. Xcode配置order文件 如图配置文件 6. 查看二进制重排结果 最后同样的查看生成的link map文件:
没有二进制重排之前: 发现是按照文件按照方法的顺序来的。
二进制重排之后:
发现此时就是按照我们的order文件的顺序来的
-End-
最近有一些小伙伴,让我帮忙找一些 面试题 资料,于是我翻遍了收藏的 5T 资料后,汇总整理出来,可以说是程序员面试必备!所有资料都整理到网盘了,欢迎下载!
面试题
】即可获取