WWDC2016-Optimizing App Startup Time

Mach-O

Mach-O 文件是运行时可执行文件类型,包括:

  • 可执行文件。Executable — Main binary for application.
  • Dylib — Dynamic library.(aka DSO or DLL)
  • Bundle — Dylib that can not be linked. 只能在运行时用 dlopen() 函数打开。
  • Images — Any executable dylib or bundle.
  • Framework — Dylib with directory for resources and headers.
  • 图像格式:分成3段,每一段都是页面大小的倍数。

页面大小由硬件决定,arm64处理器页面大小是16K,其他的是4K。

  • 通用文件:Universal Files

假设你有一个Mach-O文件运行在64位(arm64)处理器的设备上,如果你想把它运行在32位(armv7s)的设备上,Xcode里会发生什么变化?

答案是:会生成另一个单独的Mach-O文件。然后这两个文件合并生成第三个文件,这个文件就是通用文件。通用文件会有一个头文件,占一页大小。

虚拟内存:Virtual Memory

虚拟内存是间接层。当所有的进程存在时,OS 会使用间接层来管理物理内存。每一个进程都是一个逻辑地址空间,它们被映射到 RAM 的某个物理页面。

  1. Page fault:如果一个虚拟内存不映射到任何物理内存,那么访问这个进程 时,就会产生页面错误,内核会停止该进程,并试图找出解决方案。
  2. 多个进程可以共享同一块物理内存,进程共享。
  3. File backed pages:基于文件的映射:不用把整个文件读入 RAM,而是调用 mmap() 函数告诉虚拟内存系统,我想把这个文件映射到进程里的这段地址。
  4. Copy-On-Write (COW):写入时复制。写入时复制所做的就是它积极地在所有进程里共享DATA页面,只要进程只读有共享内容的全局变量,但是一旦有进程想要写入其DATA页面,写入时复制开始。内核会把该页面复制,放入另一个物理RAM并重定向映射,所以该进程有了该页面的副本。
  5. Dirty vs. clean Pages:脏页面和干净页面。上面的副本被认为是脏页面。脏页面是指含有进程特定信息。干净页面是指内核可以按照需要重新建立的页面,比如重新读取磁盘的时候。脏页面比干净页面昂贵得多。

exec() —> main() 的过程

  • exec() 是一个系统调用。当你进入一个内核,说:我想把这个进程换成这个新程序。然后内核会抹去整个地址来映射这个新的可执行程序。ASLR 会给它分配一个随机地址。下一步是从该随机地址回溯到 0 地址,把整个地址标记为不可访问:

wwdc2016-406_p_1

  • Kernal loads helper program
  • Dyld (Dynamic loader)
  • Executions starts in Dyld (aka LD.SO)

当内核完成内存映射之后,就把指针指向Dyld,让Dyld来完成进程的启动。它的工作是加载所有依赖的Dylib,让它们准备好开始运行。其加载过程如下:

wwdc2016-406_p_2

  • Load dylibs

    • 读取所有依赖的 Dylib。首先从内核中读取已经加载好的主可执行文件。在这个主可执行文件的 Header 中有所有依赖库的列表。然后打开和运行这些Dylib,验证它是不是一个 Mach-O 文件,找到它的编码签名,在内核里对它进行注册,在该 Dylib 的每一段调用 mmap() 函数。
    • 在加载每个 Dylib时,每个 Dylib 可能还依赖于另一个 Dylib,所以需要递归式的把它们一个一个找出来加载到内存。
  • DATA修复

    • 重设基址(Rebase):遍历所有内部数据指针,然后为它们添加一个滑动值。这些指针在段里的位置都编码在__LINKEDIT段里。I/O比较多。
    • 绑定(Bind):针对那些指向Dylib范围外的指针而言的。其计算复杂度比Rebase要高得多。
    • ObjC:经过前两步之后,在ObjC运行时还需要一些额外的操作。在ObjC运行时,必须要维护一张表格,包含所有名称及其映射的类。每次加载的名称都将定义一个类,名称需要登记在一个全局表格里
  • Initializer

  • 跳转到main()函数

启动时间优化

经过上面的总结:
理论上 App 的启动时间是由 main() 函数之前的加载时间(t1)和 main() 函数之后的加载时间(t2)。

t1 时间优化

加入 DYLD_PRINT_STATISTICS 分析 App 启动日志

屏幕快照 2018-10-23 下午12.47.17

在环境变量中添加 DYLD_PRINT_STATISTICS 就可以在控制台看见自己 App 的启动时间输出。

优化方向

  1. 根据上文的总结,首先就是需要减少动态库的加载,构建OS时,会预计算大量的dylib数据,但是始终无法全部预算。

  2. 减少 rebase/binding time。时间都花费在修复__DATA里的指针。所以方法就是减少需要修复的指针。

  3. 减少ObjC类对象和ivars的数量。

  4. 使用Swift语言 :Swift 通常用的数据要少一些。而且 Swift 更为内联,可以更好地使用 code-gen 减少消耗。

  5. 使用 initialize 方法替换 load 方法。

    • 显示的初始化器:ObjC +load() 方法。如果你是用的话最好换成 +initialize() 方法,但是这种显示的初始化器最好换成点初始化器:dispatch_once() 或者 pthread_once() 或者 std::once()。
    • 隐式初始化器:大部分来自 C++ 的全局变量,带有非默认的初始化器,非默认的构造函数。这里可以用前面提到的点初始化器替代,或者把全局的换成非全局的结构或者指针,指向想要初始化的对象。或者 Only set simple values (PODs)。或者用 Swift 重新编写,Swift 的全局变量可以在使用前确保被初始化,其本质还是在后台调用了点初始化器。

t2 时间优化

main() 被调用过后,App 主要所做的是一些初始化工作和首页显示。

首先的显示主要包含四个方面:

  • 图片的解码
  • 控件的布局
  • 控件的绘制
  • 网络请求

初始化主要是:

  • 加载 UserDefault 产生的 plist 文件
  • didFinishLaunching 中的配置代码

优化方向

  1. 使用纯代码代替 nib 加载首页视图
  2. 在控件布局周期中打点观察,特别是 viewDidLoadviewWillAppear 这两个操作
  3. 减少 Log
  4. 梳理启动的网络请求,评估是否可以在
  5. 延迟加载 didFinishLaunching 中的某些配置代码