探究App的启动过程,有助于我们优化App的启动时间,从main函数之前和main函数之后两个阶段进行分析一下。
1. 分析前的准备
1.1 dyld的介绍
dyld的全称是dynamic loader,它的作用是加载一个进程所需要的image(映像),dyld是苹果的动态链接器,动态链接库的加载过程主要由dyld来完成。
- 系统先加载解析App的可执行文件(Mach-O文件),从里面获取dyld的路径
- 然后加载dyld,dyld去初始化运行环境,开启缓存策略,加载程序相关依赖库(其中也包含我们的可执行文件),并对这些库进行链接
- 最后调用每个依赖库的初始化方法(在这一步,runtime被初始化)
- 当所有依赖库初始化完成后,轮到最后一位(程序可执行文件)进行初始化,在这时runtime会对项目中所有类进行类结构初始化,然后调用所有的load方法。
- 最后dyld返回main函数地址,main函数被调用,我们便来到了熟悉的程序入口
1.2 dyld共享库缓存
当你构建一个真正的程序时,将会链接各种各样的库。他们又会依赖一些framework和动态库,因此需要加载的动态库会非常多,而对于相互依赖的符号就更多了,可能会有上千个符号需要解析处理,这样将耗费很长的时间。
为了缩短这个处理过程所花费时间,OS X和iOS上的动态链接器使用了共享缓存。
对于每一种架构,操作系统都有一个单独的文件,文件中包含了绝大多数的动态库,这些库都已经链接为一个文件,并且已经处理好了他们之间的符号关系。当加载一个Mach-O文件(一个可执行文件或者一个库)时,动态链接器首先会检查共享缓存看看是否已经存在,如果存在那么就直接从共享缓存中拿出来使用。每一个进程都把这个共享缓存映射到了自己的地址空间中。这个方法大大优化了OS X和iOS上程序的启动时间。比如手机开机后,连续两次启动同一个APP的pre-main实际时间的差值比较大, 因为第一次启动的时候,会把App使用到的系统动态库加入缓存,第二次直接从缓存中取就行了。
1.3 ASLR(Address Space Layout Randomization)
地址空间布局随机化,镜像会在随机的地址上加载。
传统方式下,进程每次启动采用的都是固定可预见的方法,这意味着一个给定的程序在给定的架构上的进程初始虚拟内存都是基本一致的,而且在进程正常运行的生命周期中,内存中的地址分布具有非常强的可预测性,这给了黑客很大的施展空间(代码注入,重写内存)
如果采用ASLR,进程每次启动,地址空间都会被简单的随机化,但是只是偏移,不是搅乱。大体布局、程序文本、数据和库是一样的,但是具体的地址都不同了,可以阻挡黑客对地址的猜测。
1.4 代码签名
可能我们认为Xcode会把整个文件都做加密hash并用做数字签名。其实为了在运行时验证Mach-O文件的签名,并不是每次重复读入整个文件,而是把每页内容都生成一个单独的加密散列值,存储在__LINKEDIT中,这使得文件每页的内容都能及时被校验并确保不被篡改。
1.5 虚拟内存virtual memory
虚拟内存是在物理内存上建立的一个逻辑地址空间,它向上(应用)提供了一个连续的逻辑地址空间,向下隐藏了物理内存的细节。
虚拟内存使得逻辑地址可以没有实际的物理地址,也可以让多个逻辑地址对应到一个物理地址。
虚拟内存被划分为一个个大小相同的page(64位系统上是16KB),提高管理和读写的效率,Page又分为只读和读写的Page。
虚拟内存是建立在物理内存和进程之间的中间层。在iOS上,当内存不足时,会尝试释放那些只读的Page,因为只读的Page在下次被访问的时候,可以再从磁盘读取,如果没有可用内存,会通知在后台的App(也就是在这个时候收到了memory warning),如果在这之后仍然没有可用内存,则会杀死在后台的APP。
1.6 Page fault
在应用执行的时候,它被分配的逻辑地址空间都是可以访问的,当应用访问一个逻辑Page,而在对应的物理内存中并不存在的时候,这时候就发生了一次Page fault,当Page fault发生的时候,会中断当前的程序,在物理内存中寻找一个可用的Page,然后从磁盘中读取数据到物理内存,接着继续执行当前程序。
1.7 Dirty Page & Clean Page
- 如果一个Page可以从磁盘上重新生成,那么这个Page称为Clear Page
- 如果一个Page包含了进程相关信息,那么这个Page称为Dirty Page
像代码段这种只读的Page就是Clean Page,而数据段(_DATA)这种读写的Page,当写数据发生的时候,会触发CO(copy on write),也就是写时复制,Page会被标记成Dirty,同时会被复制。
2. App启动过程
-
解析Info.plist
加载相关信息,例如闪屏
沙箱建立、权限检查 -
Mach-O加载 (Mach-O这里不再介绍)
如果是胖二进制文件,寻找适合当前CPU架构的部分
加载所有依赖的Mach-O文件(递归调用Mach-O加载的方法)
定位内部、外部指针引用,例如字符串、函数等
加载类扩展(Category)中的方法
C++静态对象加载、调用ObjC的 +load 函数
执行声明为attribute((constructor))的C函数 -
程序执行
调用main()
调用UIApplicationMain()
调用applicationWillFinishLaunching
从上面的顺序中也可以看出来,attribute((constructor))的函数调用会在+load函数之后调用。
换成另一个说法就是:
App开始启动后,系统首先加载可执行文件(自身App的所有.o文件的集合),然后加载动态链接器dyld(用于加载动态链接库的库)。dyld从当前可执行文件的依赖开始,递归加载所有依赖的动态链接库。
动态链接库包括:iOS中用到的所有系统framework,加载OC runtime方法的libobjc,系统级别的libSystem,例如libdispatch(GCD)和libsystem_blocks(Block)
启动过程图
使用dyld2启动应用的过程如图:
大致的过程如下:
1.加载dyld到App进程
2.加载动态库(包括所依赖的所有动态库)
3.Rebase
4.Bind
5.初始化Objective-C Runtime
6.其它的初始化代码
2.1 可执行文件的内核流程
如图,当启动一个应用程序时,系统最后会根据你的行为调用两个函数,fork和execve:
fork功能创建一个进程;
execve功能加载和运行程序
这里有多个不同的功能,比如execl、execv、exect,每个功能提供了不同传参和环境变量的方法到程序中。在OSX中,每个这些其他的exec路径最终调用了内核路径execve。
执行exec系统调用,一般都是这样,用fork()函数新建一个进程,然后让进程去执行exec调用。我们知道,在fork()建立新进程之后,父进程与子进程共享代码段(TEXT),但数据空间(DATA)是分开的,但父进程会把自己数据空间的内容copy到子进程中去,还有上下文也会copy到子进程中去。
为了提高效率,采用一种写时copy的策略,即创建子进程的时候,并不copy父进程的地址空间,父子进程拥有共同的地址空间,只有当子进程需要写入数据(如向缓冲区写入数据),这时候会复制地址空间,复制缓冲区到子进程中去。从而父子进程拥有独立的地址空间。而对于fork()之后执行exec之前,这种策略能够很好的提高效率,如果一开始就copy,那么exec之后,子进程(可以说父进程也可以说是子进程,因为两个进程的数据此时是一样的)的数据会被放弃,被新的进程所代替。
2.2 App启动流程的节点
iOS应用的启动可分为per-main阶段和main两个阶段,所以App总启动时间 = pre-main耗时 + main耗时
阶段 | pre-main | main |
---|---|---|
流程 | 系统dylib(动态链接库)和自身App可执行文件的加载 | main方法执行之后到AppDelegate类中的didFinishLaunchingWithOptions 方法执行结束前这段时间,主要是构建第一个界面,并完成渲染展示 |
-
pre-main
-
main
2.3 dyld加载过程
dyld加载过程主要包含以下几个步骤:
2.3.1 Load dylibs image 加载动态库
dyld会首先读取Mach-O文件的Header和Load Commands,接着就知道了这个可执行文件依赖的动态库。例如加载动态库A到内存,接着检查A所依赖的动态库,就这样的递归加载,直到所有的动态库加载完毕。通常一个App所依赖的动态库在100-400个左右,其中大多数都是系统的动态库,它们会被缓存到dyld shared cache(动态共享库缓存)。
所以应用所依赖的dylib文件,可能会再依赖其他dylib,所以dyld所需要加载的是动态库列表一个递归依赖的集合。
针对这一步骤的优化有:
1. 减少非系统库的依赖
2. 合并非系统库
2.3.2 Rebase/Bind image
为什么要有这一步呢?
我们知道有两种主要的技术来保证应用的安全:ASLR和Code Sign,这里再次介绍一下:
- ASLR
地址空间布局随机化,App被启动的时候,程序会被影射到逻辑的地址空间,这个逻辑的地址空间有一个起始地址,而ASLR技术使得这个起始地址是随机的。如果是固定的,那么黑客很容易就可以由起始地址+偏移量找到函数的地址。- 在进行Code sign的时候,加密哈希不是针对于整个文件,而是针对于每一个Page的。这就保证了在dyld进行加载的时候,可以对每一个page进行独立的验证。
Mach-O中有很多符号,有指向当前Mach-O的,也有指向其他dylib的,比如printf。那么,运行时,代码如何找到printf的地址呢?
Mach-O中采用了PIC技术,全称是Position Independ Code。当你的程序要调用printf的时候,会先在__DATA中建立一个指针指向printf,再通过这个指针实现间接调用。dyld这时候就需要做一些fix-up工作,帮助应用程序找到这些符号的实际地址。主要包括两部分:
Rebase 修正内部(指向当前Mach-O文件)的指针指向
Bind 修正外部指针指向
Rebase步骤先进行,需要把镜像读入内存,并以page为单位进行加密验证,保证不会被篡改,所以这一步的瓶颈在IO。之所以要Rebase,是因为刚提到的ASLR使得地址随机化,导致起始地址不固定,另外由于Code Sign,导致不能直接修改Image。Rebase的时候,只需要增加对应的偏移量即可,待Rebase的数据都放在__LINKEDIT中。
Rebase解决了内部的符号引用问题,Bind在其后进行解决外部的符号引用,由于要查询符号表,来指向跨镜像的资源,加上在Rebase阶段,镜像已被读入和加密验证,所以这一步在于CPU计算。
优化该阶段的关键在于减少_DATA segment中的指针数量。我们可以优化的点有:
1. 减少Objc类数量,减少selector数量
2. 减少C++虚函数数量
2.3.3 Objc setup
Objc setup主要是在objc_init完成的,objc_init是在libsystem中的一个initialize方法libsystem_initializer中初始化了libdispatch,然后libdispatch_init调用了_os_object_int,最终调用了_objc_init。
void _objc_init(void)
{
static bool initialized = false;
if (initialized) return;
initialized = true;
environ_init();
tls_init();
static_init();
lock_init();
exception_init();
_dyld_objc_notify_register(&map_2_images, load_images, unmap_image);
}
runtime在_objc_init向dyld绑定了3个回调函数,分别是map_2_images, load_images, unmap_image
- dyld在binding操作结束之后,会发出dyld_image_state_bound通知,然后与之绑定的回调函数map_2_images就会被调用,它主要做以下几件事来完成Objc Setup:
- 读取二进制文件的DATA段内容,找到与objc相关的信息
- 注册Objc类
- 确保selector的唯一性
- 读取protocol以及category的信息
- load_images函数作用就是调用Objc的load方法,它监听dyld_image_state_dependents_initialize通知
- upmap_image可以理解为map_2_images的逆向操作
2.3.4 initializers
以上三步属于静态调整,都是在修改__DATA segment中的内容,而这里开始动态调整,开始在堆和栈中写入内容。
主要工作是:
- objc的+load()函数
- C++的构造函数属性函数 形如
attribute((constructor)) void DoSomeInitializationWork()
- 非基本类型的C++静态全局变量的创建(通常是类或结构体)比如一个全局静态结构体的创建,如果在构造函数中有繁重的工作,那么会拖慢启动速度
Objc的load函数和C++的静态构造器采用由底向上的方式执行,来保证每个执行的方法,都可以找到所依赖的动态库:
- dyld开始将程序二进制文件初始化
- 交由ImageLoader读取image,其中包含了我们的类、方法等各种符号
- 由于runtime向dyld绑定了回调,当image加载到内存后,dyld会通知runtime进行处理
- runtime接手后调用mapimages做解析和处理,接下来loadimages中调用callloadmethods方法,遍历所有加载进来的Class,按继承层级依次调用Class的+load方法和其Category的+load方法
整个事件由dyld主导,完成运行环境的初始化后,配合ImageLoader将二进制文件按格式加载到内存,动态链接依赖库,并由runtime负责加载成objc定义的结构,所有初始化工作结束后,dyld调用真正的main函数
2.4 dyld3
上文的讲解是dyld2的加载方式。而最新的是dyld3加载方式略有不同:
dyld2是纯粹的in-process,也就是在程序进程内执行的,也就意味着只有当应用程序被启动的时候,dyld2才能开始执行任务。
dyld3则是部分out-of-process,部分in-process。图中,虚线之上的部分是out-of-process的,在App下载安装和版本更新的时候回去执行,out-of-process会做如下事情:
- 分析Mach-o Headers
- 分析依赖的动态库
- 查找需要Rebase & Bind之类的符号
- 把上述结果写入缓存
这样,在应用启动的时候,就可以直接从缓存中读取数据,加快加载速度。
3. 优化步骤
3.1 pre-main阶段优化
- 删除无用代码(未被调用的静态变量、类和方法)
- 抽象重复代码
2.1 可能一个类有不同的分类,导致方法重复,这样会增加App体积,增加启动时间
2.2 抽离相同功能的代码
- +load方法中做的事情延迟到+initialize中,或者在+load中做的事情不宜花费过多的时间
load是在启动的时候调用,而initalize是在类首次被使用的时候调用,不过当你把load中的逻辑移到initialize中时候,一定要注意initialize的重复调用问题
- 减少不必要的framework,优化已有的framework
3.2 main阶段优化
这一阶段的时间主要是指:main函数开始到第一个界面渲染完成这段时间,优化出发点就是减少从main函数开始到第一个界面出现的时间,可以从两方面入手:
- didFinishLaunchingWithOptions
一般情况下,app在didFinishLaunchingWithOptions这个函数中会做以下工作:
日志、统计
配置APP运行环境
第三方SDK继承 ...
如果这个工作里面有的功能可能是不必要的,有的可以采用懒加载的方法,那么可以进行优化。
- 首次启动渲染的页面优化
- 不使用xib或者storyboard,直接使用代码
- 对于viewDidLoad以及viewWillAppear方法中尽量不做,少做,晚做,或者采用异步的方式
- 当首页逻辑比较复杂的时候,建议通过instruments的Time Profiler分析耗时瓶颈
- 写代码注意
- 版本迭代过程中,如果业务变化,导致代码变化,一般情况下需要把旧代码&旧资源删了
- 尽量抽象重复的代码,重构代码,采用合理的设计模式
- 在写启动相关业务模块时注意延迟加载或者懒加载
- 类和方法名不要太长:iOS每个类和方法名都在__cstring段里都存了相应的字符串值,所以类和方法名的长短,对于可执行文件大小是有影响的,原因还是OC的动态特性,因为需要通过类/方法名反射找到这个类/方法进行调用,OC对象模型会把类/方法名字符串都保存下来。
先大概了解一下,以便以后随时翻阅。