在聊启动优化前,我们先得明确一个概念:启动分为冷启动(app第一次启动)和热启动,而优化的一般是冷启动。冷启动部分一般会以main函数作为结点分为两部分:main函数之前和main函数之后;
main函数之前 -- pre-main
想要优化main函数之前耗时,那得知道main函数之前都干了什么。我们通过xcode工程添加环境变量DYLD_PRINT_STATISTICS去打印出来;

Total pre-main time: 1.1 seconds (100.0%)
dylib loading time: 48.16 milliseconds (4.1%)
rebase/binding time: 37.59 milliseconds (3.2%)
ObjC setup time: 430.91 milliseconds (36.7%)
initializer time: 656.02 milliseconds (55.9%)
slowest intializers ://下面几个是最慢耗时库的list
libSystem.B.dylib : 9.07 milliseconds (0.7%)
libglInterpose.dylib : 206.17 milliseconds (17.5%)
demoApp : 768.08 milliseconds (65.4%)
-
Total pre-main time:pre-main总的时间。
-
dylib loading time:动态库加载时间。能使用系统提供的动态库尽量使用过系统的;如果是自己开发的动态库,尽量不要超过6个,如果超过6个,考虑去合并多余的动态库
-
rebase/binding time:rebase(修正偏移指针=ASLR + 偏移值)、binding(符号绑定),简单说就是将函数、常量等的最终地址确定下来,便于查找调用的时间;
-
ObjC setup time:OC类的注册耗时
-
initializer time:load函数耗时
-
libSystem.B.dylib:系统库的耗时
-
libglInterpose.dylib:调试库(也是系统库)的耗时
-
demoApp:主程序的耗时
以上哪些部分可以优化
- dylib loading time:动态库加载时间。能使用系统提供的动态库尽量使用过系统的;如果是自己开发的动态库,尽量不要超过6个,如果超过6个,考虑去合并多余的动态库
- rebase/binding time和ObjC setup time:这两个其实能优化的很少,即减少类的数量,而且效果不好(减少20000个类才只能减少800ms),当然也可以优化比如删除不再使用的类、减少重复代码...注意如果是Swift,会快很多,这个是语言级别的优化了,所以你也可以将一些oc类用swift实现,具体能减少多少自己去测试吧
- initializer time:既然是load()方法的耗时,可以考虑将load()方法里的耗时操作放到initialize()方法中,做到加载,当然不能破坏原来的功能(比如方法交换什么的放到initialize()方法中就会出问题)
main函数之后
main函数之后的耗时一般是main函数进来到第一个界面显示的时间(viewDidAppear:), 所以可以用打点计时的方式去记录,这里推荐一个三方的计时器: time = main ---》第一个界面 打点计时器:BLStopwatch
BLStopwatch使用举例
我们可以在我们需要计算耗时的地方加上BLStopwatch 1、didFinishLaunchingWithOptions耗时




如何优化
根据自己工程的业务,main函数之后可以在下面几个阶段去做优化
- 1.懒加载:把暂时不需要的加载的放到后面去
- 2.发挥CPU的价值,尽量用多线程进行初始化
- 3.启动界面避免使用storyboard、xib
- 4.release版本移除打印
二进制重排优化
虚拟内存和物理内存
如下图可知,每个进程访问内存时并不是直接访问的,每个进程会有一分虚拟内存,需要通过中间的页表(映射表),映射后们才能取到真实的物理地址,所以进程中的虚拟内存地址是连续的,但是映射后真实的物理地址并不一定是连续的。

