iOS 如何优化 App 的启航日

App 运行理论


  • main() 执行前发生的行

  • Mach-O 格式

  • 虚拟内存基础

  • Mach-O 二进制的加载

理论速成

Mach-O 术语

Mach-O 是指向不同运行时可执行文件的文件类型。

文件类型:

  • Executable: 应用之显要二进制

  • Dylib: 动态链接库(又如 DSO 或 DLL)

  • Bundle: 不能被链接的 Dylib,只能于运作时行使 dlopen() 加载,可用作
    macOS 的插件。

Image: executable,dylib 或 bundle
Framework: 包含 Dylib 以及资源文件和头文件的文书夹

Mach-O 镜像文件

Mach-O 于细分成有 segement,每个 segement 又让分开成有 section。

segment 的讳还是大写的,且空间大小为页的整数。页的分寸和硬件有关,在
arm64 架构一页是 16KB,其余为 4KB。

section 虽然从未整数倍页大小的范围,但是 section 之间无会见时有发生臃肿。

差一点拥有 Mach-O
都富含这三个段子(segment): __TEXT,__DATA 和 __LINKEDIT:

  • __TEXT 包含 Mach header,被实践的代码和光读常量(如C
    字符串)。只念而实施(r-x)。

  • __DATA 包含全局变量,静态变量等。可读写(rw-)。

  • __LINKEDIT 包含了加载程序的『元数据』,比如函数的名称与地址。只读(r–)。

Mach-O Universal 文件

FAT 二进制文件,将余搭的
Mach-O 文件合并而成。它通过 Fat Header
来记录不同架构在文书中之偏移量,Fat Header 占一页的长空。

按分页来囤积这些 segement 和 header
会浪费空间,但马上便于虚拟内存的落实。

虚拟内存

虚拟内存就是千篇一律重合间接寻址(indirection)。软件工程被发生句格言就是别问题都能够通过丰富一个间接层来缓解。虚拟内存解决之是管理有进程使物理
RAM 的题目。通过补加间接层来被每个过程使逻辑地址空间,它可以投到 RAM
上之某部物理页上。这种映射不是一定底,逻辑地址可能映射不至 RAM
上,也或发生多只逻辑地址映射到同一个大体 RAM
上。针对第一栽情况,当进程要存储逻辑地址内容常常会见触发 page
fault;第二种状况就是是基本上进程共享内存。

对文本可以毫无一次性读入整个文件,可以下分页映射(mmap())的方式读取。也就是把文件之一片段映射到过程逻辑内存的某某页上。当有想只要读取的页没在内存中,就会见触发
page fault,内核只会念入那无异页,实现文件的懒加载。

也就是说 Mach-O
文件中之 __TEXT 段可以投到大半只经过,并得以懒加载,且经过中共享内存。__DATA 段是只是读写的。这里用到了
Copy-On-Write 技术,简称
COW。也尽管是基本上独过程共享一页内存空间时,一旦出经过而举行写操作,它见面预先以随即页内存内容复制一客出来,然后再次照射逻辑地址及新的
RAM 页上。也就是是此过程自己备了那么页内存的正片。这就是干到了
clean/dirty page 的定义。dirty page 含有进程自己之信息,而 clean page
可以给基本重新转(重新宣读磁盘)。所以 dirty page 的代价大于 clean
page。

Mach-O 镜像 加载

之所以在多个经过加载 Mach-O
镜像时 __TEXT 和 __LINKEDIT 因为只读,都是好共享内存的。而 __DATA 因为可读写,就会出
dirty page。当 dyld
执行完毕后,__LINKEDIT 就从未因此了,对应的内存页会给回收。

安全

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

代码签名:可能我们以为 Xcode 会把方方面面文件都做加密 hash
并为此做数字签名。其实为在运转时说明 Mach-O
文件之签名,并无是每次又读入整个文件,而是将每页内容都好成一个单身的加密散列值,并蕴藏于 __LINKEDIT 中。这使得文件每页的始末都能够及时被校验确并保管不叫篡改。

从 exec() 到 main()

exec() 是一个系调用。系统基本把用映射到新的地点空间,且每次开头位置还是擅自的(因为运用
ASLR)。并以开始位置到0x000000 这段范围之经过权限都记为不可读写不可实践。如果是
32 位进程,这个限制至少是 4KB;对于 64 位进程则最少是 4GB。NULL
指针引用和指针截断误差都是会见被她擒获。

dyld 加载 dylib 文件

Unix
的前二十年生舒服,因为那儿还从来不说明动态链接库。有了动态链接库后,一个用以加载链接库的拉程序让创造。在苹果的阳台里是 dyld,其他
Unix 系统也产生 ld.so。 当内核完成投进程的工作晚会用名字呢 dyld 的Mach-O
文件映射到过程面临的自由地址,它以 PC
寄存器设为 dyld 的地址并运行。dyld 于运用进程中运作的干活是加载应用靠的装有动态链接库,准备好运行所要的一切,它有着的权力和用相同。

