iOS应用的启动时间优化

App启动步骤

iOS应用的启动可以分为:

  1. pre-main阶段
    • 系统exec()调用
    • 加载Dyld
    • Dyld递归加载App依赖的Dylib
    • Rebase
    • Bind
    • ObjC
    • Initialiser
  2. main阶段
    • main()调用
    • UIApplicationMain()调用
    • application:willFinishLaunchingWithOptions:调用
    • application:didFinishLaunchingWithOptions:调用
    • 首屏渲染

pre-main阶段概括来说,系统内核会先调用exec()函数,将App加载到内存中。然后系统内核会再加载一个Dyld程序,Dyld负责加载App所依赖的所有动态链接库。再接着Dyld会调整这些动态链接库的指针指向。最后加载Runtime组件,向需要初始化的对象发送初始化消息。

本文主要涉及pre-main阶段。

原理篇

内核简介

内核是操作系统的核心。iOS和OS X使用的都是XNU内核。在这里,我们不需要知道XNU内核的详细架构,只需要知道它的功能即可,例如:提供基础服务,像线程,进程管理,IPC(进程间通信),文件系统等等。

Mach-O

Mach-O (Mac object file format) 是一种运行时可执行文件的文件类型。具体的文件类型包括:

  • Executable:应用主要的二进制文件;
  • Dylib: 动态链接库(类似其他平台上的DSO或DLL);
  • Bundle:特殊的Dylib,不能被链接,只能在运行时使用dlopen()函数打开,Mac OS的插件会用到;
  • Image: 代指一个Executable、Dylib或者Bundle;
  • Framework:包含了资源和头文件目录结构的Dylib;

Segment

一个Mach-O文件被划分为多个Segment,Segment使用全大写字母命名。每一个Segment的大小都是一个内存页大小的整数倍。几乎所有的Mach-O文件都包含这三个Segment:

  • __TEXT: readonly,包含了Mach的头文件,代码,以及常量(比如C字符串);
  • __DATA: read-write,包含了全局变量,静态变量等;
  • __LINKEDIT: 包含了如何加载程序的元数据信息;

例如在下图中,TEXT段的大小为3页,DATA段和LINKEDIT段的大小都为1页。

页面大小则取决于具体的硬件。

Section

每个Segment又被划分为多个Section,Section使用全小写字母命名。Segment的大小与页面大小无关,但是Segment之间互不重叠。

Mach-O Universal File

假如一个Mach-O文件需要同时在32bit和64bit的iOS设备上运行,Xcode会生成两个Mach-O文件,一个支持在32bit设备上运行(armv7s),另一个支持在64bit设备上运行(arm64)。然后将这两个文件合并为一个文件,合并生成的文件就叫做Mach-O通用文件。

Mach-O通用文件会包含一个占用一个页面大小的头部,该头部会包含所支持的体系结构的列表以及相应的偏移量。如图所示:

那为什么segment大小必须是页面大小的整数倍?为什么一个头部要占用一个页面大小?这样是否浪费了大量空间?这就涉及到下部分的内容虚拟内存了。

虚拟内存

可以将虚拟内存理解为一个中间层,其要解决的问题是当多个进程同时存在时,如何对物理内存进行管理。虚拟内存技术提高了CPU利用率,使多个进程可以同时、按需加载。

虚拟内存被划分为一个个大小相同的页面(Page),提高管理和读写的效率。 页面大小则取决于具体的硬件,在arm64处理器上一页的大小为16K,其他处理器上一页的大小为4K。

逻辑地址到物理RAM的映射

每个进程都是一个逻辑地址空间,映射到RAM的某个物理页面。这种映射关系不一定是一对一的,逻辑地址可以不对应任何物理RAM,多个逻辑地址也可能映射到同一块物理RAM。

如果一个逻辑地址不映射到任何物理RAM,当进程要访问该地址时,就会产生页面错误(Page fault),内核将中断该进程,寻找可用的物理内存,接着继续执行当前程序。