内存分页管理
每个进程都有若干虚拟的页表(上图的映射表),当我们在使用进程时,系统会对相对的部分的页表进行映射访问,但是当前物理内存没有对应的部分时,系统就会阻塞当前进程(这个过程我们称为缺页中断),并去创建的页表对应的内存。即上图进程1,P2页在物理内存中没有时,就会在物理内存中创建;如果物理内存有空的内存区就在空的内存区创建,没有空的内存区,系统就会找一页去覆盖某个不活跃的页。
ASLR
虚拟内存确实能解决效率的问题,但是也会有安全问题,因为虚拟内存时确定的,所以很容易被黑客找到;因此出现了在每次加载虚拟内存前加上一个随机的偏移值,使虚拟内存地址变的不确定:ASLR技术(地址空间随机化,具体自己百度)

二进制重排原理
我们在启动的时候,需要加载大量的代码,而这些代码的顺序是根据文件的顺序生成的。比如下图,app启动需要用到P1,P3,P5部分的页表,但是中间隔了P2、P4这个时候我们可以考虑将提到前面,这样缺页中断(page fault)就会少,耗时也就少了;这种将启动代码放在二进制文件前面的做法就是二进制重排

System Trace
日常开发中性能分析是用最多的工具无疑是Time Profiler,但Time Profiler是基于采样的,并且只能统计线程实际在运行的时间,而发生Page Fault的时候线程是被blocked,所以我们需要用一个不常用但功能却很强大的工具:System Trace。(Xocde - Instruments - System Trace)

main thread,选择Virtual Memory。图中File Backed Page In次数就是缺页中断Page Fault次数,并且双击还能按时序看到引起Page Fault的堆栈,可以看到demo工程有3078次缺页中断,耗时821.13ms。

- 如果是程序第一次启动,这个缺页中断数会比热启动大
- 将app退到后台,双击home键,上滑杀死app,重新启动,这个缺页中断数值会小很多,可以认为这种不算冷启动。说明系统会做一些优化。
- 双击home键,上滑杀死app,之后打开其他几个app,过个10min后重新启动app,这个缺页中断数又会变得很大
Linkmap
Linkmap是iOS编译过程的中间产物,记录了二进制文件的布局,需要在Xcode的Build Settings里开启Write Link Map File - YES:

product - show in finder - intermediates.noindex - XXX build - bebug - XXX.build - XXX.linkMap-normal-arm64.txt