下面的步子做了 dyld 的光阴线:

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

加载 Dylib

于主执行文书之 header 获取到用加载的所据动态库列表,而 header
早就给外核映射了。然后其需要找到每个
dylib,然后打开文件读取文件开始位置,确保其是 Mach-O
文件。接着会找到代码签名并将那个登记到根本。然后在 dylib 文件之每个
segment 上调用mmap()。应用所据的 dylib 文件或者会见重新因其他
dylib,所以 dyld 所待加载的凡动态库列表一个递归依赖之会师。一般下会加载
100 到 400 独 dylib 文件,但大部分都是系
dylib,它们会吃优先计算和缓存起来,加载速度挺快。

Fix-ups

在加载所有的动态链接库之后,它们只是处在相互独立的状态,需要拿其绑定起来,这就是
Fix-ups。代码签名使得我们不克改命令,那样就非可知于一个 dylib
的调用另一个 dylib。这时急需加博中接层。

当代 code-gen 被称动态 PIC(Position Independent
Code),意味着代码可以为加载到间接的地方及。当调用发生时,code-gen
实际上会在 __DATA 段中开创一个对于调用者的指针,然后加载指针并超越反过去。

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

Rebasing 和 Binding

Rebasing:在镜像内部调整指针的对
Binding:将指针指于镜像外部的内容

足由此命令行查看 rebase 和 bind 等信息:

1
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 实例不采用预绑定会发出五独由:

  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。

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 消耗。

Binding

Binding 是拍卖那些针对 dylib
外部的指针,它们其实吃标记(symbol)名称绑定,也即是个字符串。之前提到 __LINKEDIT 段中为蕴藏了用
bind 的指针,以及指针需要对的标记。dyld 需要找到 symbol
对应的实现,这要广大计,去符号表里查找。找到后会将内容存储到 __DATA 段中的不胜指针中。Binding
看起计算量比 Rebasing 更可怜,但实际上用的 I/O 操作非常少,因为之前
Rebasing 已经替 Binding 做过了。

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 必须是绝无仅有的。

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 图标到加载 App 闪屏之间会生出只卡通,我们想 App
启动速度比之动画更快。虽然不同装备上 App
启动速度不雷同,但启动时间最好控制在 400ms。需要专注的是启动时使超过
20s,系统会认为产生了死循环并杀掉 App 进程。当然启动日太好以 App
所支撑之最低配置设施也遵循。直到 applicationWillFinishLaunching 被调动,App
才起步了。

测启动时

Warm launch: App 和多少都在内存中
Cold launch: App 不在根本缓冲存储器中

冷启动(Cold
launch)耗时才是咱们需要测量的第一数据,为了规范测量冷启动耗时,测量前需要重新开设备。在 main() 方法执行前测量是老麻烦之,好当 dyld 提供了内建的测量方法:在
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%)

优化启动时间

足对 App 启动前之每个步骤C++进行相应的优化办事。

加载 Dylib

前面涉嫌了加载系统的 dylib 很快,因为起优化。但加载内嵌(embedded)的
dylib 文件充分占时间,所以尽量把多单内嵌 dylib
合并成为一个来加载,或者以 static
archive。使用 dlopen() 来在运转时懒加载是勿建议之,这么做或会见带有问题,并且总的支付更充分。

Rebase/Binding

事先提过 Rebaing 消耗了大量时间在 I/O 上,而于后头的 Binding
就小用 I/O
了,而是将日耗在测算上。所以马上点儿单步骤的耗时凡乱在同的。

前说过得自查看 __DATA 段中要更正(fix-up)的指针,所以减少指针数量才会缩减这有些干活的耗时。对于
ObjC
来说就是减 Class,selector 和 category 这些元数据的数额。从编码原则和设计模式之类的答辩都见面鼓励大家多写精致短小之近乎以及法,并拿各部分方法独立有一个路,其实这会加启动日。对于
C++ 来说要减小虚方法,因为虚方法会创建
vtable,这吗会见在__DATA 段中开创布局。虽然 C++
虚方法对启动耗时的加而比 ObjC 元数据要少,但依然不足忽略。最后推荐应用
Swift 结构体,它要 fix-up 的情比较少。

ObjC Setup

本着当下步所能工作特别少,几乎都因 Rebasing 和 Binding 步骤中减掉所急需 fix-up
内容。因为前面的工作吗会使这步耗时减少。

Initializer

显式初始化

  • 使用 +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() 开启了多线程,系统只能加锁,这即严重影响了性,还可能会见招致死锁以及生未知的产物。所以也不要以初始化器中创造线程。

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