一款 App 的启动速度,不单单是用户体验的事情,往往还决定了它能否获取更多的用户。这就好像陌生人第一次碰面,第一感觉往往决定了他们接下来是否会继续交往。
由此可见,启动速度的优化必然是 App 开发过程中不可或缺的一个环节。 那App 在启动时都做了哪些事儿?
App 启动时都干了些什么事儿?
一般而言,App 的启动时间,指的是从用户点击 App 开始,到用户看到第一个界面之间的时间。
根据Apple官方的《WWDC Optimizing App Startup Time》,iOS应用的启动可分为pre-main阶段和main两个阶段,所以
阶段 | pre-main | main |
---|---|---|
流程 | main()函数执行前,加载系统dylib(动态链接库)和自身App可执行文件的加载 | main方法执行之后,到AppDelegate类中的didFinishLaunchingWithOptions方法执行结束前这段时间,主要是构建第一个界面,并完成渲染展示 |
其中main耗时也被细化为两部分:
- main函数 到 首屏渲染耗时,详细点就是指的是从 main() 函数执行开始,到 appDelegate 的 didFinishLaunchingWithOptions 方法里首屏渲染相关方法执行完成
- 首屏渲染后的其他业务服务模块的初始化、监听的注册、配置文件的读取等,简单说的话,从首屏渲染完成时开始,到 didFinishLaunchingWithOptions 方法作用域结束时结束
在详细分析之前,先了解几个概念
Mach-O文件
Mach-O是OSX和iOS系统中的可执行文件的格式,主要包括以下几种文件类型:
-
Executable
:应用的主要二进制 -
Dylib
:动态链接库 -
Bundle
:不能被链接,只能在运行时使用dlopen加载 -
Image
:包含Executable、Dylib和Bundle -
Framework
:包含Dylib、资源文件和头文件的文件夹
Mach-O文件格式
Header:保存了一些基本信息,包括了该文件运行的平台、文件类型、LoadCommands的个数等等。
LoadCommands:可以理解为加载命令,在加载Mach-O文件时会使用这里的数据来确定内存的分布以及相关的加载命令。比如我们的main函数的加载地址,程序所需的dyld的文件路径,以及相关依赖库的文件路径。
Data: 这里包含了具体的代码、数据等等。
我们可以通过Mach-O文件查看器MachOView查看一个可执行文件内容:
Mach-O 被划分成一些 segement,每个 segement 又被划分成一些 section。segment 的名字都是大写的,且空间大小为页的整数。页的大小跟硬件有关,在 arm64 架构一页是 16KB,其余为 4KB。
section 虽然没有整数倍页大小的限制,但是 section 之间不会有重叠。几乎所有 Mach-O 都包含这三个段(segment):
- __TEXT 包含 Mach header,被执行的代码和只读常量(如C 字符串)。只读可执行(r-x)。
- __DATA 包含全局变量,静态变量等。可读写(rw-)。
- __LINKEDIT 包含了加载程序的『元数据』,比如函数的名称和地址。只读(r–)
系统使用动态链接有几点好处:
代码共用
:很多程序都动态链接了这些 lib,但它们在内存和磁盘中中只有一份。易于维护
:由于被依赖的 lib 是程序执行时才链接的,所以这些 lib 很容易做更新,比如libSystem.dylib 是 libSystem.B.dylib 的替身,哪天想升级直接换成libSystem.C.dylib 然后再替换替身就行了。减少可执行文件体积
:相比静态链接,动态链接在编译时不需要打进去,所以可执行文件的体积要小很多。
dyld简介
苹果系统中,需要几乎所有的程序都会用到动态库dylib,动态库不能直接运行,而是需要通过系统的动态链接器dyld
加载到内存后执行,动态链接器在系统中以一个用户态的可执行文件形式存在。
一般应用程序会在Mach-O文件部分指定一个LC_LOAD_DYLINKER的加载命令,此加载命令指定了dyld的路径,通常它的默认值是“/usr/lib/dyld”。系统内核在加载Mach-O文件时,会使用该路径指定的程序作为动态库的加载器来加载dylib。
系统先读取App的可执行文件(Mach-O文件),从里面获得dyld的路径,然后加载dyld
Dyld缓存
dyld加载时,为了优化程序启动,启用了技术。每一个进程都把这个共享缓存映射到了自己的地址空间中,之后,当任何Mach-O映像加载时,dyld首先会检查该Mach-O映像与所需的动态库是否在共享缓存中,如果存在,则直接将它在共享内存中的内存地址映射到进程的内存地址空间。在程序依赖的系统动态库很多的情况下,这种做法对程序启动性能是有明显提升的。
虚拟内存
- 虚拟内存是在物理内存上建立的一个逻辑地址空间,它向上(应用)提供了一个连续的逻辑地址空间,向下隐藏了物理内存的细节。
- 虚拟内存使得逻辑地址可以没有实际的物理地址,也可以让多个逻辑地址对应到一个物理地址。
- 虚拟内存被划分为一个个大小相同的Page(64位系统上是16KB),提高管理和读写的效率。 Page又分为只读和读写的Page。
虚拟内存是建立在物理内存和进程之间的中间层。在iOS上,当内存不足的时候,会尝试释放那些只读的Page,因为只读的Page在下次被访问的时候,可以再从磁盘读取。如果没有可用内存,会通知在后台的App(也就是在这个时候收到了memory warning),如果在这之后仍然没有可用内存,则会杀死在后台的App。
ARM处理器64bit的架构情况下,也就是0x000000000 - 0xFFFFFFFFF,每个16进制数是4位,即2的36次幂,就是64GB,即App最大的虚拟内存空间为64GB。
共享动态库其实就是共享的物理内存中的那份动态库,App虚拟内存中的共享动态库并未真实分配物理内存,使用时虚拟内存会访问同一份物理内存达到共享动态库的目的,iPhone7 PLUS(之前的产品最大为2GB)的物理内存RAM也只有3GB,那么超过3GB的物理内存如何处理呢,系统会使用一部分硬盘空间ROM来充当内存使用,在需要时进行数据交换,当然磁盘的数据交换是远远慢于物理内存的,这也是我们内存过载时,App卡顿的原因之一。
ASLR
全称是 Address space layout randomization, “地址空间布局随机化”。
传统方式下,进程每次启动采用的都是固定可预见的方式,这意味着一个给定的程序在给定的架构上初始虚拟内存都是基本一致的,程序每次都会加载在内存中相同的基地址,在进程运行的生命周期中的方法、变量等地址每次都一样,这使得程序很不安全。
采用ASLR,进程每次启动,地址空间都会被简单地随机化,但是只是偏移,不是搅乱。大体布局——程序文本、数据和库是一样的,但是具体的地址都不同了,可以阻挡黑客对地址的猜测 。
iOS应用启动流程
可执行文件的内核流程
如图,当启动一个应用程序时,系统最后会根据你的行为调用两个函数,fork
和execve
。fork功能创建一个进程;execve功能加载和运行程序。这里有多个不同的功能,比如execl,execv和exect,每个功能提供了不同传参和环境变量的方法到程序中。在OSX中,每个这些其他的exec路径最终调用了内核路径execve。
1、执行exec系统调用,一般都是这样,用fork()函数新建立一个进程,然后让进程去执行exec调用。我们知道,在fork()建立新进程之后,父进各与子进程共享代码段,但数据空间是分开的,但父进程会把自己数据空间的内容copy到子进程中去,还有上下文也会copy到子进程中去。
2、为了提高效率,采用一种写时copy的策略,即创建子进程的时候,并不copy父进程的地址空间,父子进程拥有共同的地址空间,只有当子进程需要写入数据时(如向缓冲区写入数据),这时候会复制地址空间,复制缓冲区到子进程中去。从而父子进程拥有独立的地址空间。而对于fork()之后执行exec后,这种策略能够很好的提高效率,如果一开始就copy,那么exec之后,子进程的数据会被放弃,被新的进程所代替
Apple官方给出的:
pre-main和main阶段
查看Pre-Main()阶段花费的时间
Xcode,Edit Scheme
->Run->Arguments->Environment Variables
添加环境变量DYLD_PRINT_STATISTICS
,设置值为1,App启动加载时Xcode的控制台就会有pre-main各个阶段的详细耗时输出。
注意:DYLD_PRINT_STATISTICS变量打印时间是iOS10以后才支持的功能,所以需要用iOS10系统及以上的机器来做测试
Pre-main阶段
Load Dylib
在每个动态库的加载过程中, dyld需要:
1. 分析所依赖的动态库
2. 找到动态库的mach-o文件
3. 打开文件
4. 验证文件
5. 在系统核心注册文件签名
6. 对动态库的每一个segment调用mmap()
从执行文件的 LC_LOAD_DYLIB 获取到需要加载的所依赖动态库列表,然后它需要找到每个 dylib,然后打开文件读取文件起始位置,确保它是 Mach-O 文件。接着会找到代码签名并将其注册到内核。然后在 dylib 文件的每个 segment 上调用 mmap()。
一个App通常需要加载很多个dylibs,dylib文件可能会再依赖其他 dylib,所以dyld所需要加载的是动态库列表一个递归依赖的集合
其中大部分都是系统 dylib,因为有优化(因为操作系统自己要用部分framework所以在操作系统开机后就已经加载到内存了),它们会被预先计算和缓存起来,加载速度很快。但加载内嵌(embedded)的 dylib 文件很占时间,所以尽可能把多个内嵌 dylib 合并成一个来加载,或者使用 static archive。使用 dlopen() 来在运行时懒加载是不建议的,这么做可能会带来一些问题,并且总的开销更大。
针对这一步骤的优化有:
① 减少非系统库的依赖;
② 使用静态库而不是动态库;(但会增大应用包体积)
③ 合并非系统动态库为一个动态库;
Rebase/Bind image
,针对mach-o在加载到内存中不是固定的首地址(ASLR)这一现象做数据修正的过程;
在iOS4.3前会把dylib加载到指定地址,所有指针和数据对于代码来说都是固定的,dyld 就无需做rebase/binding了。
iOS4.3后引入了 ASLR
,可执行文件和动态链接库在虚拟内存中的加载地址每次启动都不固定。dylib会被加载到随机地址,这个随机的地址跟代码和数据指向的旧地址会有偏差,dyld 需要修正这个偏差,做法就是将 dylib 内部的指针地址都加上这个偏移量,偏移量的计算方法如下:
Slide = actual_address - preferred_address
Rebase
rebase步骤先进行,需要把镜像读入内存,并以page为单位进行加密验证,保证不会被篡改,所以这一步的瓶颈在IO。
Bind image
,将指针指向镜像外部的内容,binding就是将这个二进制调用的外部符号进行绑定的过程。比如我们objc代码中需要使用到NSObject, 即符号OBJC_CLASS$_NSObject,但是这个符号又不在我们的二进制中,在系统库 Foundation.framework中,因此就需要binding这个操作将对应关系绑定到一起;
由于要查询符号表,来指向跨镜像的资源(根据字符串匹配的方式),加上在rebase阶段,镜像已被读入和加密验证,所以这一步的瓶颈在于CPU计算。
Binding 是处理那些指向 dylib 外部的指针,它们实际上被符号(symbol)名称绑定,也就是个字符串。__LINKEDIT段中也存储了需要 bind 的指针,以及指针需要指向的符号。dyld 需要找到 symbol 对应的实现,这需要很多计算,去符号表里查找。找到后会将内容存储到__DATA 段中的那个指针中。Binding 看起来计算量比 Rebasing 更大,但其实需要的 I/O 操作很少,Binding的时间主要是耗费在计算上,因为IO操作之前 Rebasing 已经替 Binding 做过了,所以这两个步骤的耗时是混在一起的。
可以从查看__DATA 段中需要修正(fix-up)的指针,所以减少指针数量才会减少这部分工作的耗时。对于 ObjC 来说就是减少 Class,selector 和 category 这些元数据的数量。从编码原则和设计模式之类的理论都会鼓励大家多写精致短小的类和方法,并将每部分方法独立出一个类别,其实这会增加启动时间。对于 C++ 来说需要减少虚方法,因为虚方法会创建 vtable,这也会在__DATA段中创建结构。虽然 C++ 虚方法对启动耗时的增加要比 ObjC 元数据要少,但依然不可忽视。最后推荐使用 Swift 结构体,它需要 fix-up 的内容较少。
Objective-C 中有很多数据结构都是靠 Rebasing 和 Binding 来修正(fix-up)的,比如 Class 中指向父类的指针和指向方法的指针。
针对这一步骤的优化有:
① 减少Objc类数量, 减少selector数量,把未使用的类和函数都可以删掉
② 减少C++虚函数数量
③ 转而使用swift stuct(其实本质上就是为了减少符号的数量,使用swift语言来开发?)
ObjC SetUp
Objc setup主要是在objc_init
完成的,objc_init是在libsystem中的一个initialize方法libsystem_initializer中初始化了libdispatch,然后libdispatch_init调用了_os_object_int, 最终调用了_objc_init
。
void _objc_init(void)
{
static bool initialized = false;
if (initialized) return;
initialized = true;
environ_init();
tls_init();
static_init();
lock_init();
exception_init();
_dyld_objc_notify_register(&map_2_images, load_images, unmap_image);
}
runtime在_objc_init向dyld绑定了3个回调函数,分别是map_2_images、load_images 和 unmap_image
- dyld在binding操作结束之后,会发出dyld_image_state_bound通知,然后与之绑定的回调函数map_2_images就会被调用,它主要做以下几件事来完成Objc Setup:
1) runtime运行时初始化
2) 读取二进制文件的 DATA 段内容,找到与 objc 相关的信息
3) 注册 Objc 类,ObjC Runtime 需要维护一张映射类名与类的全局表。当加载一个 dylib 时,其定义的所有的类都需要被注册到这个全局表中
4) 确保 selector 的唯一性
5) 读取 protocol 以及 category 的信息
- load_images函数监听dyld_image_state_dependents_initialize通知,作用是:
6) 调用Objc的load方法
- unmap_image可以理解为map_2_images的逆向操作
ObjC 是个动态语言,可以用类的名字来实例化一个类的对象。这意味着 ObjC Runtime 需要维护一张映射类名与类的全局表。当加载一个 dylib 时,其定义的所有的类都需要被注册到这个全局表中。
initializers
以上三步属于静态调整,都是在修改__DATA segment中的内容,而这里则开始动态调整,开始在堆和栈中写入内容。 工作主要有:
1、 Objc的+load()函数
2、 C++的构造函数属性函数 形如attribute((constructor)) void DoSomeInitializationWork()
3、 非基本类型的C++静态全局变量的创建(通常是类或结构体)(non-trivial initializer) 比如一个全局静态结构体的构建,如果在构造函数中有繁重的工作,那么会拖慢启动速度
Objc的load函数和C++的静态构造器采用由底向上的方式执行,来保证每个执行的方法,都可以找到所依赖的动态库
1、 dyld开始将程序二进制文件初始化
2、 交由ImageLoader读取image,其中包含了我们的类、方法等各种符号
3、 由于runtime向dyld绑定了回调,当image加载到内存后,dyld会通知runtime进行处理
4、 runtime接手后调用mapimages做解析和处理,接下来loadimages中调用 callloadmethods方法,遍历所有加载进来的Class,按继承层级依次调用Class的+load方法和其 Category的+load方法
引用博客解释下
静态构造器
:
constructor 和 +load 都是在 main 函数执行前调用,但 +load 比 constructor 更加早一丢丢,因为 dyld(动态链接器,程序的最初起点)在加载 image(可以理解成 Mach-O 文件)时会先通知 objc runtime 去加载其中所有的类,每加载一个类时,它的 +load 随之调用,全部加载完成后,dyld 才会调用这个 image 中所有的 constructor 方法。
所以 constructor 是一个干坏事的绝佳时机:
1、所有 Class 都已经加载完成
2、main 函数还未执行
3、无需像 +load 还得挂载在一个 Class 中
针对这一步骤的优化有:
① 减少Objc类数量, 减少selector数量,把未使用的类和函数都可以删掉,减少protocol数量
② +load方法中做的事情延迟到+initialize中,不要在+load中做需要花费过多时间的事情
检查未使用类、方法、静态变量的工具:
-
AppCode
,可以检查未使用类、方法、静态变量
-
Fui
地址,可以检查未使用的类
用法也相当简单,这里不做说明
“冷启动”与“热启动”
名称 | 定义 |
---|---|
冷启动 | 手机启动后或者相当长的时间间隔后,某个APP第一次启动 |
热启动 | 歧义1: 按下home键把APP挂到后台,之后点击APP再回来到前台 |
歧义2:短时间内第二次启动APP(杀掉进程重启) |
关于冷启动没有什么争议,但是对热启动有不同的看法
个人偏向于歧义2,经过实践:
短时间内第二次启动APP(杀掉进程重启)的时间存在差异:总消耗时间内减少
由此可以产生一个以问题
问题:手机从开机后,连续两次启动同一个APP的pre-main实际时间的差值比较大,这一步可以在真机上复现,那么这两次启动pre-main的时间差值,是跟系统的framework关系比较大,还是跟APP自身依赖的第三方framework关系比较大呢?
回答:操作系统对于动态库有一个共享的空间,在这个空间被填满,或者没有其他机制来清理这一块的内存之前,动态库被加载到内存后就一直存在。所以,问题中开机后连续启动同一个APP两次的pre-main时间的差值,可以认为是动态库被第一次加载后缓存到内存造成的,时间上也肯定是第二次比第一次快。比如有一些系统的动态库,操作系统还暂时没用到,但是你的APP用到了,在第一次启动APP就会加载到内存,第二次就直接拿内存里的。你自己APP用到的动态库也类似,只不过APP自己的动态库只能共享给自己的Extension,而不能给别的进程,进程有相互独立的地址空间,而且你的APP是用户态,而不是内核态,不能像系统的动态库那样被所有进程访问。详情见《现代操作系统》
main阶段
当所有的依赖库库的lnitializer都调用完后,dyld::main函数会返回程序的main函数地址,main函数被调用,从而代码来到了我们熟悉的程序入口。
int main(int argc, char * argv[]) {
@autoreleasepool {
return UIApplicationMain(argc, argv, nil, NSStringFromClass([AppDelegate class]));
}
}
main函数之后的流程如下:
总体原则无非就是减少启动的时候的步骤,以及每一步骤的时间消耗。
① 减少启动初始化的流程,能懒加载的就懒加载,能放后台初始化的就放后台,
能够延时初始化的就延时,不要卡主线程的启动时间,已经下线的业务直接删掉;
② 优化代码逻辑,去除一些非必要的逻辑和代码,减少每个流程所消耗的时间;
③ 启动阶段使用多线程来进行初始化,把CPU的性能尽量发挥出来;
④ 使用纯代码而不是xib或者storyboard来进行UI框架的搭建,尤其是主UI框架比如TabBarController这种,
尽量避免使用xib和storyboard,因为xib和storyboard也还是要解析成代码来渲染页面,多了一些步骤;
main函数执行之后:
main() 函数执行后的阶段,指的是从 main() 函数执行开始,到 appDelegate 的 didFinishLaunchingWithOptions 方法里首屏渲染相关方法执行完成。
首页的业务代码都是要在这个阶段,也就是首屏渲染前执行的,主要包括了:
- 首屏初始化所需配置文件的读写操作;
- 首屏列表大数据的读取;
- 首屏渲染的大量计算等。
更加优化的开发方式,应该是从功能上梳理出哪些是首屏渲染必要的初始化功能,哪些是 App 启动必要的初始化功能,而哪些是只需要在对应功能开始使用时才需要初始化的。梳理完之后,将这些初始化功能分别放到合适的阶段进行。
首屏渲染完成后
首屏渲染后的这个阶段,主要完成的是,非首屏其他业务服务模块的初始化、监听的注册、配置文件的读取等。从函数上来看,这个阶段指的就是截止到 didFinishLaunchingWithOptions 方法作用域内执行首屏渲染之后的所有方法执行完成。简单说的话,这个阶段就是从渲染完成时开始,到 didFinishLaunchingWithOptions 方法作用域结束时结束。这个阶段用户已经能够看到 App 的首页信息了,所以优化的优先级排在最后。但是,那些会卡住主线程的方法还是需要最优先处理的,不然还是会影响到用户后面的交互操作。