# Path: /Users/Library/Developer/Xcode/DerivedData/Demo-aomzumcaofovkzbrotetwdamhtrc/Build/Products/Debug-iphoneos/Demo.app/Demo
# Arch: arm64
# Object files:
[ 0] linker synthesized
[ 1] /Users/Library/Developer/Xcode/DerivedData/Demo-aomzumcaofovkzbrotetwdamhtrc/Build/Intermediates.noindex/Demo.build/Debug-iphoneos/Demo.build/Objects-normal/arm64/AppDelegate.o
[ 2] /Users/Library/Developer/Xcode/DerivedData/Demo-aomzumcaofovkzbrotetwdamhtrc/Build/Intermediates.noindex/Demo.build/Debug-iphoneos/Demo.build/Objects-normal/arm64/ViewController.o
[ 3] /Users/Library/Developer/Xcode/DerivedData/Demo-aomzumcaofovkzbrotetwdamhtrc/Build/Intermediates.noindex/Demo.build/Debug-iphoneos/Demo.build/Objects-normal/arm64/main.o
...
# Sections:
# Address Size Segment Section
0x100005EC0 0x00000664 __TEXT __text
0x100006524 0x00000090 __TEXT __stubs
0x1000065B4 0x000000A8 __TEXT __stub_helper
...
0x100008000 0x00000008 __DATA __got
0x100008008 0x00000060 __DATA __la_symbol_ptr
0x100008068 0x00000060 __DATA __cfstring
...
# Symbols:
# Address Size File Name
0x100005EC0 0x000000A8 [ 3] _main
0x100005F68 0x0000002C [ 1] +[AppDelegate load]
0x100005F94 0x0000002C [ 2] +[ViewController load]
0x100005FC0 0x00000064 [ 2] -[ViewController viewDidLoad]
0x100006024 0x0000006C [ 1] -[AppDelegate application:didFinishLaunchingWithOptions:]
0x100006090 0x00000114 [ 1] -[AppDelegate application:configurationForConnectingSceneSession:options:]
0x1000061A4 0x00000064 [ 1] -[AppDelegate application:didDiscardSceneSessions:]
0x100006208 0x00000014 [ 2] -[ViewController test2]
0x10000621C 0x00000014 [ 2] -[ViewController test1]
0x100006230 0x00000070 [ 2] -[ViewController viewDidAppear:]
0x1000062A0 0x00000088 [ 4] -[SceneDelegate scene:willConnectToSession:options:]
0x100006328 0x00000040 [ 4] -[SceneDelegate sceneDidDisconnect:]
...
linkmap主要包括三大部分:
- Object Files 生成二进制用到的link单元的路径和文件编号,其顺序就是工程文件在
build phase - compile sources中的顺序 - Sections 记录Mach-O每个Segment/section的地址范围.
__TEXT是代码段,只读;__DATA是数据段,可读可写。 - Symbols 按顺序记录每个符号的地址范围。1.以地址为区分的 2.file指的是占用多少空间,在方法中写的代码越多,size越大
.order文件
.order文件可以指定符号(符号)的放到虚拟内存页表的前几页。按照这个思路,我们可以将app启动时需要调用的符号(方法)整理到.order文件文件中,这样缺页中断数就会减少,启动也就是更快了。
.order文件配置
- 在主工程路径下新建一个.order文件,使用vim编辑它
~ cd /Users/Demo
~ Demo touch fun.order
~ Demo vim fun.order
- vim编辑内容
_main
+[AppDelegate load]
+[ViewController load]
-[ViewController viewDidLoad]
-[dljljl dage]//工程中的无效的方法会被丢弃
-[oooo v587]//工程中的无效的方法会被丢弃
- 指定.order文件路径- Build Settings - order file。
这样二进制文件重排就OK了,当然这只是个demo,二进制重排启动优化效果不明显,但是如果是大的工程,那绝对是有很大的进步。
如何找到所有启动时的方法
我们已经知道了如何使用order file进行二进制重排,但是如何找到启动时,所有调用的的方法呢?
fishhook - objc_msgSend方案
抖音团队给出一个方案:使用了绝大部分Objective C的方法在编译后会走objc_msgSend,所以通过fishhook hook这一个C函数即可获得Objective C符号。但是这个会有一些瓶颈:
- initialize hook不到
- 部分block hook不到
- C++通过寄存器的间接函数调用静态扫描不出来
这种重排方案能够覆盖到80%~90%的符号,不能达到100%。
使用Clang SanitizerCoverage 的方案
简单来说 SanitizerCoverage 是 Clang 内置的一个代码覆盖工具。它把一系列以 _sanitizer_cov_trace_pc 为前缀的函数调用插入到用户定义的函数里,借此实现了全局 AOP 的大杀器。其覆盖之广,包含 Swift/Objective-C/C/C++ 等语言,Method/Function/Block 全支持。
- 如何开启SanitizerCoverage
在 build settings 里的 “Other C Flags” 中添加 -fsanitize-coverage=func,trace-pc-guard。如果含有 Swift 代码的话,还需要在 “Other Swift Flags” 中加入 -sanitize-coverage=func 和 -sanitize=undefined。所有链接到 App 中的二进制都需要开启 SanitizerCoverage,这样才能完全覆盖到所有调用。

