前言
启动分为3种类型,分别为 Cold 冷启动,Warm 热启动,Resume 重新回到活跃状态
这三种启动,对应的时机以及资源在内存中状态,如下图
启动流程
从用户点击图标到第一帧的展示,整个流程的App lifeCyle是怎么样的?如下图。
PS: 如果你用过Instuments的App launch工具来观察整个启动的生命周期话,会发现有一个差异。 System Interface之前还有一个阶段,这个阶段实际只是消耗了5ms,其它的几百毫秒的消耗, 都是app launch工具本身为了追踪数据而产生的额外耗时,可以不用理会。
流程可以按2个部分来看,系统的初始化和App的初始化。 也就是常说的pre-main和after main
Pre-main
分为2个阶段
- System Interface
- Runtime Init
System Interface
- dyld初始化
- dyld加载动态库
- rebase/binding 符号的重定向与符号绑定
Runtime Init
- runtime的初始化 (catoray加载)
- 所有类的load方法执行
After-main
分为3个阶段
- UIKit Init
- Application Init
- Inital Frame Render
UIKit Init
- 实例化Application
- 系统事件的处理,包括runloop
Application Init
- Application的初始化
- 调用application:willFinishLaunchingWithOptions: application:didFinishLaunchingWithOptions:
Inital Frame Render
- 布局计算
- 第一帧渲染
以上就是整个启动流程所做的事情,这些阶段我们能够控制的其实不多。
在pre-main这个阶段
我们能够影响的只有动静态库的数量以及load方法的执行时间。
动静态库
区别
产物层面
| 库的类型 | 格式 | 大小 | Framework格式 |
|---|---|---|---|
| 静态库 | .a .framework | 大(多个mach-o) | Headers + .a + 签名 + 资源文件 |
| 动态库 | .framework | 小(只有一个mach-o) | Headers + .dylib + 签名 + 资源文件 |
编译进宿主App层面
| 库的类型 | 编译进App,对App大小影响的比较 | 链接时机 |
|---|---|---|
| 静态库 | 小 1.过滤掉了没有引用的头文件的代码 2.编译阶段已经链接进App中,所以会少一些链接过程需要的数据 | 编译阶段 |
| 动态库 | 大 | 运行阶段 |
目标文件是如何确定调用方法的地址的
正常一个文件中调用另外一个文件的方法,比如main.c中调用Dog.c 中的run() 函数,那么会在main.o文件中生成一个叫做run的(undefine symbol)未定义的符号,等到链接的时候再确定这个符号的地址
而OC由于动态语言的特性,比如main.m 调用 Dog.m中的方法时,[[Dog alloc]init],main.o文件只会生成Dog(undefine symbol),而不会生成 alloc 和 init的 未定义符号表。原因是OC方法最终实现时在运行时才确定的。
上述的动静态库的这些知识,其实已经说明了为什么动静态库的数量为什么会影响启动时间
-
首先dyld加载动态库的时间,如果动态库数量太多,显然加载时间就比较长
-
再者就是动态库太多,那么rebinding(动态绑定)的时间也会增加
-
改为静态库的话,对应的rebase时间就会增加。
- 由于ALSR的存在,需要修正内部的符号地址
- 静态库文件越多,需要内部修正的符号也越多
-
总而言之,rebase和rebinding是一对时间上互斥的操作
-
但是Dyld load + rebinding 的耗时会大于rebase,所以减少动态库才会对启动时间优化有效
在main之后这个阶段
尽量将耗时操作放到子线程中处理,并且减少Page Fault。
二进制重排
计算机是如何运行程序的?
最开始的计算机,是直接使用物理内存,并且是一次性把程序的数据加载到内存中。
在那个内存容量还不大的年代,这样的做的话,会导致要打开一个新的应用,需要先销毁掉原来的应用。内存的利用率比较低。
于是乎,后来将应用程序进行了分页处理。每次运行到需要的程序数据时,再将数据加载到内存中来。这样解决了内存利用率低的问题。
随着发展,又出现了一个问题,就是直接使用物理内存,很容易就修改到其它程序的内存地址。
于是迎来了虚拟内存的时代,系统给每个程序都开辟了相同的大小的虚拟内存,虚拟内存的地址不和物理内存地址直接对应,由系统决定映射关系。这样一来就隔绝了直接访问的问题。
但是又出现了一个问题,系统分配给每个应用的虚拟内存地址,每次都是相同的(可以见到理解为从0开始到最大的虚拟地址)。这样会导致每个应该很容易就被hook掉。所以就出现了随机偏移地址,在程序每次启动的时候会随意一个地址加到虚拟内存地址前,这样每次的内存地址就都是随机的。
这个就是ASLR,这也是为什么每次启动都需要rebase的原因。
Page Fault(分页异常中断)
由于要提高内存的利用率,所以将程序的数据分页处理,每次要从硬盘中找到对应二进制数据再加载到内存中,会有一个异常,也就是所说的Page Fault。当然一个Page Fault的阻塞时间都是在几毫秒间,正常情况下根本感知不到这个阻塞。但是,如果在启动的时候,有几百成千个Page Fault,那么这个时间就会被感知。这也就是为什么解决Page Fault会提高启动速度的原因了。
如何减少启动阶段Page Fault?
单个或少量的Page Fault,在用户感知层面几乎是无感知的。只有在启动的时候,程序会去初始化很多数据的时候,才会产生大量的Page Fault,因此只需要将启动阶段调用到方法的二进制数据放在一起,就可以减少查找和加载的时间。
iPhone 6s 之前,一个分页是4KB,iPhone 6s 之后,一个分页是16KB
Clane静态插桩
知道了如何减少Page Fault,那么现在该如何知道启动阶段调用了哪些方法呢?
刚好Clane和Swiftc 这2个前端编译器都已经是支持我们获取调用的任意函数。
如果想深入研究一下编译到底是如何支持获取调用任意函数的话,可以通过汇编去追踪一下。这里我说结论,就是编译阶段,编译器为我们的函数插入了这2个方法(这个代码可以在 Clane 的官网找到)
void __sanitizer_cov_trace_pc_guard_init(uint32_t *start,
uint32_t *stop)
void __sanitizer_cov_trace_pc_guard(uint32_t *guard)
既然要获取到调用了哪些方法,那么就需要有一个截止保存时间。这个要根据业务的不同调整,可以在applicationDidFinishLaunching 后截止,当然也可以在第一帧展示的时候。
最后获得最终调用的方法的字符串列表文件,然后通过配置,让编译器根据这份文件重新排列一下二进制文件的顺序,从而达到重排的效果
优化结果
动态库
改静态库
以优化的结果的数据来看
- 大部分动态库改为静态库后,启动时间确实有了200-300毫秒的提升。
- 二进制重排并没有网上说的效果,甚至说我根本没法去验证有没有提升。
PS:以上验证优化的步骤,都是重启手机后,没有任何进程,进行的测试。毕竟像苹果在wwdc大会上说的,🍎要等于🍎
分析了一下二进制重排效果不明显的原因
- 可能现在编译器已经进行了优化
- 项目本身在main到didFinishLaunching这期间,初始化的数据不算多。毕竟能延后执行的,都延后执行了。
二进制重排的流程就不累赘了。至于动态库改静态库,还是有一些骚操作的。后续单独再讲讲。