优化 App 的启动时间

 转:http://yulingtianxia.com/blog/2016/10/30/Optimizing-App-Startup-Time/


参考 文章:

https://icetime17.github.io/2018/01/01/2018-01/APP启动优化的一次实践/

APP启动优化的一次实践

https://mp.weixin.qq.com/s/Kf3EbDIUuf0aWVT-UCEmbA

iOS App 启动性能优化

https://mp.weixin.qq.com/s/zeWfmAi0YnoQowcPpFhHUA

iOS启动时间优化

https://mp.weixin.qq.com/s/wBZFv_-l7MDtTdofIxS13A

今日头条iOS客户端启动速度优化

https://developer.apple.com/videos/play/wwdc2016/406/

http://yulingtianxia.com/blog/2016/10/30/Optimizing-App-Startup-Time/

优化 App 的启动时间  (最好)

http://www.zoomfeng.com/blog/launch-time.html

iOS启动时间优化



打印 启动时间 :

Total pre-main time:  94.33 milliseconds (100.0%)

 dylib loading time:  61.87 milliseconds (65.5%)

 rebase/binding time:   3.09 milliseconds (3.2%)

 ObjC setup time:  10.78 milliseconds (11.4%)

 initializer time:  18.50 milliseconds (19.6%)

 slowest intializers :

 libSystem.B.dylib :   3.59 milliseconds (3.8%)

 libBacktraceRecording.dylib :   3.65 milliseconds (3.8%)

 GTFreeWifi :   7.09 milliseconds (7.5%)



启动时间线:

Load dylibs -> Rebase -> Bind -> ObjC -> Initializers



一、了解

0、Mach-O 镜像 加载

所以在多个进程加载Mach-O 镜像时__TEXT 和__LINKEDIT 因为只读,都是可以共享内存的。而__DATA 因为可读写,就会产生dirty page。当dyld 执行结束后,__LINKEDIT 就没用了,对应的内存页会被回收。

1、安全

ASLR(Address Space Layout Randomization):地址空间布局随机化,镜像会在随机的地址上加载。这其实是一二十年前的旧技术了。

代码签名:可能我们认为Xcode 会把整个文件都做加密hash 并用做数字签名。其实为了在运行时验证Mach-O 文件的签名,并不是每次重复读入整个文件,而是把每页内容都生成一个单独的加密散列值,并存储在__LINKEDIT 中。这使得文件每页的内容都能及时被校验确并保不被篡改。

2、从exec() 到main()

exec() 是一个系统调用。系统内核把应用映射到新的地址空间,且每次起始位置都是随机的(因为使用ASLR)。并将起始位置到0x000000 这段范围的进程权限都标记为不可读写不可执行。如果是32 位进程,这个范围至少是4KB;对于64 位进程则至少是4GB。NULL指针引用和指针截断误差都是会被它捕获。

3、dyld 加载dylib 文件

Unix 的前二十年很安逸,因为那时还没有发明动态链接库。有了动态链接库后,一个用于加载链接库的帮助程序被创建。在苹果的平台里是dyld,其他Unix 系统也有ld.so。 当内核完成映射进程的工作后会将名字为dyld 的Mach-O 文件映射到进程中的随机地址,它将PC 寄存器设为dyld 的地址并运行。dyld 在应用进程中运行的工作是加载应用依赖的所有动态链接库,准备好运行所需的一切,它拥有的权限跟应用一样。


二、加载Dylib

从主执行文件的header 获取到需要加载的所依赖动态库列表,而header 早就被内核映射过。然后它需要找到每个dylib,然后打开文件读取文件起始位置,确保它是Mach-O 文件。接着会找到代码签名并将其注册到内核。然后在dylib 文件的每个segment 上调用mmap()。应用所依赖的dylib 文件可能会再依赖其他dylib,所以dyld 所需要加载的是动态库列表一个递归依赖的集合。一般应用会加载100 到400 个dylib 文件,但大部分都是系统dylib,它们会被预先计算和缓存起来,加载速度很快。

1、Fix-ups

在加载所有的动态链接库之后,它们只是处在相互独立的状态,需要将它们绑定起来,这就是Fix-ups。代码签名使得我们不能修改指令,那样就不能让一个dylib 的调用另一个dylib。这时需要加很多间接层。

现代code-gen 被叫做动态PIC(Position Independent Code),意味着代码可以被加载到间接的地址上。当调用发生时,code-gen 实际上会在__DATA 段中创建一个指向被调用者的指针,然后加载指针并跳转过去。

