启动的过程一般是指从用户点击app图标
开始到AppDelegate 的didFinishLaunching
方法执行完成为止,其中,启动也分为冷启动
和热启动
冷启动
:内存中不包含app相关数据的启动,一般我们可以通过重启手机来实现冷启动
- 1
pre-main
阶段,即main函数之前
,操作系统加载App可执行文件到内存,执行一系列的加载&链接
等工作,简单来说,就是dyld加载
过程 - 2:
main
函数之后,即从main函数开始,到Appdelegate 的didFinishLaunching
方法执行完成
为止,主要是构建第一个
界面,并完成渲染
-热启动
:是指杀掉app进程后,数据仍然存在时的启动
其中1,2 两过程 就是 从用户点击App图标
到用户能看到app主界面
的过程,即需要启动优化的部分
pre-main
阶段的优化
统计main函数
启动时间
- 查看系统给的反馈需要增加一个
环境变量
, - 增加路径:在
Xcode -> Edit Scheme -> Run -> Arguments -> Environment Variables
中, - 增加一个环境变量
DYLD_PRINT_STATISTICS:1
。
-dylib loading time
(动态库耗时):主要是加载动态库
,系统的动态库做过优化,耗时较少。苹果官方推荐最多不要超过6
个外部动态库,多余6个,需要考虑合并动态库
,合并动态库对于启动时期的优化,非常有效
-rebase/binding time
(偏移修正/符号绑定耗时)
-rebase
(偏移修正):任何一个app
生成的二进制文件,在二进制文件内部所有的方法、函数调用,都有一个地址,这个地址是在当前二进制文件中的偏移地址
。一旦在运行时刻
(即运行到内存中),每次系统都会随机分配一个ASLR
(Address Space Layout Randomization
,地址空间布局随机化)地址值(是一个安全机制
,会分配一个随机
的数值,插入在二进制文件的开头
),例如,二进制文件中有一个 test
方法,偏移值是0x0001
,而随机分配的ASLR是0x1f00
,如果想访问test方法,其内存地址(即真实地址)变为 ASLR+偏移值 = 运行时确定的内存地址(即0x1f00+0x0001 = 0x1f01
)
-binding
(绑定):,例如NSLog
方法,在编译时期生成的mach-o
文件中,会创建一个符号!NSLog(
目前指向一个随机的地址),然后在运行时(从磁盘加载到内存中,是一个镜像文件),会将真正的地址给符号(即在内存中将地址与符号进行绑定,是dyld做的,也称为动态库符号绑定),一句话概括:绑定就是给符号赋值的过程
-
Objc setup time:
注册所有OC类
耗时,类越多耗时越多
,有人统计过2万个自定义的OC的类,大概耗时800毫秒
。删除不用的类,可以减少耗时 -
initializer time
:load
方法 和C++构造函数
的耗时.减少重写load
方法,尽量将事情延迟
到 main 方法以后,可以减少耗时。
优化建议
- 减少
外部动态库
的数量
-减少OC类
,因为OC类越多,越耗时
-将不必须在+load
方法中做的事情延迟到+initialize
中,尽量不要用C++虚函数 - 启动阶段能使用
多线程来
初始化的,就使用多线程 - 使用
纯代码
。不用xib storyboard
(要额外进行代码解析转换和页面的渲染)
二进制重排
原理
当进程访问一个虚拟内存page
,而对应的物理内存
不存在时,会触发缺页中断
(Page Fault
),因此阻塞进程。此时就需要先加载数据到物理内存
,然后再继续访问
App在冷启动
过程中,会有大量的类、分类、三方
等需要加载和执行,此刻需要调用的方法,处于不同的Page
导致的此时的产生的Page Fault
所带来的的耗时是很大的
将所有启动时刻
需要调用的方法
,排列在一起,即放在一个
页中,这样就从多个Page Fault
变成了一个Page Fault
,优化了启动时间
监测
查看一下项目的缺页异常数量
。注意需要卸载 APP
或者重启手机
,来保证这个APP完全没有被加载到内存中
打开Instrument -> System Trace
- 点击启动,第一个界面出来后,停掉,
!Page Faul](https://upload-images.jianshu.io/upload_images/9660710-21b0a88923b7e8a7.png?imageMogr2/auto-orient/strip%7CimageView2/2/w/1240)
从图中可以看出发生的PageFault
有427
次
优化后项目的缺页遗产数量是286
, 减少了启动时大概40%的缺页异常~
通过linkMap
也可以查看启动方法的顺序
-
在
Build Setting -> Write Link Map File
设置为YES
-
编译,然后在对应的路径下查找
link map
文件,如下所示,可以发现 类中函数的加载顺序是从上到下
的,而文件的顺序是根据Build Phases -> Compile Sources
中的顺序加载的
二进制重排的方法
1.方法的重排序
xcode
已经为我们提供了这个机制,它使用的链接器
叫做ld,
ld
有一个参数叫做Order File
, 我们可以通过配置order
文件,来使编译时生成的二进制的文件的Link Map
种的符号顺序,按照我们指定的顺序排列生成。而且libobjc
实际上也做了二进制重排。
【第一步】在项目根目录
下建一个xxx.order
的文件,里面写上按照自己想排列的顺序
,写上方法
或者函数的名字
。(如果写了一个不存在的符号,也不会报错,会被自动过滤掉~)
【第三步】重新编译,查看 Link Map
文件的顺序,果然,按照我们指定的顺序排列啦!
找到启动时需要方法 -> 静态插桩
接下来,需要做的就是写入 order 文件里的符号
了,我们不可能手写上所有的启动时需要的执行的符号,这里的所有符号包括,调用的方法、函数、C++构造方法、swift方法、block。
这里使用
LLVM 内置的简单代码覆盖率检测工具 [图片上传失败...(image-b6c749-1626686913498)]。它在
边缘、函数、
基本块``级别`上插入对用户定义函数的调用。
edge
(默认):检测边缘
(所有的指令跳转都会被插入对用户定义函数的调用, 如循环、分支判断、方法函数等)。
bb
:检测基本块。
func
:仅将检测每个
功能的输入块(这个就是我们要重排序的符号)。
按照文档,
【第1步】搜索并设置
Other C Flags/ Other C++ Flags
为-fsanitize-coverage=func,trace-pc-guard
(这里要用func, 不能用默认的edge, 不然会造成死循环
)。-
如果有swift ,需要设置
Other Swift Flags
设置为-sanitize-coverage=func -sanitize=undefined
【第2步】编译器将插入对
模块构造函数
的调用,所以我们要实现这个方法:
__sanitizer_cov_trace_pc_guard_init(uint32_t *start, uint32_t *stop);
- 参数1 start 是一个指针,指向无符号int类型,4个字节,相当于一个数组的起始位置,即符号的起始位置(是从高位往低位读)
- 参数2 stop,由于数据的地址是往下读的(即从高往低读,所以此时获取的地址并不是stop真正的地址,而是标记的最后的地址,读取stop时,由于stop占4个字节,stop真实地址 = stop打印的地址-0x4)
- 【第3步】
__sanitizer_cov_trace_pc_guard
实现: 主要是捕获所有的启动时刻
的符号
,将所有符号入队
//原子队列,其目的是保证写入安全,线程安全
static OSQueueHead queue = OS_ATOMIC_QUEUE_INIT;
//定义符号结构体,以链表的形式
typedef struct {
void *pc;
void *next;
}XXNode;
/*
- start:起始位置
- stop:并不是最后一个符号的地址,而是整个符号表的最后一个地址,最后一个符号的地址=stop-4(因为是从高地址往低地址读取的,且stop是一个无符号int类型,占4个字节)。stop存储的值是符号的
*/
void __sanitizer_cov_trace_pc_guard_init(uint32_t *start,
uint32_t *stop) {
static uint64_t N;
if (start == stop || *start) return;
printf("INIT: %p - %p\n", start, stop);
for (uint32_t *x = start; x < stop; x++) {
*x = ++N;
}
}
/*
可以全面hook方法、函数、以及block调用,用于捕捉符号,是在多线程进行的,这个方法中只存储pc,以链表的形式
- guard 是一个哨兵,告诉我们是第几个被调用的
*/
void __sanitizer_cov_trace_pc_guard(uint32_t *guard) {
// if (!*guard) return;//将load方法过滤掉了,所以需要注释掉
//获取PC
/*
- PC 当前函数返回上一个调用的地址
- 0 当前这个函数地址,即当前函数的返回地址
- 1 当前函数调用者的地址,即上一个函数的返回地址
*/
void *PC = __builtin_return_address(0);
//创建node,并赋值
XXNode *node = malloc(sizeof(XXNode));
*node = (XXNode){PC, NULL};
//加入队列
//符号的访问不是通过下标访问,是通过链表的next指针,所以需要借用offsetof(结构体类型,下一个的地址即next)
OSAtomicEnqueue(&queue, node, offsetof(XXNode, next));
}
-【第四步:获取所有符号并写入文件】
extern void getOrderFile(void(^completion)(NSString *orderFilePath)){
collectFinished = YES;
__sync_synchronize();
NSString *functionExclude = [NSString stringWithFormat:@"_%s", __FUNCTION__];
dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(0.01 * NSEC_PER_SEC)), dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
//创建符号数组
NSMutableArray<NSString *> *symbolNames = [NSMutableArray array];
//while循环取符号
while (YES) {
//出队
CJLNode *node = OSAtomicDequeue(&queue, offsetof(CJLNode, next));
if (node == NULL) break;
//取出PC,存入info
Dl_info info;
dladdr(node->pc, &info);
// printf("%s \n", info.dli_sname);
if (info.dli_sname) {
//判断是不是OC方法,如果不是,需要加下划线存储,反之,则直接存储
NSString *name = @(info.dli_sname);
BOOL isObjc = [name hasPrefix:@"+["] || [name hasPrefix:@"-["];
NSString *symbolName = isObjc ? name : [@"_" stringByAppendingString:name];
[symbolNames addObject:symbolName];
}
}
if (symbolNames.count == 0) {
if (completion) {
completion(nil);
}
return;
}
//取反(队列的存储是反序的)
NSEnumerator *emt = [symbolNames reverseObjectEnumerator];
//去重
NSMutableArray<NSString *> *funcs = [NSMutableArray arrayWithCapacity:symbolNames.count];
NSString *name;
while (name = [emt nextObject]) {
if (![funcs containsObject:name]) {
[funcs addObject:name];
}
}
//去掉自己
[funcs removeObject:functionExclude];
//将数组变成字符串
NSString *funcStr = [funcs componentsJoinedByString:@"\n"];
NSLog(@"Order:\n%@", funcStr);
//字符串写入文件
NSString *filePath = [NSTemporaryDirectory() stringByAppendingPathComponent:@"cjl.order"];
NSData *fileContents = [funcStr dataUsingEncoding:NSUTF8StringEncoding];
BOOL success = [[NSFileManager defaultManager] createFileAtPath:filePath contents:fileContents attributes:nil];
if (completion) {
completion(success ? filePath : nil);
}
});
}
- 第五步:在
didFinishLaunchingWithOptions
方法最后调用】需要注意的是,这里的调用位置是由你决定的,一般来说,是第一个渲染的界面
- 【第六步:
拷贝文件,放入指定位置,并配置路径
】一般将该文件放入主项目路径下,并在Build Settings -> Order File
中配置./XX.order
,下面是配置前后的对比(上边是配置前的熟悉怒,下边是配置后符号顺序的)