iOS App启动时间优化原理

0.787字数 3886阅读 805

背景

99u在去年做过一次加快启动时间的优化,虽然是工厂大大们在主导,但是一直很好奇当我们的手指在屏幕上点击一个app到看到第一个画面的时候,系统经历了哪些阶段都做了哪些事情,工厂在从main函数入口到主UI框架的viewDidAppear函数调用的这一段时间做了大量优化,那么在main之前究竟发生了什么,我们又应该如何通过组织代码来加快app加载的过程呢?

App的启动过程

通过在工程的scheme中添加环境变量DYLD_PRINT_STATISTICS,设置值为1,App启动加载时Xcode的控制台就会有pre-main各个阶段的详细耗时输出。

Total pre-main time: 1.1 seconds (100.0%)
         dylib loading time:  36.26 milliseconds (3.1%)
        rebase/binding time: 686.71 milliseconds (60.2%)
            ObjC setup time: 101.48 milliseconds (8.9%)
           initializer time: 314.61 milliseconds (27.6%)
           slowest intializers :
             libSystem.B.dylib :   9.56 milliseconds (0.8%)
          libglInterpose.dylib : 111.40 milliseconds (9.7%)
         libMTLInterpose.dylib :  49.69 milliseconds (4.3%)
               im1517284265074 : 200.11 milliseconds (17.5%)

一般说来,pre-main阶段的定义为APP开始启动到系统调用main函数这一段时间;main阶段则代表从main函数入口到主UI框架的viewDidAppear函数调用的这一段时间。那么这里的参数又都代表了什么含义,如何优化?下面首先从一些基本概念开始。

基本概念

Mack-O 格式

Mack-O包含如下几种格式的文件:

  1. Executable:应用以及extension的可执行二进制文件
  2. Dylib:动态链接库,如其他平台上的DSO和DLL
  3. Bundle:不能被链接,只能在运行时使用dlopen加载
  4. Image:镜像,可以用来表示任何一种类型的可执行文件,如Executable、Dylib和Bundle(后面会经常用到它)
  5. Framework:苹果特有的一种可执行文件,里面包含了Dylib以及相关的头文件和资源

Mack-O 镜像文件

  1. 镜像文件被划分为segments,所有的segments名字都是大写的
  2. 每个segment具有一个或多个page size大小,page size大小由硬件决定,如arm64时16kb,其他的是4kb
  3. 几乎每一个镜像文件都包含的segment有TEXT DATA 和 LINKEDIT
  4. TEXT处于文件的开头,他包含了Mack Header(指定文件的目标体系结构,如PPC,PPC64,IA-32或x86-64),被执行的机器指令以及一些只读常量,如c字符串
  5. DATA部分是可读写的,包含了全局变量以及静态变量
  6. LINKEDIT是原数据部分,如函数的名字和地址
动态库内容

Mack-O 通用文件

  1. 针对多个硬件平台的Mack-O文件的合体
  2. 文件头部为Fat Header segment,里面记录了所有架构以及他们的TEXT segement在文件内的偏移量
  3. 这个Fat Header只有一个 page 的大小
Mack-O 通用文件内容

这里所有的segment的大小都需要是page大小的整数倍,主要原因与接下来要将的虚拟内存有关

虚拟内存

每个进程内部都是使用的逻辑地址空间,这个逻辑地址与物理RAM之间存在着映射关系,这个映射是以page为单位的。这种映射关系不一定是1对1的,有可能某个逻辑地址不对应任何的物理RAM,也可以多个逻辑地址共同对应一个物理RAM地址。虚拟内存主要有以下几个用处:

  1. 当某个逻辑地址没有对应的物理内存地址的时候,内核会中断当前线程以执行对应的策略
  2. 多个逻辑地址映射到同一个物理内存地址的时候,可以允许不同的进程共享同一块物理内存
  3. 允许在读取文件的时候不用读取整个文件直接到内存,而是通过调用mmap将文件的某个部分读取到我这个进程的某段逻辑地址,这样当你第一次读取文件的某个部分的时候,因为它还没有与真实的物理地址对应,也就是上面的第一种情况,此时系统会紧紧读取该部分,也就是page大小的内容到内存中,实现了读取文件的懒加载

以page为单位的操作

基于上面的特性,任何Mach-O镜像的TEXT segment部分都可以被映射到不同的进程内,被懒加载读取,而且以page为单位被多个进程共享。

