APP启动流程
# 一、应用启动流程
1、整体过程
(1)解析Info.plist
- 加载相关信息,例如如闪屏
- 沙箱建立、权限检查
(2)Mach-O(可执行文件)加载
- 如果是胖二进制文件(为了保持向下兼容,且支持旧有设备及旧有指令集),寻找合适当前CPU类别的部分
- 加载所有依赖的Mach-O文件(递归调用Mach-O加载的方法)
- 定位内部、外部指针引用,例如字符串、函数等
- 加载类扩展(Category)中的方法
- C++静态对象加载、调用ObjC的 +load 函数
- 执行声明为__attribute__((constructor))的C函数
(3)程序执行
调用main()
调用UIApplicationMain()
调用applicationWillFinishLaunching
# Virtual Memory
虚拟内存是在物理内存上建立的一个逻辑地址空间,它向上(应用)提供了一个连续的逻辑地址空间,向下隐藏了物理内存的细节。 虚拟内存使得逻辑地址可以没有实际的物理地址,也可以让多个逻辑地址对应到一个物理地址。 虚拟内存被划分为一个个大小相同的Page(64位系统上是16KB),提高管理和读写的效率。 Page又分为只读和读写的Page。
# Page fault
在应用执行的时候,它被分配的逻辑地址空间都是可以访问的,当应用访问一个逻辑Page,而在对应的物理内存中并不存在的时候,这时候就发生了一次Page fault。当Page fault发生的时候,会中断当前的程序,在物理内存中寻找一个可用的Page,然后从磁盘中读取数据到物理内存,接着继续执行当前程序。
Dirty Page & Clean Page 如果一个Page可以从磁盘上重新生成,那么这个Page称为Clean Page 如果一个Page包含了进程相关信息,那么这个Page称为Dirty Page 像代码段这种只读的Page就是Clean Page。而像数据段(_DATA)这种读写的Page,当写数据发生的时候,会触发COW(Copy on write),也就是写时复制,Page会被标记成Dirty,同时会被复制。
2、主要阶段:
分为两个阶段,pre-main阶段和main()阶段。程序启动到main函数执行前是pre-main阶段;在执行main函数后,调用AppDelegate中的-application:didFinishLaunchingWithOptions:方法完成初始化,并展示首页,这是main()阶段,或者叫做main()之后阶段。
# (1)pre-main阶段:
- 加载应用的可执行文件。
- 加载动态链接库加载器dyld(dynamic loader)。
- dyld递归加载应用所有依赖的dylib(dynamic library 动态链接库)。
- 进行**
rebase指针调整和bind**符号绑定。 ObjC的runtime初始化(ObjC setup):ObjC相关Class的注册、category注册、selector唯一性检查等。- 初始化(Initializers):执行
+load()方法、用attribute((constructor))修饰的函数的调用、创建C++静态全局变量等。
# (2)main()阶段:
- dyld调用main()
- 调用UIApplicationMain()
- 调用applicationWillFinishLaunching
- 调用didFinishLaunchingWithOptions
二、获取启动流程的时间消耗
如何统计各阶段耗时:
Time Profiler
Xcode自带的工具,原理是定时抓取线程的堆栈信息,通过统计比较时间间隔之间的堆栈状态,计算一段时间内各个方法的近似耗时。精确度取决于设置的定时间隔。
通过 Xcode → Open Developer Tool → Instruments → Time Profiler 打开工具,注意,需将工程中 Debug Information Format 的 Debug 值改为 DWARF with dSYM File,否则只能看到一堆线程无法定位到函数。

通过双击具体函数可以跳转到对应代码处,另外可以将 Call Tree 的 Seperate by Thread 和 Hide System Libraries 勾选上,方便查看。

正常Time Profiler是每1ms采样一次, 默认只采集所有在运行线程的调用栈,最后以统计学的方式汇总。所以会无法统计到耗时过短的函数和休眠的线程,比如下图中的5次采样中,method3都没有采样到,所以最后聚合到的栈里就看不到method3。

我们可以将 File -> Recording Options 中的配置调高,即可获取更精确的调用栈。

System Trace
有时候当主线程被其他线程阻塞时,无法通过 Time Profiler 一眼看出,我们还可以使用 System Trace,例如我们故意在 dyld 链接动态库后的回调里休眠10ms:
tatic void add(const struct mach_header* header, intptr_t imp) {
usleep(10000);
}
- (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions
{
dispatch_sync(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
_dyld_register_func_for_add_image(add);
});
....
}
2
3
4
5
6
7
8
9
10

可以看到整个记录过程耗时7s,但 Time Profiler 上只显示了1.17s,且看到启动后有一段时间是空白的。这时通过 System Trace 查看各个线程的具体状态。
可以看到主线程有段时间被阻塞住了,存在一个互斥锁,切换到 Events:Thread State观察阻塞的下一条指令,发现0x5d39c 执行完成释放锁后,主线程才开始执行。

