App 启动时间优化
引言:
一个项目做的时间长了,启动流程往往容易杂乱,库也用的越来越多,APP的启动时间也会慢慢变长。本次将针对iOS APP的启动时间优化一波。
通常针对一个技术点做优化的时候,都要先了解清楚这个技术点有哪些流程,优化的方向往往是减少流程的数量,以及减少每个流程的消耗。
本次优化从结果上来看,main阶段的优化效果最显著,尤其是启动时的一些IO操作处理,对启动时间的减少有很大作用。多线程启动的设计和验证最有意思,但是在实践上由于我们业务本身的原因,只开了额外一个子线程来并行启动,且仅在子线程做了少量的独立操作,主要还是我们的业务之间耦合性太强了,不太适合拆分到子线程。
一般说来,pre-main阶段的定义为APP开始启动到系统调用main函数这一段时间;main阶段则代表从main函数入口到主UI框架的viewDidAppear
函数调用的这一段时间。(本文后续main阶段的时间统计都用viewDidAppear
作为基准而非的applicationWillFinishLaunching
)
本文前半部分讲原理(内容基本是从网上借鉴/摘录),后半部分讲实践,pre-main阶段的原理比较难理解,不过实践倒是根据结论直接做就好了。
# 一. App启动过程
# 1. 解析Info.plist
- 加载相关信息,例如闪屏
- 沙箱建立、权限检查
# 2. Mach-O加载
- 如果是胖二进制文件,寻找合适当前CPU架构的部分
- 加载所有依赖的Mach-O文件(递归调用Mach-O加载的方法)
- 定位内部、外部指针引用,例如字符串、函数等
- 执行声明为
__attribute__((constructor))
的C函数 - 加载类扩展
(Category)
中的方法 - C++静态对象加载、调用ObjC的 +load 函数
# 3. 程序执行
- 调用
main()
- 调用
UIApplicationMain()
- 调用
applicationWillFinishLaunching
换成另一个说法就是:
App开始启动后,系统首先加载可执行文件(自身App的所有.o文件的集合),然后加载动态链接器dyld,dyld是一个专门用来加载动态链接库的库。 执行从dyld开始,dyld从可执行文件的依赖开始, 递归加载所有的依赖动态链接库。
动态链接库包括:iOS 中用到的所有系统 framework,加载OC runtime
方法的libobjc
,系统级别的libSystem
,例如libdispatch(GCD)
和libsystem_blocks (Block)
。
# 二. 可执行文件的内核流程
如图,当启动一个应用程序时,系统最后会根据你的行为调用两个函数,fork
和execve
。fork功能创建一个进程;execve
功能加载和运行程序。这里有多个不同的功能,比如execl
,execv
和exect
,每个功能提供了不同传参和环境变量的方法到程序中。在OSX中,每个这些其他的exec
路径最终调用了内核路径execve
。
执行
exec
系统调用,一般都是这样,用fork()
函数新建立一个进程,然后让进程去执行exec
调用。我们知道,在fork()
建立新进程之后,父进程与子进程共享代码段(TEXT),但数据空间(DATA)是分开的,但父进程会把自己数据空间的内容copy
到子进程中去,还有上下文也会copy
到子进程中去。为了提高效率,采用一种写时copy的策略,即创建子进程的时候,并不copy父进程的地址空间,父子进程拥有共同的地址空间,只有当子进程需要写入数据时(如向缓冲区写入数据),这时候会复制地址空间,复制缓冲区到子进程中去。从而父子进程拥有独立的地址空间。而对于
fork()
之后执行exec后,这种策略能够很好的提高效率,如果一开始就copy,那么exec
之后,子进程(确定不是父进程?)的数据会被放弃,被新的进程所代替。
# 三. pre-main和main阶段原理浅析
rebase
修复的是指向当前镜像内部的资源指针; 而bind指向的是镜像外部的资源指针。
rebase
步骤先进行,需要把镜像读入内存,并以page为单位进行加密验证,保证不会被篡改,所以这一步的瓶颈在IO。bind在其后进行,由于要查询符号表,来指向跨镜像的资源,加上在rebase
阶段,镜像已被读入和加密验证,所以这一步的瓶颈在于CPU计算。这两个步骤在下面会详细阐述。
# 1. pre-main过程
# 2. main过程
# 四. 一些概念
# 1. 什么是dyld?
动态链接库的加载过程主要由dyld来完成,dyld是苹果的动态链接器。
系统先读取App的可执行文件(Mach-O文件),从里面获得dyld的路径,然后加载dyld,dyld去初始化运行环境,开启缓存策略,加载程序相关依赖库(其中也包含我们的可执行文件),并对这些库进行链接,最后调用每个依赖库的初始化方法,在这一步,runtime被初始化。 当所有依赖库的初始化后,轮到最后一位(程序可执行文件)进行初始化,在这时runtime会对项目中所有类进行类结构初始化,然后调用所有的load方法。最后dyld返回main函数地址,main函数被调用,我们便来到了熟悉的程序入口。
当加载一个Mach-O
文件 (一个可执行文件或者一个库) 时,动态链接器首先会检查共享缓存看看是否存在其中,如果存在,那么就直接从共享缓存中拿出来使用。每一个进程都把这个共享缓存映射到了自己的地址空间中。这个方法大大优化了 OS X 和 iOS 上程序的启动时间。
问题:测试发现,由于手机从开机后,连续两次启动同一个APP的pre-main实际时间的差值比较大,这一步可以在真机上复现,那么这两次启动pre-main的时间差值,是跟系统的framework关系比较大,还是跟APP自身依赖的第三方framework关系比较大呢?
# 2. 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–)。
ASLR(Address Space Layout Randomization):地址空间布局随机化,镜像会在随机的地址上加载。
传统方式下,进程每次启动采用的都是固定可预见的方式,这意味着一个给定的程序在给定的架构上的进程初始虚拟内存都是基本一致的,而且在进程正常运行的生命周期中,内存中的地址分布具有非常强的可预测性,这给了黑客很大的施展空间(代码注入,重写内存);
如果采用ASLR,进程每次启动,地址空间都会被简单地随机化,但是只是偏移,不是搅乱。大体布局——程序文本、数据和库是一样的,但是具体的地址都不同了,可以阻挡黑客对地址的猜测 。
代码签名:可能我们认为 Xcode 会把整个文件都做加密 hash 并用做数字签名。其实为了在运行时验证 Mach-O 文件的签名,并不是每次重复读入整个文件,而是把每页内容都生成一个单独的加密散列值,并存储在 __LINKEDIT
中。这使得文件每页的内容都能及时被校验确并保不被篡改。
# 3. 关于虚拟内存
我们开发者开发过程中所接触到的内存均为虚拟内存,虚拟内存使App认为它拥有连续的可用的内存(一个连续完整的地址空间),这是系统给我们的馈赠,而实际上,它通常是分布在多个物理内存碎片,系统的虚拟内存空间映射vm_map负责虚拟内存和物理内存的映射关系。
ARM处理器64bit的架构情况下,也就是0x000000000 - 0xFFFFFFFFF
,每个16进制数是4位,即2的36次幂,就是64GB,即App最大的虚拟内存空间为64GB。
共享动态库其实就是共享的物理内存中的那份动态库,App虚拟内存中的共享动态库并未真实分配物理内存,使用时虚拟内存会访问同一份物理内存达到共享动态库的目的。 iPhone7 PLUS(之前的产品最大为2GB)的物理内存RAM也只有3GB,那么超过3GB的物理内存如何处理呢,系统会使用一部分硬盘空间ROM来充当内存使用,在需要时进行数据交换,当然磁盘的数据交换是远远慢于物理内存的,这也是我们内存过载时,App卡顿的原因之一。