所以dyld 做的事情就是修正(fix-up)指针和数据。Fix-up 有两种类型,rebasing 和binding。

2、Rebasing 和Binding

Rebasing:在镜像内部调整指针的指向

Binding:将指针指向镜像外部的内容

 可以通过命令行查看rebase 和bind 等信息:

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

通过这个命令可以查看所有的Fix-up。rebase,bind,weak_bind,lazy_bind 都存储在__LINKEDIT 段中,并可通过LC_DYLD_INFO_ONLY 查看各种信息的偏移量和大小。

 建议用MachOView 查看更加方便直观。

 从dyld 源码层面简要介绍下Rebasing 和Binding 的流程。

ImageLoader是一个用于加载可执行文件的基类,它负责链接镜像,但不关心具体文件格式,因为这些都交给子类去实现。每个可执行文件都会对应一个ImageLoader 实例。ImageLoaderMachO 是用于加载Mach-O 格式文件的ImageLoader 子类,而ImageLoaderMachOClassic 和ImageLoaderMachOCompressed都继承于ImageLoaderMachO,分别用于加载那些__LINKEDIT 段为传统格式和压缩格式的Mach-O 文件。

 因为dylib 之间有依赖关系,所以ImageLoader 中的好多操作都是沿着依赖链递归操作的,Rebasing 和Binding 也不例外,分别对应着recursiveRebase() 和recursiveBind() 这两个方法。因为是递归,所以会自底向上地分别调用doRebase() 和doBind() 方法,这样被依赖的dylib 总是先于依赖它的dylib 执行Rebasing 和Binding。传入doRebase() 和doBind() 的参数包含一个LinkContext 上下文,存储了可执行文件的一堆状态和相关的函数。

 在Rebasing 和Binding 前会判断是否已经Prebinding。如果已经进行过预绑定(Prebinding),那就不需要Rebasing 和Binding 这些Fix-up 流程了,因为已经在预先绑定的地址加载好了。

ImageLoaderMachO实例不使用预绑定会有5个原因:

1.       Mach-O Header 中MH_PREBOUND 标志位为0

2.       镜像加载地址有偏移(这个后面会讲到)

3.       依赖的库有变化

4.       镜像使用flat-namespace,预绑定的一部分会被忽略

5.       LinkContext 的环境变量禁止了预绑定


ImageLoaderMachO中doRebase() 做的事情大致如下:


1.       如果使用预绑定,fgImagesWithUsedPrebinding计数加一,并return;否则进入第二步

2.       如果MH_PREBOUND 标志位为1(也就是可以预绑定但没使用),且镜像在共享内存中,重置上下文中所有的lazy pointer。(如果镜像在共享内存中,稍后会在Binding 过程中绑定,所以无需重置)

3.       如果镜像加载地址偏移量为0,则无需Rebasing,直接return;否则进入第四步

4.       调用rebase() 方法,这才是真正做Rebasing 工作的方法。如果开启TEXT_RELOC_SUPPORT 宏,会允许rebase() 方法对__TEXT 段做写操作来对其进行Fix-up。所以其实__TEXT 只读属性并不是绝对的。

ImageLoaderMachOClassic和ImageLoaderMachOCompressed 分别实现了自己的doRebase() 方法。实现逻辑大同小异,同样会判断是否使用预绑定,并在真正的Binding 工作时判断TEXT_RELOC_SUPPORT 宏来决定是否对__TEXT 段做写操作。最后都会调用setupLazyPointerHandler在镜像中设置dyld 的entry point,放在最后调用是为了让主可执行文件设置好__dyld 或__program_vars。


3、Rebasing

 在过去,会把dylib 加载到指定地址,所有指针和数据对于代码来说都是对的,dyld 就无需做任何fix-up 了。如今用了ASLR 后会将dylib 加载到新的随机地址(actual_address),这个随机的地址跟代码和数据指向的旧地址(preferred_address)会有偏差,dyld 需要修正这个偏差(slide),做法就是将dylib 内部的指针地址都加上这个偏移量,偏移量的计算方法如下:

Slide = actual_address - preferred_address

 然后就是重复不断地对__DATA 段中需要rebase 的指针加上这个偏移量。这就又涉及到page fault 和COW。这可能会产生I/O 瓶颈,但因为rebase 的顺序是按地址排列的,所以从内核的角度来看这是个有次序的任务,它会预先读入数据,减少I/O 消耗。

4、Binding