接着我们观察 0x5d39c 线程,发现在主线程阻塞的这段时间,该线程执行了多次10ms的 sleep 操作,到此就找到了主线程被子线程阻塞导致启动缓慢的原因。

今后,当我们想更清楚的看到各个线程之间的调度就可以使用 System Trace,但还是建议优先使用 Time Profiler,使用简单易懂,排查问题效率更高。
App Launch
Xcode11 之后新出的工具,功能相当于 Time Profiler 和 System Trace 的整合。
Hook objc_msgSend
可以对 objc_msgSend 进行 Hook 获取每个函数的具体耗时,优化在启动阶段耗时多的函数或将其置后调用。实现方法可查看笔者之前的文章 通过objc_msgSend实现iOS方法耗时监控 (opens new window)。
1、pre-main阶段
对于pre-main阶段,Apple提供了一种测量方法,在 Xcode 中 Edit scheme -> Run -> Auguments 将环境变量DYLD_PRINT_STATISTICS 设为1 。之后控制台会输出类似内容,我们可以清晰的看到每个耗时:

从上面可以看出时间区域主要分为下面几个部分:
dylib loading time
- 动态库载入过程,会去装载app使用的动态库,而每一个动态库有它自己的依赖关系,所以会消耗时间去查找和读取。
- dyld (the dynamic link editor)动态链接器,是一个专门用来加载动态链接库的库,它是开源的。在 xnu 内核为程序启动做好准备后,执行由内核态切换到用户态,由dyld完成后面的加载工作,dyld的主要是初始化运行环境,开启缓存策略,加载程序依赖的动态库(其中也包含我们的可执行文件),并对这些库进行链接(主要是rebaseing和binding),最后调用每个依赖库的初始化方法,在这一步,runtime被初始化。
rebase/binding time
ASLR(Address Space Layout Randomization),地址空间布局随机化。在ASLR技术出现之前,程序都是在固定的地址加载的,这样hacker可以知道程序里面某个函数的具体地址,植入某些恶意代码,修改函数的地址等,带来了很多的危险性。ASLR就是为了解决这个的,程序每次启动后地址都会随机变化,这样程序里所有的代码地址都需要需要重新对进行计算修复才能正常访问。rebasing这一步主要就是调整镜像内部指针的指向。
Binding:将指针指向镜像外部的内容。
ObjC setup time
- dyld调用的
objc_init方法,这个是runtime的初始化方法,在这个方法里面主要的操作就是加载类(对需要的class和category进行注册); - objc_init方法通过内部的_dyld_objc_notify_register向dyld注册了一个通知事件,当有新的image(程序中对应实例可简称为image,如程序可执行文件macho,Framework,bundle等)加载到内存的时候,就会触发
load_images方法,这个方法里面就是加载对应image里面的类,并调用load方法(在下一阶段initializer)。 - 如果有继承的类,那么会先调用父类的
load方法,然后调用子类的,但是在load里面不能调用[super load]。最后才是调用category的load方法。总之,所有的load都会被调用到(注意:子类的initialize方法会覆盖父类,不同于load方法)。
- dyld调用的
initializer time
承接上一过程进行初始化(load)。如果我们代码里面使用了clang的__attribute__((constructor))`构造方法,这里会调用到。
2、main()阶段
测量main()函数开始执行到didFinishLaunchingWithOptions执行结束的时间,简单的方法:直接插入代码。(也可以使用其他工具)
- main函数里

- 到主UI框架的.m文件用extern声明全局变量StartTime

- 在viewDidAppear函数里,再获取一下当前时间,与StartTime的差值即是main()阶段运行耗时。

三、改善APP的启动
建议应用的启动时间控制在400ms之下,并且在20s内启动,否则系统会kill app。优化APP的启动时间,需要就是分别优化pre-main和main的时间。
1、改善启动时pre-main阶段
(1)加载 Dylib
载入动态库,这个过程中,会去装载app使用的动态库,而每一个动态库有它自己的依赖关系,所以会消耗时间去查找和读取。对于Apple提供的的系统动态库,做了高度的优化。而对于开发者定义导入的动态库,则需要在花费更多的时间。Apple官方建议尽量少的使用自定义的动态库,或者考虑合并多个动态库,其中一个建议是当大于6个的时候,则需要考虑合并它们。
(2)Rebase/Binding
减少App的Objective-C类,分类和Selector的个数。这样做主要是为了加快程序的整个动态链接, 在进行动态库的重定位和绑定(Rebase/binding)过程中减少指针修正的使用,加快程序机器码的生成;
(3)Objc setup
大部分ObjC初始化工作已经在Rebase/Bind阶段做完了,这一步dyld会注册所有声明过的ObjC类,将分类插入到类的方法列表里,再检查每个selector的唯一性。
在这一步倒没什么优化可做的,Rebase/Bind阶段优化好了,这一步的耗时也会减少。
(4)Initializers
到了这一阶段,dyld开始运行程序的初始化函数,调用每个Objc类和分类的+load方法,调用C/C++ 中的构造器函数(用attribute((constructor))修饰的函数),和创建非基本类型的C++静态全局变量。Initializers阶段执行完后,dyld开始调用main()函数。
在这一步,我们可以做的优化有:
- 少在类的+load方法里做事情,尽量把这些事情推迟到+initiailize
- 减少构造器函数个数,在构造器函数里少做些事情
- 减少C++静态全局变量的个数
# 2、main()阶段的优化
(1)核心点:didFinishLaunchingWithOptions方法
这一阶段的优化主要是减少didFinishLaunchingWithOptions方法里的工作,在didFinishLaunchingWithOptions方法里我们经常会进行:
- 创建应用的window,指定其rootViewController,调用window的makeKeyAndVisible方法让其可见;
- 由于业务需要,我们会初始化各个三方库;
- 设置系统UI风格;
- 检查是否需要显示引导页、是否需要登录、是否有新版本等;
由于历史原因,这里的代码容易变得比较庞大,启动耗时难以控制。
(2)优化点:
满足业务需要的前提下,didFinishLaunchingWithOptions在主线程里做的事情越少越好。在这一步,我们可以做的优化有:
- 梳理各个二方/三方库,把可以延迟加载的库做延迟加载处理,比如放到首页控制器的viewDidAppear方法里。
- 梳理业务逻辑,把可以延迟执行的逻辑做延迟执行处理。比如检查新版本、注册推送通知等逻辑。
- 避免复杂/多余的计算。
- 避免在首页控制器的viewDidLoad和viewWillAppear做太多事情,这2个方法执行完,首页控制器才能显示,部分可以延迟创建的视图应做延迟创建/懒加载处理。
- 首页控制器用纯代码方式来构建。
# 四、+load与+initialize
# 1、+load
(1)+load方法是一定会在runtime中被调用的。只要类被添加到runtime中了,就会调用+load方法,即只要是在Compile Sources中出现的文件总是会被装载,与这个类是否被用到无关,因此+load方法总是在main函数之前调用。
(2)+load方法不会覆盖。也就是说,如果子类实现了+load方法,那么会先调用父类的+load方法(无需手动调用super),然后又去执行子类的+load方法。
(3)+load方法只会调用一次。
(4)+load方法执行顺序是:类 -> 子类 ->分类。而不同分类之间的执行顺序不一定,依据在Compile Sources中出现的顺序**(先编译,则先调用,列表中在下方的为“先”)**。
(5)+load方法是函数指针调用,即遍历类中的方法列表,直接根据函数地址调用。如果子类没有实现+load方法,子类也不会自动调用父类的+load方法。
# 2、+initialize
(1)+initialize方法是在类或它的子类收到第一条消息之前被调用的,这里所指的消息包括实例方法和类方法的调用。因此+initialize方法总是在main函数之后调用。
(2)+initialize方法只会调用一次。
(3)+initialize方法实际上是一种惰性调用,如果一个类一直没被用到,那它的+initialize方法也不会被调用,这一点有利于节约资源。
(4)+initialize方法会覆盖。如果子类实现了+initialize方法,就不会执行父类的了,直接执行子类本身的。如果分类实现了+initialize方法,也不会再执行主类的。
(5)+initialize方法的执行覆盖顺序是:分类 -> 子类 ->类。且只会有一个+initialize方法被执行。
(6)+initialize方法是发送消息(objc_msgSend()),如果子类没有实现+initialize方法,也会自动调用其父类的+initialize方法。
# 3、两者的异同
# (1)相同点
- load和initialize会被自动调用,不能手动调用它们。
- 子类实现了load和initialize的话,会隐式调用父类的load和initialize方法。
- load和initialize方法内部使用了锁,因此它们是线程安全的。
# (2)不同点
- 调用顺序不同,以main函数为分界,
+load方法在main函数之前执行,+initialize在main函数之后执行。 - 子类中没有实现
+load方法的话,子类不会调用父类的+load方法;而子类如果没有实现+initialize方法的话,也会自动调用父类的+initialize方法。 +load方法是在类被装在进来的时候就会调用,+initialize在第一次给某个类发送消息时调用(比如实例化一个对象),并且只会调用一次,是懒加载模式,如果这个类一直没有使用,就不回调用到+initialize方法。
# 4、使用场景
(1)+load一般是用来交换方法Method Swizzle,由于它是线程安全的,而且一定会调用且只会调用一次,通常在使用UrlRouter的时候注册类的时候也在+load方法中注册。
(2)+initialize方法主要用来对一些不方便在编译期初始化的对象进行赋值,或者说对一些静态常量进行初始化操作。