如果两个进程的逻辑地址映射到了同一块物理RAM,这两个进程将共享这一块物理内存。

File backed mapping

虚拟内存另一个有意思的特性是基于文件的映射(File backed mapping)。在进行文件读取时,不需要将整个文件都读入到RAM,而是调用mmap()函数将文件的某个片段映射到逻辑内存的某个地址范围。如果要访问的文件内容不在内存中,即发生Page fault时,内核才会去读取要访问的文件内容,从而实现了文件的懒加载。

Copy-On-Write

总的来说,Mach-O文件中的__TEXT段可以使用懒加载的方式映射到多个进程中,这些进程会共享这一块内存。__DATA段是可读可写的,这就涉及到了Copy-On-Write技术,简称COW。

当多个进程共享同一个内存页时,一旦其中一个进程要对__DATA段进行写入操作时,就会发生COW。这时,内核会复制这一页内存,并重定向之前的映射关系,这样进行写入操作的进程就拥有了该页内存的拷贝副本,写入操作完成后不会对其他进程造成影响。

由于包含了进程相关的特定信息,拷贝生成的新内存页被称为Dirty Page。与之相对的被称为Clean Page,可以被内核重新生成(比如重新从磁盘读取)。所以,Dirty Page的内存代价要远远大于Clean Page

所以在多个进程加载同一个Dylib时,__TEXT__LINKEDIT因为是只读的,是可以共享内存的。而__DATA因为是可读写的,就会产生Dirty Page(参见下文Rebase和Bind的介绍)。当对Dylib的操作执行结束后,__LINKEDIT就没用了,对应的内存页会被回收。

页面权限

可以将一个内存页标记为readable, writable, executable, 或者这三者的任意组合。

在iOS上,当内存不足的时候,会尝试释放那些被标记为只读的页,因为只读的页在下次被访问的时候,可以再从磁盘读取。如果没有可用内存,会通知在后台的App(也就是在这个时候收到了memory warning),如果在这之后仍然没有可用内存,则会杀死在后台的App。

安全性相关

以下两项技术提升了应用安全性:

  • ASLR:Address Space Layout Randomization,地址空间布局随机化
  • Code Signing: 代码签名

ASLR

简单来说,就是当Mach-O文件映射到逻辑地址空间的时候,利用ASLR技术,可以使得文件的起始地址总是随机的,以避免黑客通过起始地址+偏移量找到函数的地址。

Code Signing

在编译阶段,Mach-O文件的每一个页面都会进行单独的哈希算法加密,所有的哈希值存储在__LINKEDIT中。这保证了每个页面在被加载的过程中都能得到及时验证。

exec()调用

exec()是一个系统调用。当打开一个应用时,ASLR会将应用映射到一个起点随机的地址空间。然后将该随机地址到0的整个地址空间标记为不可访问,即不可读,不可写,不可访问(下图中的PAGEZERO)。

PAGEZERO是一个安全领域, NULL指针就是指向这里。其大小为:

  • 32bit进程:4KB+
  • 64bit进程:4GB+

Dylb

Dyld(Dynamic loader)是一个用来加载动态链接库(Dylib)的帮助程序。当系统内核将应用加载到内存的一个随机逻辑地址之后,就会将Dyld加载到内存的另一个随机逻辑地址。然后程序计数器(PC寄存器)的PC指针指向Dyld,由Dyld完成应用的启动。Dyld的任务是加载应用所需要的所有Dylibs,其操作权限与应用权限相同。

Dyld具体的加载步骤为:

  1. Load dylibs:Map all dependent dylibs, recurse
  2. Fix-ups:Rebasing and Binding
  3. ObjC: Objc prepare images
  4. Initializersre: Run initializers

Load dylibs