而对于DATA部分,因为它是可读写的,因此有对应的Copy-On-Write策略,当某进程在去读取DATA部分而另外一个进程需要修改的时候,内核会将需要被写入的page大小部分拷贝到另外一个物理内存地址中,然后将该线程内它的虚拟地址映射到新的内存地址。此时系统成有dirty和clean两份page,被copy出来的那份为dirty page,同时脏数据页还包含了进程相关的信息,这部分数据页是比较耗性能的

最后,权限的设置也是以page为单位的,可以对单独的page数据设置可读或可写等权限

安全性

  1. ASLR:物理内存的分配是随机的,镜像被夹在到随机的内存地址当中
  2. 代码签名:为了保证在运行的时候保证文件内容的正确性,需要对文件进行签名,但是因为文件内存的物理地址是随机的而且是以page为单位的,为了快速地判断文件内容是否被修改过,需要对每个page签名,所有这些签名信息被存储在LINKEDIT

Mack-O 镜像的加载

  1. 首先在加载一个dyld的时候,我们将它进行虚拟地址映射而不是直接读取到物理内存中,文件的开头映射到虚拟内存的起始位置
  2. 系统首先读取文件的Mach Header,发现没有对应的物理内存,此时开始上文提到的懒加载,将一个page的数据读取到物理内存中,并做好与虚拟内存的映射
  3. 然后系统用同样的方式继续往后读取Mach Header,当发现某些信息需要从LINKEDIT中获取的时候,就开始用同样的方式读取LINKEDIT中的部分
  4. 但是在进程中,LINKEDIT会告诉dyld,你需要对DATA部分做一些修正才能让dyld正确运行,于是又开始用同样的懒加载方式读取了DATA部分的数据库到内存中
  5. 但是此时进程是会修改DATA部分的数据的,因此被修改的部分被标记为脏数据页
  6. 此时如果有另外一个进程使用此dyld的时候,TEXT和LINKEDIT部分是不会被重新读取的,可以直接使用内存中已有的内容,对于DATA部分的数据,如果内存中有对应的脏数据页,则重新读取,如果没有那么就服用内存中现有的数据,如果同一段数据都被两个进程修改了,那么此时内存中会有两个对应的脏数据页
镜像文件的加载

exec() to main()

exec()

exec是一个系统调用。

  1. 当内核启动一个应用的时候,会随机地在无用的内存地址内找个地方加载你的应用。
  2. 加载应用的起始点到内存的开头被标记为不可读取、不可写、不可执行
  3. 当需要使用到动态库的时候,由dyld来加载动态库,因此此时系统也将Dyld夹在到内存中的一个随机的地址,有dyld来结束剩下的加载,它的主要职责就是加载所有使用到的动态库,并使它们准备好运行
动态库加载

dyld的运行主要有以下几个步骤:

  1. 首先,dyld映射所有以来到的dylibs,通过从主运行程序中的头部中可以读取到所有依赖的dylibs
  2. 查找到所有的dylibs
  3. 打开并开始读取每个Mack-O文件
  4. 验证Mack-O文件,并将验证信息注册到内核当中
  5. 这样就可以对每个page块调用mmap()

如此依赖应用的所有动态库也被加载到内存中,一般情况下一个应用会加载1-400个dylibs,当然大部分是系统的动态库,在加载的时候系统已经做过优化

但是此时所有的动态库都是各自为政的,它们之间存在着依赖关系,所以我们还需要将它们串联起来,也就是修正引用的地址。这里就涉及到一个问题,因为有代码签名的原因,我们不能直接修改指令,那么我们又该如何修正动态库之间的互相调用呢?

现代的code-gen代码生成器是一种动态的PIC(Position Independent Code),位置独立表示代码可以被加载到任意的地址,动态表示指令不是直接被指向的,也就是说当需要调用其他库中的指令的时候,code-gen会在DATA块中创建一个指针,这个指针指向了真实的指令,然后本库中的调用加载这个被创建的指针并跳转到真正被调用的指令去。

