本篇主要是对应用启动时间优化的梳理。

启动过程的技术调研

App 总启动时间 t 分为两部分:

  • main() 之前的加载时间 t1
  • main() 之后的加载时间 t2

即 t = t1 + t2。

其中 t1 = 系统 dylib(动态链接库)加载时间 + App 可执行文件加载时间;t2 = 从 main() 方法执行到 AppDelegate 类中 didFinishLaunchingWithOptions: 方法执行结束前的时间。

依次看下 t1、t2 都做了什么。

main() 调用之前的加载

App 启动后,系统会先加载 App 中所有的可执行文件(.o 文件集合);然后加载动态链接库 dyld(dyld 是专门用来加载动态链接库的)。

dyld 从可执行文件中递归所有依赖的动态链接库。动态链接库有:

  • iOS 中所有系统 framework
  • libobjc(用于加载 OC runtime 方法)
  • libSystem(如 GCD 的 libdispatch、Block 的 libsystem_blocks)

系统链接库和 App 本身的可执行文件,都是 image,每个 App 是以 image 为单位进行加载的。

image

image 有:

  • 可执行文件(.o 文件)
  • dylib 动态链接库(动态链接库+相应资源包,如 UIKit、Foundation 等)

关于动态链接

动态链接的好处:

  • 代码公用。很多程序动态链接这些 lib,但内存和磁盘中只有一份。
  • 易于维护。因被依赖的 lib 在程序运行时才链接,所以这些 lib 可以很容易被更新。
  • 减少了可执行文件的体积。相比静态链接,动态链接不需要在编译时打包到包内,可执行文件小了很多。

所有动态链接库 framework、静态库 .a、所有类编译后的 .o文件,最终都是通过 dyld(动态链接器)加载到内存中。每个 image 都由一个 ImageLoader 的类来负责加载。

ImageLoader

image 表示二进制文件,ImageLoader 的作用是将这些文件加载到内存,且一一对应,每个 ImageLoader 对应加载一个文件。

在程序运行时,先将动态链接的 image 递归加载,再从可执行文件 image 递归加载所有符号。

动态链接库加载的具体流程

加载主要分为 5 步:

  • load dylibs image
  • rebase image
  • bind image
  • objc setup
  • initializers
  1. load dylibs image

在加载每个动态库时,dyld 需要:

  • 分析依赖的动态库
  • 找到动态库的 mach-o 文件
  • 打开文件
  • 验证文件
  • 在系统核心注册文件签名
  • 对动态库的每个 segment 调用 mmap()

系统库由于被优化,加载会很快,这里加载可做的优化有:

  • 减少非系统库的依赖
  • 合并非系统库
  • 部分库,通过拷贝代码的方式引入
  1. rebase/bind image

由于 ASLR(address space layout randomization) 的存在,可执行文件和动态链接库在虚拟内存中的加载地址每次启动都不固定,需要先修复 image 的指针,再指向正确地址。

rebase 修复指向当前 image 内部的资源指针;bind 指向 image 外部的资源指针。

rebase 的步骤:

  • 将镜像读入内存
  • 以 page 为单位进行加密验证,保证不会被篡改

rebase 之后再进行 bind,bind 步骤:

  • 查询符号表,指向跨 image 的资源

该阶段可优化的点:

  • 减少 objc 类的数量,减少 selector 数量
  • swift 多使用 struct,以减少符号数量
  1. objc setup

这一步:

  • 注册 objc 类
  • 把 category 的定义插入方法列表
  • 保证每个 selector 唯一

如果前面减少了依赖和减少了 objc 类数量及 selector 数量,则这一步不在需要额外优化。

  1. initializers

前面三步都是在修改 _DATA segment,这一步开始在堆和栈中写入内容。具体有:

  • objc +load
  • 其他构造函数(如 c++)

具体顺序:

  • dyld 开始将程序二进制文件初始化
  • 交由 ImageLoader 读取 image,包含了类、方法及各种符号
  • 由于 runtime 向 dyld 绑定了回调。当 image 加载到内存后,dyld 会通知 runtime 去处理
  • runtime 收到通知后,调用 mapImages 做析构和处理。接着 loadImages 中调用 callloadmethods 方法。遍历所有加载进来的 class,按继承层级依次调用 class 的 +load 方法及其 category 的 +load 方法

到这里,可执行文件和动态库的所有内容(class、selector、IMP等)都已按格式加载到内存,被 runtime 所管理。接着,runtime 的一些黑科技,如 swizzle 才可以生效。

在初始化完成后,dyld 调用 main 函数。如果 App 是第一次被运行,App 的代码会被 dyld 缓存,因此杀掉进程再次打开 App 时,会发现还是很快。但如果该 App 长时间未启动或当前 dyld 的缓存被其他 App 占用,则需要再进行前面的链接加载过程,时间会长些。这也是冷启动和热启动的概念。

t1 的时间如何得到?

真机调试时,Scheme 中 Run 开启 DYLD_PRINT_STATISTICS,会得到类似如下输入(截图来自官方 session):

根据图可以看到主要耗时在 image 加载和 oc 类的初始化。

所以针对 main() 调用之前的加载可以优化的点有:

  • 减少不必要的 framework,以减少动态链接的耗时
  • 合并或删减一些 oc 类。可以通过 AppCode 检测当前没用的类
    • 无用静态变量
    • 废弃的方法
  • 将 +load 方法中的实现尽量延迟到 +initialize

main() 调用之后的加载

main() 调用之后,主要进行的是初始化相关的服务,显示首页内容。

视图的渲染分三个阶段:

  • 准备阶段。图片的解码。
  • 布局阶段。首页所有 UIView 的 layoutSubViews 运行
  • 绘制阶段。首页所有 UIView 的 drawRect: 运行

接着,是启动之后的必要初始化、一些数据的创建和读取。

所以针对 main() 调用之后可优化的点有:

  • 使用代码加载首页视图,不使用 xib
  • 一些非必要的初始化可以延后
  • 每次 NSLog 会隐式创建一个 Calendar,只在内测时开启 log
  • 启动时的网络请求异步处理

main() 调用之后的耗时,可以借助 Instruments 的 Time Profiler 工具查看。

评论