通过阅读这篇文章,我们将了解APP启动过程中都做了哪些事情。文章分为三部分,第一部分是原理讲解,第二部分是优化方案,第三部分是实践应用。
第一部分 原理讲解
APP启动的分类
APP分为冷启动和热启动。
冷启动是指, App 点击启动前,它的进程不在系统里,需要系统新创建一个进程分配给它启动的情况。这是一次完整的启动过程。
热启动,对于热启动的理解存在分歧,一种是是指户点击home键后再次回到前台显示,还原到退出前的状态,继续为用户服务;另一种是用户杀掉APP进程后在dyld没有删除缓存的情况下重启APP,这种情况下的启动速度会比冷启动要快一些。
对于热启动的分歧,笔者觉得从优化角度来说,第二种根据有研究的意义和符合实际使用情况。对比两个启动方式对用户的影响,冷启动的快慢会给用户造成第一印象的好坏,影响用户的使用体验和留存。接下来我们深入研究下APP冷启动过程。
从点击到启动
当用户点击手机桌面上的图标到首页展示到用户面前并且可以进行交互,这个过程我们定义为APP启动的一个完整过程。这个过程中发生了很多事情。系统先读取App的可执行文件(Mach-O文件),从里面获得dyld的路径,然后加载dyld,dyld去初始化运行环境,开启缓存策略,加载程序相关依赖库(其中也包含我们的可执行文件),并对这些库进行链接,最后调用每个依赖库的初始化方法,在这一步,runtime被初始化。当所有依赖库的初始化后,轮到最后一位(程序可执行文件)进行初始化,在这时runtime会对项目中所有类进行类结构初始化,然后调用所有的load方法。最后dyld返回main函数地址,main函数被调用,我们便来到了熟悉的程序入口。
我们将以上阶段分为Ready、Pre-Main、Main三个阶段。
Ready阶段
iPhone/iPad的桌面系统也是一个应用,我们称之为Springboard,当用户触碰屏幕点击APP的icon时,XNU加载Mach-O和dyld,然后系统会从XNU内核态将控制权转移到dyld用户态。dyld会负责后续工作,这时第一个阶段完成。
为了精简文章体积和侧重本文重点,我们简单了解几个概念:Mach-O 、dyld ,有兴趣的同学可以自行深入学习和研究。
Mach-O
Mach-O是Mach object的缩写,是Mac\iOS上用于存储程序、库的标准格式。
Mach-O有以下几种类型,其实动态库、静态库、程序本身、bundle文件其实都是属于Mach-O文件。
既然是文件,就会有固定的存储格式:Header、Load Commands、Raw Data三大部分。
Header:保存了一些基本信息,包括了该文件运行的平台、文件类型、LoadCommands的个数等等。
LoadCommands:可以理解为加载命令,在加载Mach-O文件时会使用这里的数据来确定内存的分布以及相关的加载命令。比如我们的main函数的加载地址,程序所需的dyld的文件路径,以及相关依赖库的文件路径。
Raw Data: 这里包含了具体的代码、数据等等。
dyld
dyld是动态链接器。dyld从可执行文件的依赖开始, 递归加载所有的依赖动态链接库。系统内核在加载动态库前,会加载dyld,然后调用去执行__dyld_start(),该函数会执行dyldbootstrap::start(),后者会执行_main()函数,dyld的加载动态库的代码就是从_main()开始执行的。
dyld存放位置目前版本存放的路径是/usr/lib/dyld。系统会解析Mach-O文件中的Load Commands段的LC_LOAD_DYLINKER,获取到dyld的路径(如下图)。
根据这一阶段的具体工作,我们没有优化空间,准备工作完全有系统来处理。
pre-main阶段
当dyld执行到_main()函数时,它将加载程序所需要的动态库,对其进行rebase以及bind操作,然后运行初始化函数,执行程序的main函数。
我们将以上分为Load dylibs image、Rebase image、Bind image、Objc、setup initializers五个阶段,接下来我们看一下每个阶段都做了什么工作。
-
Load dylibs image阶段
将Mach-O中的代码段和数据段加载到虚拟内存后,dyld从主执行文件的 header 获取到需要加载的所依赖动态库列表,找到对应的dylib后确保它是Mach-O文件,接着找到代码签名并将其注册到内核中。应用所依赖的 dylib 文件可能会再依赖其他 dylib,所以 dyld 所需要加载的是动态库列表一个递归依赖的集合。一般应用会加载 100 到 400 个 dylib 文件,但大部分都是系统 dylib,它们会被预先计算和缓存起来,加载速度很快。(下图是一个APP的加载动态库的情况,在lldb下通过image命令查看)
动态库的加载分为两种类型,一种是系统动态库,一种是自定义的动态库。针对系统动态库,苹果帮我们做了很好的优化,从iOS3.1开始,为了提高性能,绝大部分的系统动态库文件都打包存放到了一个缓存文件中,那就是动态共享缓存dsc(dyld shared cache),dsc存在的位置在/System/Library/Caches/com.apple.dyld/dyld_shared_cache_armX,X代表的是ARM处理器指令集架构,APP启动前会通过dyld在des中查找然后递归加载所有依赖的动态库到内存中。(dyld源码下载地址https://opensource.apple.com/tarballs/dyld/)
对于自定义动态库的使用,我们有很大的优化空间,减少自定义动态库的使用数量,体积和引用关系,都有助于缩短这个阶段的耗时。
-
Rebase image阶段
为了防止程序被轻易的恶意篡改,iOS4.3后苹果提出了ASLR的方案,每个程序有自己的虚拟内存,所有的虚拟内存都是从0x00000000开始的,使用ASLR后,程序加载VM Address会有一个偏移量,这个偏移量是每次首次加载程序时随机产生,然后在这个偏移量的基础上继续加载Pagezero区域(0x100000000),之后的内容就是顺序有序加载。那么这个过程就是为了针对mach-o在加载到内存中不是固定的首地址(ASLR)这一现象做数据修正的过程;由于ASLR(address space layout randomization)的存在,可执行文件和动态链接库在虚拟内存中的加载地址每次启动都不固定,所以需要这2步来修复镜像中的资源指针,来指向正确的地址。 rebase修复的是指向当前镜像内部的资源指针; 而bind指向的是镜像外部的资源指针。
-
Bind image
将指针指向镜像外部的内容,binding就是将这个二进制调用的外部符号进行绑定的过程。比如我们objc代码中需要使用到NSObject, 即符号OBJC_CLASS$_NSObject,但是这个符号又不在我们的二进制中,在系统库 Foundation.framework 中,因此就需要binding这个操作将对应关系绑定到一起;Binding 看起来计算量比 Rebasing 更大,但其实需要的 I/O 操作很少,Binding的时间主要是耗费在计算上,因为IO操作之前 Rebasing 已经替 Binding 做过了,所以这两个步骤的耗时是混在一起的。
可以从查看 __DATA 段中需要修正(fix-up)的指针,所以减少指针数量才会减少这部分工作的耗时。对于 ObjC 来说就是减少 Class,selector 和 category 这些元数据的数量。从编码原则和设计模式之类的理论都会鼓励大家多写精致短小的类和方法,并将每部分方法独立出一个类别,其实这会增加启动时间。
Objective-C 中有很多数据结构都是靠 Rebasing 和 Binding 来修正(fix-up)的,比如 Class 中指向父类的指针和指向方法的指针。Rebase&&Binding该阶段的优化关键在于减少__DATA segment中的指针数量。
-
Objc setup
在这一阶段,dyld会读取二进制文件的DATA段内容,找到与objc相关的信息;注册 Objc 类,ObjC Runtime 需要维护一张映射类名与类的全局表。当加载一个 dylib 时,其定义的所有的类都需要被注册到这个全局表中;读取 protocol 以及 category 的信息,把category的定义插入方法列表 (category registration),确保 selector 的唯一性。
对于这一阶段,我们能做的基本没有,完全依赖上一步的优化而减少耗时。
-
initializers
虚拟内存动态库后边存放的是堆区和栈区。这一阶段的工作就是在这两个区域展开写入。具体内容是dyld开始将程序二进制文件初始化;交由ImageLoader读取image,其中包含了我们的类、方法等各种符号,由于runtime向dyld绑定了回调,当image加载到内存后,dyld会通知runtime进行处理;runtime接手后调用mapimages做解析和处理,接下来loadimages中调用 callloadmethods方法,遍历所有加载进来的Class,按继承层级依次调用Class的+load方法和其 Category的+load方法。执行完后便会进入main()阶段。
针对这一阶段的优化方案也是比较明显。就是针对+load进行处理,网上比较统一的答案是使用+initialize来替代+load的使用。这个方案的使用当然是可以的,但是要注意逻辑的调用时机是否复合你的研发需求,还有就是需要注意做一些代码执行次数的保护。
main阶段
main() 函数执行的阶段,指的是从 main() 函数执行开始,到 appDelegate 的 didFinishLaunchingWithOptions 方法里首屏渲染相关方法执行完成。
执行到这一阶段说明dyld已经进入了程序的main()函数入口,UIApplicationMain回调代理方法didFinishLaunchingWithOptions。这里没有需要优化的地方,我们要做的就是处理didFinishLaunchingWithOptions方法执行开始到结束的这段时间的工作。
开发者会把各种初始化工作都放到这个阶段执行,导致渲染完成滞后。更加优化的开发方式,应该是从功能上梳理出哪些是首屏渲染必要的初始化功能,哪些是 App 启动必要的初始化功能,而哪些是只需要在对应功能开始使用时才需要初始化的。梳理完之后,将这些初始化功能分别放到合适的阶段进行。
二、优化方案
针对pre-main和main两个阶段,我们可以总结出以下的方法:
- pre-main阶段
- 减少自定义动态库的依赖;
- 合并多个自定义动态库为一个动态库;
- 减少Objc类的数量,减少selector数量,删除无用类和函数(包括分类),如果有必要可以尝试合并一些类;
- 减少一些无用的静态变量;
- 减少C++虚函数的数量;
- 合理的+initializers替代+load的使用;
- 尽量不要用到C++的静态对象;
- 类名和方法名不宜过长,iOS每个类和方法名都在__cstring段里都存了相应的字符串值,所以类和方法名的长短也是对可执行文件大小是有影响的。
- main阶段
- 优化代码逻辑,去除一些非必要的逻辑和代码,减少每个流程所消耗的时间;
- 减少启动初始化流程,酌情将一些初始化工作延后;
- 使用多线程来处理初始化工作;
- 使用纯代码来构建tabbar或nav等视图,减少因为xib和storyboard解析成代码带来的消耗。
三、实践应用
以下测试数据均来自我司线下版,不代表线上产品性能。
pre-main阶段
main()之前的加载时间如何衡量,苹果提供了一种检测pre-main阶段的方法:DYLD_PRINT_STATISTICS = 1。
为了验证我们上文所说的信息,我们分别做了两次对比,
//冷启动-1
Total pre-main time: 3.3 seconds (100.0%)
dylib loading time: 273.38 milliseconds (8.1%)
rebase/binding time: 163.78 milliseconds (4.8%)
ObjC setup time: 363.07 milliseconds (10.8%)
initializer time: 2.5 seconds (76.0%)
slowest intializers :
libSystem.B.dylib : 19.49 milliseconds (0.5%)
libMainThreadChecker.dylib : 92.20 milliseconds (2.7%)
libglInterpose.dylib : 164.11 milliseconds (4.9%)
libMTLInterpose.dylib : 69.42 milliseconds (2.0%)
美术宝1对1线下版 : 2.4 seconds (72.1%)
//冷启动-2
Total pre-main time: 3.3 seconds (100.0%)
dylib loading time: 249.00 milliseconds (7.4%)
rebase/binding time: 167.79 milliseconds (4.9%)
ObjC setup time: 404.63 milliseconds (12.0%)
initializer time: 2.5 seconds (75.5%)
slowest intializers :
libSystem.B.dylib : 16.98 milliseconds (0.5%)
libMainThreadChecker.dylib : 88.08 milliseconds (2.6%)
libglInterpose.dylib : 173.89 milliseconds (5.1%)
libMTLInterpose.dylib : 70.84 milliseconds (2.1%)
美术宝1对1线下版 : 2.4 seconds (72.2%)
//热启动-1
Total pre-main time: 1.8 seconds (100.0%)
dylib loading time: 89.18 milliseconds (4.9%)
rebase/binding time: 168.99 milliseconds (9.3%)
ObjC setup time: 131.04 milliseconds (7.2%)
initializer time: 1.4 seconds (78.4%)
slowest intializers :
libSystem.B.dylib : 19.17 milliseconds (1.0%)
libMainThreadChecker.dylib : 58.62 milliseconds (3.2%)
libglInterpose.dylib : 161.28 milliseconds (8.9%)
libMTLInterpose.dylib : 42.63 milliseconds (2.3%)
ZegoLiveRoom : 39.11 milliseconds (2.1%)
美术宝1对1线下版 : 1.2 seconds (70.3%)
//热启动-2
Total pre-main time: 1.3 seconds (100.0%)
dylib loading time: 172.99 milliseconds (12.9%)
rebase/binding time: 40.79 milliseconds (3.0%)
ObjC setup time: 63.74 milliseconds (4.7%)
initializer time: 1.0 seconds (79.2%)
slowest intializers :
libSystem.B.dylib : 11.00 milliseconds (0.8%)
libMainThreadChecker.dylib : 41.56 milliseconds (3.1%)
libglInterpose.dylib : 99.05 milliseconds (7.3%)
libMTLInterpose.dylib : 49.45 milliseconds (3.6%)
美术宝1对1线下版 : 1.0 seconds (74.9%)
因为考虑到dyld缓存的原因,所以针对冷启动的测试,我会重启设备后进行打印,热启动的测试是在冷启动后我们杀掉应用后间隔几秒钟再次开启应用。测试结果也是比较明显和直观,我司APP冷启动时的pre-main阶段大约耗时3.3秒。这是个什么概念?老牛拉车。
以下是WWDC2016 Apple给出的建议
Apple suggest to aim for a total app launch time of under 400ms and you must do it in less than 20 seconds or the system will kill your app.
Apple建议应用的启动时间控制在400ms之下。并且必须在20s以内完成启动,否则系统则会kill掉应用程序。
从范围400ms到20s可以看出,我们优化的空间还蛮大,因为毕竟有个底线400ms在嘛。分析一下数据,可以看出前四个阶段的占比不高,24%大约就是790ms左右,占比最大的其实是第五个阶段(intializers阶段)。
先从前四个阶段入手,看一下目前APP启动时大约用到了多少动态库,
大约485个,其中有一些动态库是相同的地址,这个工程中的大部分哭都是来自des系统库,其中有两个目录下的文件是来自于我们自己的Mach-O,DoraemonLoadAnalyze和KSYMediaPlayer,第一个是滴滴的性能检测库,只有在线下版这种target下会进行打包,KSYMediaPlayer是用于视频播放的一个库,线上版和线下版都有存在,这个库是没有办法去掉。那么我们在同样的线下版环境下进行以下对比测试,去掉第一个动态库试一试。得到的结果不是很理想,这也在情理之中,结果还是3.3s左右。这个也是在情理之中的事情,四百多个库不会因为1两个库的额减少而出现明显变化。
接下来来处理下第五个阶段,lldb帮我们打印出了最慢的几个因素。
- libSystem.B.dylib
- libMainThreadChecker.dylib
- libglInterpose.dylib
- libMTLInterpose.dylib
- 美术宝1对1线下版
根据之前的学习,这个阶段的大部分内容是runtime在帮你处理初始化相关的工作。前四个因素我们没有优化的点,接着从美术宝1对1线下版这个Mach-O文件处理,对其中所有重写了+load方法的地方我们重新处理看看效果。
主程序中重写了+load的类大约有21个文件左右,将这些工作延后处理,得到以下测试数据:
//冷启动
Total pre-main time: 2.9 seconds (100.0%)
dylib loading time: 407.79 milliseconds (13.7%)
rebase/binding time: 142.19 milliseconds (4.7%)
ObjC setup time: 370.54 milliseconds (12.4%)
initializer time: 2.0 seconds (68.9%)
slowest intializers :
libSystem.B.dylib : 20.20 milliseconds (0.6%)
libMainThreadChecker.dylib : 85.84 milliseconds (2.8%)
libglInterpose.dylib : 153.29 milliseconds (5.1%)
libMTLInterpose.dylib : 73.55 milliseconds (2.4%)
美术宝1对1线下版 : 1.9 seconds (65.1%)
//热启动
Total pre-main time: 1.2 seconds (100.0%)
dylib loading time: 174.79 milliseconds (13.4%)
rebase/binding time: 38.48 milliseconds (2.9%)
ObjC setup time: 55.36 milliseconds (4.2%)
initializer time: 1.0 seconds (79.2%)
slowest intializers :
libSystem.B.dylib : 10.56 milliseconds (0.8%)
libMainThreadChecker.dylib : 41.27 milliseconds (3.1%)
libglInterpose.dylib : 111.59 milliseconds (8.6%)
libMTLInterpose.dylib : 32.40 milliseconds (2.5%)
美术宝1对1线下版 : 966.81 milliseconds (74.6%)
由于热启动的数据根据缓存的因素耗时上下浮动,但是冷启动的数据比较稳定,通过对比可以看出我们的优化还是很有效果的,从整体启动时间3.3s下降到了2.9s,其中主程MachO的耗时从2.4下降到了1.9s。到目前这里,我们还可以继续根据上面的方案继续优化pre-main这个阶段的内容,但是由于美术宝的项目过于庞大,加之组件化的加入,这里靠一人之力就不去尝试测试了。笔者猜测这部分虽然还有优化的空间,但是鉴于优化方案的操作是减少类名称和方法名称长度,从理论上来判断应该是通过减小体积来缩小IO时间,所以能继续缩短多少时间,笔者猜也未必会太多,但是从数据来看耗时较大的地方是主包,所以从C++虚函数和构造函数、静态变量、删除无用类来看的话,应该还是有很可观的优化空间的。
main阶段
这个阶段我们主要从didFinishLaunchingWithOptions方法中进行优化,首屏VC的初始化我们暂不考虑。代码打点工具有很多,我们这里使用BLStopwatch,使用方法比较简单,记录一个起始时间,再记录一个截止时间。我们先看一下优化前的数据:
//冷启动
15:45:33.171686+0800 美术宝1对1线下版[227:4773] 初始化耗时:3.158380s
//热启动
15:49:14.153892+0800 美术宝1对1线下版[237:6115] 初始化耗时:2.000703s
如果你是用户,新下载了APP,打开耗时3.3+3.1s,你会怎么想?
可能是我自己的手机该换了。
让我们来分析一下这个didFinishLaunchingWithOptions中到底做了什么事情,哪些事情是可以放到子线程中执行。
#1 神策完成: 0.753s
#2 防止崩溃完成: 1.112s
#3 bugly完成: 0.106s
#4 日志服务器hook完成: 0.020s
#5 httpDNS完成: 0.020s
#6 startLaunchInit完成: 0.649s
#7 开启APP额外初始化完成: 0.486s
#8 JPush初始化完成: 0.024s
#9 引导完成: 0.004s
首先看一下神策SDK的初始化,这个耗时相对真个阶段的整体耗时来说占了四分之一左右,笔者细细看了下神策开发文档,其中提示要求在主线程初始化,并且咨询了需求点是用来埋点统计使用,所以无法将其放到子线程或延时初始化,目前也只能放到这里暂不修改。
防崩溃初始化工作,这个地方耗时1.112s,其中主要工作是进行一些类的方法交换,时间全浪费在这个主线程处理一些与UI无关的工作上了,我么将其滞后处理,放到+initialize中处理。
关于bugly这部分的初始化工作,不知道会不会和神策的需求有冗余,如果有的话可以尝试去掉一个,bugly目前的耗时不是很多0.106s左右,看了bugly的文档没有提示说要在主线程,但是对于这种统计性的SDK,笔者认为还是放到这里不要动了,底层机制透明的,我们无法预料放到其他线程或延时初始化有没有影响。
日志服务器hook初始化和httpDNS的工作这里耗时0.02s+0.02s,苍蝇虽小仍是肉,也不能放过优化,看了内部实现,果断放到了子线程中处理。
startLaunchInit和引导和UI相关、极光推送的内容放主线程中暂不处理。
以下是修改后的数据:
#1 神策完成: 0.756
#2 防崩溃完成: 0.001
#3 bugly完成: 0.132
#4 日志服务器hook完成: 0.000
#5 httpDNS完成: 0.000
#6 startLaunchInit完成: 0.627
#7 初始化启动页面完成: 0.408
#8 JPush完成: 0.042
#9 引导获取完成: 0.021
//冷启动
16:45:33.171686+0800 美术宝1对1线下版[227:4773] 初始化耗时:1.958380s
到目前为止项目优化启动时间大约1.5s左右。启动优化除了受技术本身影响还和你的业务需求息息相关。这部分工作我们还有很大的提升空间,文章中如有问题欢迎校正。