导言
随着客户端业务越来越重,启动初始化代码越来越多,导致我们的APP启动时间越来越长。
而对于App来说用户体验却至关重要。
这里列举两个公开的数据:
《页面加载超过3秒,57%的用户会离开》
《Amazon页面加载延长1秒,一年就会减少16亿美金营收》
我在某天不经意间发现竞品们的打开时长时:
再相比我们的APP启动时长就有点慢了。
面对竞品怎么能在开机就输呢。
经过一番努力之后,我将启动时长优化了将近50%,先来看看效果:
总的来说效果还是挺明显的。
本文会记录这一趟优化之旅的全过程,并且附上优化原理。
我们都知道iOS在启动过程中做了非常多的事情,只是因为在硬件日新月异的今天,这种感觉和差异很难被感知到。
APP启动过程的优化大致就是以下两步:
- main函数之前
- main函数之后
接下来让我们分步来看。
Pre Main
main函数之前的东西看似离我们很远,感觉好像平时接触不到,但其实我们写的每一行代码都可能影响到main函数之前发生的事情。
先简单来看下,iOS在main函数前做了些什么:
操作系统通过
dyld
(dynamic link editor
, Apple的动态链接器,用来装载Mach-O
格式的文件,二进制可执行文件和动态库灯饰该格式文件)加载共享缓存,将可执行文件加载进内存,同时递归加载所有依赖的动态库。-
之后每个动态库执行一些初始化方法
doInitialization
,最先执行的是libsystem.B.dylib
中的_objc_init
,而这个方法,阅读过objc-runtime
源码的小伙伴一定很熟悉:void _objc_init(void) { static bool initialized = false; if (initialized) return; initialized = true; // fixme defer initialization until an objc-using image is found? environ_init(); tls_init(); static_init(); lock_init(); exception_init(); _dyld_objc_notify_register(&map_images, load_images, unmap_image); }
这里比较特殊所以单独拿出来讲,这里会通过
_dyld_objc_notify_register
方法注册回调。 -
当所有的初始化方法执行完毕之后,会通知
notifySingle
回调到刚才说的注册方法 通知runtime进行下一步的操作,对
map_images
进行可执行文件的内容解析和处理,如合并category
的方法列表、协议列表等等。拿到所有类与分类的
+(void)load
方法地址进行调用。进行各种objc结构的初始化(组册类、初始化类对象等等)
调用
C++
静态初始化器和_attribute((constructor))
修饰的函数
至此,经过以上大致的操作,Per main
基本结束了,这部分的源码都在dyld
和objc-runtime
的源码都有体现。有需要可以去官方下载看。
知道以上这些基本原理可以开始动手了。
通过添加环境变量DYLD_PRINT_STATISTICS
可以获取启动时长的一些基本信息
大概耗时1.3s
,可以看到时长耗时主要是在上面说的dylib loading time
动态库链接(593.39ms
)和initializer
(690.05ms
)的调用+(void)load
方法时长(C++静态初始化器和_attribute((constructor))函数基本可以忽略不计,因为基本不用)。
Per Main 1.1 (去掉或合并多余category
与+(void)load
方法)
这个方法做起来可以很简单也可以也难,因为当业务一多,进行批量的去除与合并会有很大困难。你也可以使用工具或者脚本找到那些方法进行分析排查。不过对我来说还好,因为我找到了一个特大病号,QMUIKit
,一个腾讯的UI组件。
一张图的分类只不过是冰山一角,该库不仅类的数量巨大,大量使用category
,并且在load
使用非常多的方法交换,不否认该库的强大,当时为了省力自己写组件,觉得好用就引进来了,但是回头想想,这个库设计覆盖的功能范围过大,导致我可能就用到了其中10%到20%的功能。最后我决定删除该库。把用到的category
做了一些整理与合并(合并到4-5个左右):
虽说是体力活但工作却持续了好几天,因为该库代码倾入性还是挺强的,会动到很多原来的结构。不过删掉还是挺爽的,不仅包体积小了,启动时长还瞬间快了。这个故事告诉我们技术选型的时候还是得慎重,不然填起坑来很苦。
立杆见影,马上快了300ms+的速度(liblinterpose.dylib
是调试过程中才插入的动态库,生产中不会有,这里的371ms可以忽略)。
Per Main 1.2 (动态库转静态库)
接下来处理动态库加载时长,这里最好的办法就是把动态库都删除(好像等于没说),苹果推荐是一款应用不超过6个动态库
。
而我...
加上swift
(因为oc、swift混编)的动态库 有40个...那么就把动态库转静态库,网上关于动态库和静态库的区别文章有很多,随便看看就知道大概的区别,大家知道我们自己的framework
,其实不能称之真正意义上的动态库,虽然是通过dyld
链接加载,但他做不到共享。当然有些企业应用会通过增量下载动态库来达到热更新的目的,但上架应用是不被允许的。不过pod
很方便已经给我们提供了相关置:use_frameworks!
,注释掉该配置,打包默认就是静态库。
但是这里面也是有坑的,首先因为静态库是编译时就一起编译进执行文件中,不像动态库一样外部链接,所以如果有重名符号会报符号冲突。这样的话就要用到修改pod脚本配置进行选择性的部分静态库化,网上也有相关方法。我运气很好,没有重名,所以这步省了,很舒服。
然后可以通过一些pod配置进行测试和正式环境的区分,把测试环境和debug环境需要的库在生产中进行隔离,pod也是支持分target配置的,类似这样:
再来看看效果
可以看到dylib loading time
瞬间降低了很多,总的时间也从1.3
降到了0.87
左右。
Per Main 1.3(二进制重排)
这一切都要归功于19年8月字节跳动的一篇文章 抖音研发实践:基于二进制文件重排的解决方案 APP启动速度提升超15%,让这项古老的技术再次火了一把,这项技术是基于操作系统的,所以适用于任何基于虚拟内存的系统,不管android或是iOS。
原理的话我觉得这篇讲的挺好的:虚拟内存与物理内存的联系与区别,我这里简单总结下,还不懂可以再网上冲浪一下。
物理内存
很早的时候操作系统没有虚拟内存的概念,都是物理内存,程序能寻址的范围是有限的,这取决于CPU的地址线条数。比如在32位平台下,寻址的范围是2^32也就是4G。并且这是固定的。简单来说,你的电脑如果是8g内存,开两个应用就把整根内存条占满了。当一个进程执行完了以后,再将等待的进程装入内存。
由于指令都是直接访问物理内存的,那么我可以根据地址的偏移修改其他进程的数据,甚至会修改内核地址空间的数据,这是很致命的。
虚拟内存
基于这一点,有了虚拟内存的概念。
假设每个应用从硬盘加载进内存还是可以分配到4G的内存。但是是虚拟内存,你可以认为,每个进程都认为自己拥有4G的空间,这只是每个进程认为的,但是实际上,在虚拟内存对应的物理内存上,可能只对应的一点点的物理内存,实际用了多少内存,就会对应多少物理内存。进程得到的这4G虚拟内存是一个连续的地址空间(这也只是进程认为),而实际上,它通常是被分隔成多个物理内存碎片,还有一部分存储在外部磁盘存储器上,在需要时进行数据交换。
进程开始要访问一个地址,它可能会经历下面的过程
- 每次我要访问地址空间上的某一个地址,都需要把地址翻译为实际物理内存地址
- 所有进程共享这整一块物理内存,每个进程只把自己目前需要的虚拟地址空间映射到物理内存上
- 进程需要知道哪些地址空间上的数据在物理内存上,哪些不在(可能这部分存储在磁盘上),还有在物理内存上的哪里,这就需要通过页表来记录
- 页表的每一个表项分两部分,第一部分记录此页是否在物理内存上,第二部分记录物理内存页的地址(如果在的话)
- 当进程访问某个虚拟地址的时候,就会先去看页表,如果发现对应的数据不在物理内存上,就会发生缺页异常,缺页异常的处理过程,操
- 作系统立即阻塞该进程,并将硬盘里对应的页换入内存,然后使该进程就绪,如果内存已经满了,没有空地方了,那就找一个页覆盖,至于具体覆盖的哪个页,就需要看操作系统的页面置换算法是怎么设计的了。
关于虚拟内存与物理内存的联系,下面这张图可以帮助我们巩固。
- 我们的cpu想访问虚拟地址所在的虚拟页(VP3),根据页表,找出页表中第三条的值.判断有效位。 如果有效位为1,DRMA缓存命中,根据物理页号,找到物理页当中的内容,返回。
- 若有效位为0,参数缺页异常,调用内核缺页异常处理程序。内核通过页面置换算法选择一个页面作为被覆盖的页面,将该页的内容刷新到磁盘空间当中。然后把VP3映射的磁盘文件缓存到该物理页上面。然后页表中第三条,有效位变成1,第二部分存储上了可以对应物理内存页的地址的内容。
- 缺页异常处理完毕后,返回中断前的指令,重新执行,此时缓存命中,执行1。
- 将找到的内容映射到告诉缓存当中,CPU从告诉缓存中获取该值,结束。
这就是为什么我们手机无论起多少的应用,内存都不会爆,因为只是他的物理内存在不断覆盖。
说白了,有了虚拟内存之后,就有了中间表进行物理地址的映射,应用不再一次加载所有内容到内存,而是进行懒加载的模式,iOS的一页是16k大小,一次缺页异常(page fault)一般持续时间在微秒(us)到毫秒(ms)之间,APP操作过程中发生那么几次人为基本感知不到。
但启动时会瞬间调用很多方法和对象的创建,而此时映射表有效位全是0
,所以这个异常次数可能会变得很多,并且上架的应用在映射时还会验签,导致这个时间会更长。
说了这么多,再来看看数据就非常明显了
第一次冷启动我们自己应用,通过工具发现我们的应用启动在阶段,缺页异常(page fault)
一共发生了2160
次,耗时386.20ms
。
而当我们杀掉应用进行第二次的热启动
page fault
只有33
次,而缓存击中却有2767
次,耗时5.54ms
。这就证明了我们平时的感觉,第一次冷启动应用时会较慢。杀掉应用,第二次热启动就快很多。这么看起来iOS的物理内存好像不会在杀掉内存之后立马清空。
好了知道了原理,那我们怎么优化呢,一句话:
找到所有的的启动方法尽量往前几页挤
来触发更少次数的page fault
。
那么怎么找到所有的启动方法呢?,字节跳动给了相应的方法方法:基于fishhook去hook msg_send
(oc所有方法底层都是调用该方法)获取所有oc方法,而像+load
、block
这些不走msg_send
的都需要单独扫描和hook,该组合方案同时存在相应的问题。
基于静态扫描+运行时trace的方案仍然存在少量瓶颈:
initialize hook不到
部分block hook不到
C++通过寄存器的间接函数调用静态扫描不出来
"目前的重排方案能够覆盖到80%~90%的符号,未来我们会尝试编译期插桩等方案来进行100%的符号覆盖,让重排达到最优效果"
他们同时给了方向,通过clang插桩方式进行方法扫描。
具体方法现在也有了:App 二进制文件重排已经被玩坏了
在 Clang 10 documentation 中可以看到 LLVM 官方对 SanitizerCoverage 的详细介绍,包含了示例代码。
简单来说 SanitizerCoverage 是 Clang 内置的一个代码覆盖工具。它把一系列以 __sanitizer_cov_trace_pc_ 为前缀的函数调用插入到用户定义的函数里,借此实现了全局 AOP 的大杀器。其覆盖之广,包含 Swift/Objective-C/C/C++ 等语言,Method/Function/Block 全支持。
说实话第一次看到这哥们儿的言论的时候 我还是挺忐忑的。
生成的order
文件长这样:
目录自己选择一下,在xcode中配置一下就好了
来看下启动效果,还是很明显的:
page fault
次数降到了326
次,比之前降低耗时300ms
左右。
Per Main 1.4(总结)
至此,Per Main
阶段我们基本已经做完一轮的优化,来看看最后的耗时结果
0.74s
,比起之前的1.3s
效果还是很明显的。
After Main
main函数之后的事情,就是我们最熟悉的启动方法
- (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions{}
开始到首页加载完毕的时长,因为这部分我们可以自由操作,随便找个打点工具就可以统计下时长。
这部分就比较见仁见智了。网上也有很多关于这方面的优化,简单说说我自己的一些优化操作吧。
广告的加载尽量走缓存,不依赖网络请求。
首页的初始化我会放在广告页显示之后,而不是和广告页一起加载。(这样可以加速广告页的显示)
所有的不必要的初始化我会放在首页的
viewDidAppear
,再通过gcd
保证只初始化一次。可以看到图中第一项的将近1秒的都是统计
SDK
的初始化时间,尽量放在子线程进行初始化(确保可以放在子线程),亲测也是有效的。剩下就是按照各自的业务逻辑进行修改,总之如果是为了快速启动,尽量把初始化的方法往后挪。
以上就是我本次启动优化的全过程。迁出的这个分支来来回回改了大半个月,效果还是显著的。
每家公司的APP情况都不太一样,希望我的经历可以帮到你。
后续:假如你的工程用的是SB或者Xib,转换成代码的形式会使启动更多,因为SB和Xib会多一步把文件转成代码的过程。
最后
你知道的越多,你不知道的越多-----亚里士多德