应用的启动过程

文章整理自 头条技术DevilH 等的博客,具体请看最后的参考链接。

启动过程分解

应用启动的过程可分为两步:

t(总)= t1main函数之前) + t2main函数之后)

t1:加载系统动态库和应用可执行文件;

t2:构建界面并完成渲染和展示:main函数-application:didFinishLaunchingWithOptions:回调执行结束;

1、main 函数之前

main函数之前启动过程的简单总结:

  1. 系统内核XNU先读取应用的可执行文件,加载动态链接器dyld
  2. 动态链接器初始化运行环境,加载程序相关依赖库,并对这些库进行链接;
  3. 接着调用每个依赖库的初始化方法(runtime在这一步被初始化);
  4. 初始化程序可执行文件。这时runtime会初始化所有类的类结构,调用所有的+load方法;
  5. 最后dyld返回main函数地址,main函数被调用,来到我们的程序入口。

1.1.加载可执行文件

大致的过程是:

  • 内核启动进程管理器,为我们的应用创建新的进程;
  • 调用load_machfile()函数加载Mach-O,这里会设置虚拟内存大小、设置ASLR随机数;load_machfile()内部继续调用parse_machfile()函数对Mach-O文件进行深度解析;
  • parse_machfile先将Mach-O文件的所有的加载命令Load Commends映射进内核的内存,再分三趟解析这些加载命令。应用的可执行文件和dyld都是Mach-O文件,parse_machfile()在解析可执行文件时会继续调用load_dylinker()来处理加载命令LC_LOAD_DYLINKER
  • load_dylinker内递归调用parse_machfile解析dyld,成功后dyld开始加载共享库;
  • 解析完可执行二进制文件类型的Mach-O文件后(假设为A),会得到A的入口点;但线程并不会立刻进入到这个入口点。因为还要加载动态链接器dyld,在load_dylinker()中,dyld会保存A的入口点,递归调用parse_machfile()后,将线程入口点设为dyld的入口点。dyld完成加载库的工作之后,再将入口点设回A的入口点,程序启动完成。

1.2.dyld的工作流程

dyld 加载成功后会接管后续的启动任务:

  1. Load dylibs image;
  2. Rebase image;
  3. Bind image;
  4. ObjC Setup;
  5. Initializers;
i.Load dylibs image

dyld将可执行文件以及依赖的库递归地加载进内存,生成对应的镜像对象:

  1. 分析所依赖的动态库;
  2. 找到动态库的mach-o文件;
  3. 打开文件;
  4. 验证文件;
  5. 在系统核心注册文件签名;
  6. 对动态库的每一个segment调用mmap();

针对这一步骤的优化有:

  1. 减少非系统库的依赖;
  2. 合并非系统库;
  3. 使用静态资源,比如把代码加入主程序;
ii.链接镜像

对上面生成的镜像进行链接,主要是对镜像进行rebase/bind

由于ASLR的存在,每次启动时,可执行文件和动态链接库在虚拟内存中的加载地址都不固定,所以需要这2步来修复镜像中资源的指针。

  • rebase 修复的是指向当前镜像内部的资源指针;
  • bind 修复的是指向当前镜像外部的资源指针;

rebase步骤先进行,需要把镜像读入内存,并以page为单位进行加密验证,保证不会被篡改,所以这一步的瓶颈在IO。bind在其后进行,由于要查询符号表,来指向跨镜像的资源,加上在rebase阶段,镜像已被读入和加密验证,所以这一步的瓶颈在于CPU计算。

优化该阶段的关键在于减少__DATA segment中的指针数量:

  1. 减少 Objc 类数量, 减少 selector 数量;
  2. 减少 C++ 虚函数数量;
  3. 使用 Swift stuct,减少符号的数量;
iii.ObjC Setup

主要是调用各镜像的初始化方法:

  1. 注册Objc类 (class registration);
  2. 把category的定义插入方法列表 (category registration);
  3. 保证每一个selector唯一 (selctor uniquing);
iv.initializers

以上三步属于静态调整(fix-up),都是在修改__DATA segment中的内容,而这里则开始动态调整,开始在堆和堆栈中写入内容:

  1. Objc的+load()函数;
  2. C++的构造函数属性函数,形如 attribute((constructor)) void DoSomeInitializationWork();
  3. 非基本类型的C++静态全局变量的创建(通常是类或结构体)(non-trivial initializer) ,比如一个全局静态结构体的构建,如果在构造函数中有繁重的工作,那么会拖慢启动速度;

至此,可执行文件和动态库中的符号(Class,Protocol,Selector,IMP,…)都已经按格式成功加载到内存中,由 runtime 管理。这之后 runtime 的那些方法(动态添加Class、swizzle等)才能生效。

整个事件由dyld主导,完成运行环境的初始化后,将二进制文件按格式加载到内存,动态链接依赖库,并由 runtime 加载成 objc 定义的结构,所有初始化工作结束后,dyld调用真正的main函数。


冷启动与热启动:

  • 热启动:如果程序刚刚被运行过,则程序代码会被 dyld 缓存,即使杀掉进程再次重启,加载时间也会相对快一点;
  • 冷启动:如果长时间没有启动或者当前 dyld 的缓存已经被其他应用占据,那么这次启动所花费的时间就要长一点;

怎么衡量main()之前的耗时呢?苹果官方提供了一种方法:在Xcode中,设置环境变量DYLD_PRINT_STATISTICSDYLD_PRINT_STATISTICS_DETAILS后运行来查看耗时。

1
Edit Schemes->Run->Arguments->Environment Variables->新增上述变量之一,value=1

设置后再次启动APP即可在控制台看到日志:

1
2
3
4
5
6
7
 Total pre-main time: 1.3 seconds (100.0%)
dylib loading time: 160.66 milliseconds (11.5%)
rebase/binding time: 1.1 seconds (81.2%)
ObjC setup time: 52.53 milliseconds (3.7%)
initializer time: 47.49 milliseconds (3.4%)
slowest intializers :
libSystem.dylib : 3.80 milliseconds (0.2%)

2、main 函数之后

1、执行main函数;

2、执行UIApplicationMain函数:

1
2
创建UIApplication,启动主Runloop;
创建AppDelegate对象,开始处理系统事件;

3、检查Info.plist设置、创建显示主窗口:

1
2
3
4
加载SB;
创建Keywindow;
创建rootViewController;
显示主窗口;

优化启动时间:

main()之前可优化点:

  1. 减少不必要的 framework;
  2. 合并或者删减一些OC类;
  3. 将不必须在+load方法中做的事情延迟到+initialize中;
  4. 删减一些无用的静态变量;
  5. 删减没有被调用到或者已经废弃的方法;
  6. 尽量不要用C++虚函数(创建虚函数表有开销)。

main()及之后可优化点:

  1. 不使用xib,直接视用代码加载首页视图;
  2. release版本不要使用 NSLog 打印日志;
  3. NSUserDefaults中保存的内容不宜过多。
  4. 梳理应用启动时发送的所有网络请求,统一在异步线程请求;

相关参考:

#©头条技术博客-今日头条iOS客户端启动速度优化

#©DevilH-iOS程序启动

#©MissionPeak-XNU、dyld源码分析Mach-O和动态库的加载过程(上)

#©Jamin’s blog-由App的启动说起

#©Ole Begemann-The App Launch Sequence on iOS

#©alvin_wang-iOS编译与app启动


应用的启动过程
https://davidlii.cn/2019/02/25/launch.html
作者
Davidli
发布于
2019年2月25日
许可协议