加载dylibs的过程可以分为以下几步:

  1. Parse list of dependent dylibs:Dyld首先会读取主执行文件的头部,该头部中包含了所有需要依赖的库的列表;
  2. Find requested mach-o file: 找到列表中依赖库对应的Mach-O文件;
  3. Open and read start of file: 打开Mach-O文件,并读取头部信息,确保文件正确;
  4. Validate mach-o: 验证mach-o文件,找到其代码签名;
  5. Register code signature: 将上一步中找到的代码签名注册到内核中;
  6. Call mmap() for each segment: 在该Mach-o文件的每一个segment上调用mmap()函数

首先,应用直接依赖的库的会被加载完成。但是这些被依赖的库自身可能还会依赖其他的库,Dyld会递归加载这些依赖库,直到所以需要依赖的库都加载完成。平均来说,一个进程会加载1到400个库,这个数量很大,好在大部分都是系统库,系统会进行提前计算和缓存,所以系统库的加载速度非常快。

Dyld是开源的,其地址为:https://opensource.apple.com/source/dyld/

Fix-ups

在加载完所有依赖的dylib之后,这些dylib暂时相互独立,需要将它们绑定到一起。这就是Fix-ups。

由于代码签名的存在,我们不能对指令进行修改。所以如果一个dylib要调用另外一个dylib,只能通过添加间接层来实现。当调用发生时,code-gen,也就是动态PIC(Position Independent Code, 地址无关编码),会在__DATA段创建一个指针,指向要调用的对象,然后加载这个指针并进行跳转。

也就是说,dylibA想调用dylibB的sayHello方法,code-gen会先在dylibA的__DATA段中建立一个指针指向sayHello,再通过这个指针实现间接调用。

所以Dyld要做的就是修正这些指针和数据。

Fix-ups有两种类型:Rebasing和Binding。

Rebasing 和 Binding

Rebasing是修正指向Mach-O文件内部的指针。Binding则是修正指向Mach-O文件外部的指针。如下图所示: 指向_malloc和_free的指针是修正后的外部指针指向。而指向__TEXT的指针则是被修复后的内部指针指向。

这个步骤产生的原因是上文提到过的ASLR。由于起始地址的偏移,所有__DATA段内指向Mach-O文件内部的指针都需要增加这个偏移量, 这些指针的信息包含在__LINKEDIT段中。既然Rebase需要写入数据到__DATA段,那也就意味着Copy-On-Write势必会发生,Dirty page的创建,IO的开销也无法避免。但Rebase的顺序是按地址排列的,所以从内核的角度来看这是个有次序的任务,它会预先读入数据,减少 I/O 消耗。

Binding是__DATA段对外部符号的引用。不过和Rebase不同的是,Binding是靠字符串的匹配来查找符号表的,虽说没有多少IO,但是计算多,效率比Rebasing慢。

可以通过下面的命令查看 rebase 和 bind 的信息:

1
xcrun dyldinfo -rebase -bind -lazy_bind myapp.app/myapp

ObjC Runtime

大部分ObjC数据结构中的指针(比如IMP或superclass)都会在Fix-ups的过程中被修复。不过Runtime还需要进行一些其他操作:

  1. 将所有类的类名注册到一个全局表中(所以ObjC可以通过类名实例化一个对象)
  2. 将分类中的方法添加到类的方法列表中
  3. 检查Selector的唯一性

Initializers

ObjC中有一个+load方法,不过已经不推荐使用,建议使用+initialize方法进行替代。

如果存在+load方法,此时将会被调用。然后会自底向上的,即从底部的dylib开始,逐步向上加载,直到主执行文件,依次进行初始化。这种加载顺序确保了安全性,加载某个 dylib 前,其所依赖的其余 dylib 文件肯定已经被预先加载。

最后 dyld 会调用 main() 函数。main() 会调用 UIApplicationMain()。

实践篇

启动时间的计算标准

应用启动分为冷启动和热启动,这里只讨论冷启动。为了准确测量冷启动耗时,测量前需要重启设备。

