一次启动优化之旅

Posted by wbq on June 15, 2020

导言

随着客户端业务越来越重,启动初始化代码越来越多,导致我们的APP启动时间越来越长。

而对于App来说用户体验却至关重要。

这里列举两个公开的数据:

1
2
3
《页面加载超过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基本结束了,这部分的源码都在dyldobjc-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虚拟内存是一个连续的地址空间(这也只是进程认为),而实际上,它通常是被分隔成多个物理内存碎片,还有一部分存储在外部磁盘存储器上,在需要时进行数据交换。

进程开始要访问一个地址,它可能会经历下面的过程

  1. 每次我要访问地址空间上的某一个地址,都需要把地址翻译为实际物理内存地址
  2. 所有进程共享这整一块物理内存,每个进程只把自己目前需要的虚拟地址空间映射到物理内存上
  3. 进程需要知道哪些地址空间上的数据在物理内存上,哪些不在(可能这部分存储在磁盘上),还有在物理内存上的哪里,这就需要通过页表来记录
  4. 页表的每一个表项分两部分,第一部分记录此页是否在物理内存上,第二部分记录物理内存页的地址(如果在的话)
  5. 当进程访问某个虚拟地址的时候,就会先去看页表,如果发现对应的数据不在物理内存上,就会发生缺页异常,缺页异常的处理过程,操
  6. 作系统立即阻塞该进程,并将硬盘里对应的页换入内存,然后使该进程就绪,如果内存已经满了,没有空地方了,那就找一个页覆盖,至于具体覆盖的哪个页,就需要看操作系统的页面置换算法是怎么设计的了。

关于虚拟内存与物理内存的联系,下面这张图可以帮助我们巩固。

img

img

  1. 我们的cpu想访问虚拟地址所在的虚拟页(VP3),根据页表,找出页表中第三条的值.判断有效位。 如果有效位为1,DRMA缓存命中,根据物理页号,找到物理页当中的内容,返回。
  2. 若有效位为0,参数缺页异常,调用内核缺页异常处理程序。内核通过页面置换算法选择一个页面作为被覆盖的页面,将该页的内容刷新到磁盘空间当中。然后把VP3映射的磁盘文件缓存到该物理页上面。然后页表中第三条,有效位变成1,第二部分存储上了可以对应物理内存页的地址的内容。
  3. 缺页异常处理完毕后,返回中断前的指令,重新执行,此时缓存命中,执行1。
  4. 将找到的内容映射到告诉缓存当中,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方法,而像+loadblock这些不走msg_send的都需要单独扫描和hook,该组合方案同时存在相应的问题。

基于静态扫描+运行时trace的方案仍然存在少量瓶颈:

  • initialize hook不到

  • 部分block hook不到

  • C++通过寄存器的间接函数调用静态扫描不出来

“目前的重排方案能够覆盖到80%~90%的符号,未来我们会尝试编译期插桩等方案来进行100%的符号覆盖,让重排达到最优效果”

他们同时给了方向,通过clang插桩方式进行方法扫描。

具体方法现在也有了:App 二进制文件重排已经被玩坏了

1
2
3
在 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会多一步把文件转成代码的过程。

最后

你知道的越多,你不知道的越多—–亚里士多德