一次小遛共享的启动优化之旅

导言

随着客户端业务越来越重,启动初始化代码越来越多,导致我们的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基本结束了,这部分的源码都在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 二进制文件重排已经被玩坏了

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

最后

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

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 196,200评论 5 462
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 82,526评论 2 373
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 143,321评论 0 325
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 52,601评论 1 267
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 61,446评论 5 358
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 46,345评论 1 273
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 36,753评论 3 387
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 35,405评论 0 255
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 39,712评论 1 294
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 34,743评论 2 314
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 36,529评论 1 326
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 32,369评论 3 315
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 37,770评论 3 300
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,026评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 30,301评论 1 251
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 41,732评论 2 342
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 40,927评论 2 336