示例代码,我这里以第一个页面的viewDidAppear为结束
#import <dlfcn.h>
#import <libkern/OSAtomic.h>
- (void)viewDidAppear:(BOOL)animated {
[super viewDidAppear:animated];
[self record];
}
- (void)record {
NSMutableArray <NSString *> * symbolNames = [NSMutableArray array];
while (YES) {
SYNode * node = OSAtomicDequeue(&symbolList, offsetof(SYNode, next));
if (node == NULL) {
break;
}
Dl_info info;
dladdr(node->pc, &info);
NSString * name = @(info.dli_sname);
//判断是否是oc方法
BOOL isObjc = [name hasPrefix:@"+["] || [name hasPrefix:@"-["];
//是oc方法就直接使用,不是还要加上一个_
NSString * symbolName = isObjc ? name: [@"_" stringByAppendingString:name];
[symbolNames addObject:symbolName];
}
//取反:因为是链表所以返
NSEnumerator * emt = [symbolNames reverseObjectEnumerator];
//去掉重复方法
NSMutableArray<NSString *> *funcs = [NSMutableArray arrayWithCapacity:symbolNames.count];
NSString * name;
while (name = [emt nextObject]) {
if (![funcs containsObject:name]) {
[funcs addObject:name];
}
}
//干掉自己![self record];
[funcs removeObject:[NSString stringWithFormat:@"%s",__FUNCTION__]];//
//将数组变成字符串
NSString * funcStr = [funcs componentsJoinedByString:@"\n"];
//打印,到这一步你就可以直接复制控制台上的打印到.order文件中了
NSLog(@"&&&&&&&&&&&&&&&&&&\n%@\n&&&&&&&&&&&&&&&&&&",funcStr);
//下面就是更加自动化:在沙盒目录直接生成一个fun.order文件
NSString * filePath = [NSTemporaryDirectory() stringByAppendingPathComponent:@"fun.order"];
NSData * fileContents = [funcStr dataUsingEncoding:NSUTF8StringEncoding];
[[NSFileManager defaultManager] createFileAtPath:filePath contents:fileContents attributes:nil];
}
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.
}
//原子队列
static OSQueueHead symbolList = OS_ATOMIC_QUEUE_INIT;
//定义符号结构体
typedef struct {
void *pc;
void *next;
}SYNode;
void __sanitizer_cov_trace_pc_guard(uint32_t *guard) {
// if (!*guard) return; // load 方法的guard是0 所以这里注销掉
/* 精确定位 哪里开始 到哪里结束! 在这里面做判断写条件!*/
void *PC = __builtin_return_address(0);
//使用链表形式储存
SYNode *node = malloc(sizeof(SYNode));
*node = (SYNode){PC,NULL};
//原子队列 进入
OSAtomicEnqueue(&symbolList, node, offsetof(SYNode, next));
// printf("fname:%s \nfbase:%p \nsname:%s \nsaddr:%p\n",
// info.dli_fname,
// info.dli_fbase,
// info.dli_sname,
// info.dli_saddr);
}
@end
下面是我自己项目的控制台部分打印,到这里就可以直接复制打印到.order文件中了,当然也可以更加自动化:在沙盒目录直接生成一个fun.order文件,这部分功能我已经在实例代码中展示过。
+[_AFURLSessionTaskSwizzling load]
+[_AFURLSessionTaskSwizzling swizzleResumeAndSuspendMethodForClass:]
_af_addMethod
+[IQKeyboardManager load]
+[UIViewController(FDFullscreenPopGesturePrivate) load]
___55+[UIViewController(FDFullscreenPopGesturePrivate) load]_block_invoke
+[UINavigationController(FDFullscreenPopGesture) load]
___54+[UINavigationController(FDFullscreenPopGesture) load]_block_invoke
+[UITableView(MJRefresh) load]
+[UIScrollView(MJExtension) initialize]
___39+[UIScrollView(MJExtension) initialize]_block_invoke
。。。
。。。
。。。
写在最后
运用上述的方法我自己的工程启速度大概升了17%。其实文章的重点是想说二进制重排的,因为这部分目前网上说的不清不楚。apple早就用了这个技术,在objc源码中就包含libobjc.order文件。想做好启动优化是需要大量的底层基础的比如Clang、Mach-o、ASLR...,一遍下来,你会受益匪浅。如果有更好的优化方法,烦请在评论中给出。如果文章对您有帮助,烦请点个赞,小弟在此谢过了!