dyld在这阶段的主要任务就是修正这些指针和数据。这里分为两种情况:rebasing和binding。

  1. rebasing是修正指向本镜像内部的指针;在iOS4.3前会把dylib加载到指定地址,所有指针和数据对于代码来说都是固定的,dyld 就无需做rebase/binding了,iOS4.3后引入了 ASLR ,dylib会被加载到随机地址,这个随机的地址跟代码和数据指向的旧地址会有偏差,dyld 需要修正这个偏差,做法就是将 dylib 内部的指针地址都加上这个偏移量。然后就是重复不断地对LINK 段中需要 rebase 的指针加上这个偏移量。这可能会产生 I/O 瓶颈,但因为 rebase 的顺序是按地址排列的,所以从内核的角度来看这是个有次序的任务,它会预先读入数据,减少 I/O 消耗
  2. binding是修正指向镜像外部的指针。这部分指针实际上是通过字符串来表示的,在运行的时候dyld需要找到这个符号所表示的真实实现在哪里,这里就需要通过查找符号表,因此会有大量的计算。这一步所需要的io很少,因为link段中的数据在之前的rebasing阶段已经被读取过了。
地址修正
修正信息

接下来的步骤就是Objc运行时初始化。

  1. Objc在DATA段中有大量对应的数据信息,这部分在rebasing和binding阶段基本上已经完成
  2. 因为Objc是一种动态语言,因此需要注册类名与类相关信息的一张注册表
  3. 对在其他dylib中定义的category,也需要通过rebasing和binding来修正扩展方法的地址
  4. 保证selector的唯一性

到这里我们基本完成了DATA段中静态数据的修正,接下来就是对一些动态数据的修正:

  1. C++被静态初始化的对象方法,如用attribute((constructor))修饰
  2. Objc对应的load方法(官方不推荐使用load方法,而推荐使用initialize,如果有自定义load方法的话,那么将会在这个时刻执行)
  3. 按照引用层级,所有上面的方法从底向上调用执行

Pre-main() 总结

总结一下,在main函数被调用之前,系统主要做了如下几件事

  1. 加载所有的dylibs
  2. 修正所有的DATA数据页
  3. 运行所有初始化方法(类的load方法和c++中被静态初始化的对象的初始化函数)

那么接下来我们需要探究的问题也就出来了:

  1. app的加载时间的合理范围是多少
  2. 如何来测量app加载的时间
  3. 为什么app的加载会慢
  4. 我们可以通过哪些方式来加快app的启动

启动时间及测量

虽然启动时间与硬件平台有关,但最好控制在400毫秒以内。因为在点击app图标到app画面出现时有一个动画的,这个动画给了我们一些时间来执行pre-main

当然,如果在20s内都没有执行完,那么系统会直接杀掉app

这里所讲的所有时间包括执行以下步骤的所有时间

  1. 解析镜像
  2. 映射镜像
  3. Rebase镜像
  4. Bind镜像
  5. 镜像初始化
  6. 调用main()方法
  7. 调用UIApplicationMain()方法
  8. 调用applicationWillFinishLaunching回调

还需要注意的是,因为系统内核缓存的原因,app的启动还有热启动和冷启动之分,当app启动过且仍然在缓存中的时候,再次启动的过程就是热启动,及其重启后的第一次启动肯定是冷启动。

上问提到的DYLD_PRINT_STATISTICS环境变量只能帮助我们测量Dyld的启动时间,无法帮助我们完整地测量main()方法执行之前的所有时间

环境变量设置

为了能够解析dylib的符号表,debugger需要在加载每个dylib的时候暂停一下,同时通过USB线来传输这些数据,这是非常耗时的,但是DYLD_PRINT_STATISTICS在测量的时候已经减去了这些耗时,因此不用担心在debug模式下数据的准确性,而且测量出的耗时通常会比肉眼计算出来的耗时要小。

优化方法

dylib loading time

  1. 核心思想是减少dylibs的引用
  2. 合并现有的dylibs(最好是6个以内)
  3. 使用静态库

rebase/binding time

  1. 核心思想是减少DATA块内的指针
  2. 减少Object C元数据量,减少Objc类数量,减少实例变量和函数(与面向对象设计思想冲突)
  3. 减少c++虚函数
  4. 多使用Swift结构体(推荐使用swift)

ObjC setup time

核心思想同上,这部分内容基本上在上一阶段优化过后就不会太过耗时

initializer time

  1. 使用initialize替代load方法
  2. 减少使用c/c++的attribute((constructor));推荐使用dispatch_once() pthread_once() std:once()等方法
  3. 推荐使用swift
  4. 不要在初始化中调用dlopen()方法,因为加载过程是单线程,无锁,如果调用dlopen则会变成多线程,会开启锁的消耗,同时有可能死锁
  5. 不要在初始化中创建线程

参考文章

  1. Optimizing App Startup Time
  2. iOS启动优化
  3. iOS启动优化

推荐阅读更多精彩内容