Binding 是处理那些指向dylib 外部的指针,它们实际上被符号(symbol)名称绑定,也就是个字符串。之前提到__LINKEDIT 段中也存储了需要bind 的指针,以及指针需要指向的符号。dyld 需要找到symbol 对应的实现,这需要很多计算,去符号表里查找。找到后会将内容存储到__DATA 段中的那个指针中。Binding 看起来计算量比Rebasing 更大,但其实需要的I/O 操作很少,因为之前Rebasing 已经替Binding 做过了。

5、ObjC Runtime

 Objective-C 中有很多数据结构都是靠Rebasing 和Binding 来修正(fix-up)的,比如Class 中指向超类的指针和指向方法的指针。

 ObjC 是个动态语言,可以用类的名字来实例化一个类的对象。这意味着ObjC Runtime 需要维护一张映射类名与类的全局表。当加载一个dylib 时,其定义的所有的类都需要被注册到这个全局表中。

 C++ 中有个问题叫做易碎的基类(fragile base class)。ObjC 就没有这个问题,因为会在加载时通过fix-up 动态类中改变实例变量的偏移量。

在ObjC 中可以通过定义类别(Category)的方式改变一个类的方法。有时你想要添加方法的类在另一个dylib 中,而不在你的镜像中(也就是对系统或别人的类动刀),这时也需要做些fix-up。

ObjC 中的selector 必须是唯一的。

6、Initializers

 C++ 会为静态创建的对象生成初始化器。而在ObjC 中有个叫+load 的方法,然而它被废弃了,现在建议使用+initialize。

对比详见:http://stackoverflow.com/questions/13326435/nsobject-load-and-initialize-what-do-they-do

 现在有了主执行文件,一堆dylib,其依赖关系构成了一张巨大的有向图,那么执行初始化器的顺序是什么?自顶向上!按照依赖关系,先加载叶子节点,然后逐步向上加载中间节点,直至最后加载根节点。这种加载顺序确保了安全性,加载某个dylib 前,其所依赖的其余dylib 文件肯定已经被预先加载。

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




三、优化启动时间

可以针对App启动前的每个步骤进行相应的优化工作。

1、加载Dylib

 之前提到过加载系统的dylib 很快,因为有优化。但加载内嵌(embedded)的dylib 文件很占时间,所以尽可能把多个内嵌dylib 合并成一个来加载,或者使用static archive。使用dlopen() 来在运行时懒加载是不建议的,这么做可能会带来一些问题,并且总的开销更大。

2、Rebase/Binding

 之前提过Rebaing 消耗了大量时间在I/O 上,而在之后的Binding 就不怎么需要I/O 了,而是将时间耗费在计算上。所以这两个步骤的耗时是混在一起的。

 之前说过可以从查看__DATA 段中需要修正(fix-up)的指针,所以减少指针数量才会减少这部分工作的耗时。对于ObjC 来说就是减少Class,selector 和category 这些元数据的数量。从编码原则和设计模式之类的理论都会鼓励大家多写精致短小的类和方法,并将每部分方法独立出一个类别,其实这会增加启动时间。对于C++ 来说需要减少虚方法,因为虚方法会创建vtable,这也会在__DATA 段中创建结构。虽然C++ 虚方法对启动耗时的增加要比ObjC 元数据要少,但依然不可忽视。最后推荐使用Swift 结构体,它需要fix-up 的内容较少。

3、ObjC Setup

针对这步所能事情很少,几乎都靠Rebasing 和Binding 步骤中减少所需fix-up 内容。因为前面的工作也会使得这步耗时减少。

4、Initializer

显式初始化

 使用+initialize 来替代+load,如果 一个类中+initialize 和 +load 代码同时存在,则这种优化无效果。

 不要使用__atribute__((constructor)) 将方法显式标记为初始化器,而是让初始化方法调用时才执行。比如使用dispatch_once(),pthread_once() 或std::once()。也就是在第一次使用时才初始化,推迟了一部分工作耗时。

隐式初始化

对于带有复杂(non-trivial)构造器的C++ 静态变量:

1.       在调用的地方使用初始化器。

2.       只用简单值类型赋值(POD:Plain Old Data),这样静态链接器会预先计算__DATA 中的数据,无需再进行fix-up 工作。

3.       使用编译器warning 标志-Wglobal-constructors 来发现隐式初始化代码。

4.       使用Swift 重写代码,因为Swift 已经预先处理好了,强力推荐。

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

你可能感兴趣的:(优化 App 的启动时间)