应用的冷启动时间与具体的设备有关,但最好控制在400ms以内。一旦启动时间超过20s,系统会认为应用进入了死循环,并终结该进程。启动时间的测试应该以应用支持的最低配置设备为参考。直到 applicationWillFinishLaunching方法被调动,应用才算启动结束。

要注意的是,UIApplicationMain()和applicationWillFinishLaunching都计算在应用的启动时间中。

测量启动时间

Dyld可以测量main()方法执行之前所消耗的时间,在 Xcode 中 Edit scheme -> Run -> Auguments 将环境变量 DYLD_PRINT_STATISTICS 设为 1。运行应用,可以看到如下类似的打印输出:

1
2
3
4
5
6
7
8
Total pre-main time: 228.41 milliseconds (100.0%)
dylib loading time: 82.35 milliseconds (36.0%)
rebase/binding time: 6.12 milliseconds (2.6%)
ObjC setup time: 7.82 milliseconds (3.4%)
initializer time: 132.02 milliseconds (57.8%)
slowest intializers :
libSystem.B.dylib : 122.07 milliseconds (53.4%)
CoreFoundation : 5.59 milliseconds (2.4%)

优化建议

Dylib加载阶段

可以减少使用的Dylib数量,将多个Dylib进行合并。Apple的WWDC2016(Session 406)给出了一个例子: 一个项目依赖26个动态库,dylibs加载时间是240毫秒; 当将其合并成2个动态库时,加载时间变为20毫秒,可以说性能显著提高。

也可以使用静态库。

不建议使用dlopen()对Dylib进行懒加载,可能会造成一些其他问题,而且实际工作量可能会更多。

Rebasing/Binding阶段

根据Rebasing和Binding所进行的操作,可以减少要Fix-ups的指针数量。

具体来说,就是减少ObjC元数据(Class,selector,category)的数量。很多设计模式都会生成大量的类,但这会影响应用的启动速度。

还可以减少使用C++的虚函数,C++会生成一个虚函数表,这也会在__DATA段中创建。

最后推荐使用Swift结构体,因为struct是值类型。

ObjC setup阶段

针对这步所能事情很少,Rebasing/Binding阶段的优化工作也会使得这步耗时减少。

Initializer阶段

在iOS平台下,如果项目使用ObjC,尽量减少使用+load方法,。如果项目使用Swift,Apple已经帮我们调用了他们自己的initializer(dispatch_once), 确保Swift的Class不会被初始化多次。

在这一阶段,可以做的优化有:

  • 减少使用+load方法,如果必须使用, 替换为+initialize
  • 减少构造函数的数量,尽量不要在构造函数里进行耗时任务
  • 减少C++静态全局变量的数量

另外,不要在初始化方法中调用 dlopen(),这会对性能有影响。因为 Dyld 在 App 开始前运行,由于此时是单线程运行所以系统会取消加锁,但 dlopen() 开启了多线程,系统不得不加锁,这就严重影响了性能,还可能会造成死锁以及产生未知的后果。所以也不要在初始化器中创建线程。

补充:Dyld3

上文的讲解都是dyld2的加载方式。而在iOS 13系统中,将全面采用新的dyld3以替代之前版本的dyld2。 因为dyld3完全兼容dyld2,API接口是一样的,所以在大部分情况下,不需要做额外的适配就能平滑过渡。

dyld2是纯粹的in-process,也就是在程序进程内执行的,也就意味着只有当应用程序被启动的时候,dyld2才能开始执行任务。

dyld3则是部分out-of-process,部分in-process。图中,虚线之上的部分是out-of-process的,在App下载安装和版本更新的时候会去执行,out-of-process会做如下事情:

  • 分析Mach-o Headers
  • 分析依赖的动态库
  • 查找需要Rebase & Bind之类的符号
  • 把上述结果写入缓存

这样,在应用启动的时候,就可以直接从缓存中读取数据,加快加载速度。

参考链接