导语
一直以来看过很多关于启动优化的博客和文章,自以为收获很多。但是真正去做这件事的时候才会发现实际情况和文章中的有很大差别。
- 很多博客都是通过
Xcode
自带的工具instrument
来分析问题并查找到性能瓶颈点。但是实际操作中,自己并不能很好的运用,且各项数据指标看不太懂。 - 大多数博客中所谓的优化点,真正实际去做的时候发现并没有太大的作用。
通过这次实际操作,体会到了一些东西,这里想分享给大家
分析问题
原理性的东西太多文章已经介绍过了,这里不做过多的解释。
对于我们来说,启动优化就是想办法解决pre-main
和after-main
这两个阶段的耗时
第一步:尝试分析瓶颈点
这里分两种方式来去分析,实际操作中可以根据需要来选择
1.Xcode环境变量 + 手动埋点
pre-main
阶段的分析:
配置Xcode
环境变量
在Xcode
中Edit scheme
-> Run
-> Arguments
-> Environment Variables
添加: DYLD_PRINT_STATISTICS
这样在Xcode
控制台里可以打印出来pre-main
阶段的耗时:
从上图可以看到:pre-main
阶段耗时很大(苹果建议整个启动阶段是400ms),即使除去系统动态库的耗时,我们的优化空间很大。
但是这个阶段我们真正能做的其实并不多。
dylib loading time 动态库加载时间
rebase/binding time 修正内部/外部指针
ObjC setup time ObjC的setup时间
对于上面这三个,除了删除无用的代码和库,并没有太多办法。
我们项目是一个10年的老项目,在某个版本我删除过一大批第三方库的情况下,依然还存在80
个左右的三方库。
而这些库,在项目中都会有或多或少的场景和功能在使用。
删除无用代码的性价比其实并不高,除非能删除上千个文件,否则效果根本不明显。
关于+(void)load
方法
上图中可以看到我们的主可执行文件在intializers
阶段耗时很大,这个阶段的耗时主要是image镜像
的加载以及+(void)load
方法和c++虚函数
很多博客说的最多的就是这里,要避免+(void)load
方法和c++虚函数
但是为什么+load方法和c++虚函数耗时?
原因是在启动阶段,类一般都是懒加载的,即只有用到的时候才会去加载。
在编译阶段,凡是含有load方法的类或者类别都会被标记,存放到可执行文件里的_objc_nlclist
这个集合里。
在启动的时候,dyld
在加载完image
后,就会统一的遍历这个集合,将里面对应的类进行初始化,然后再调用对应的+(void)load
。
本来不需要提前加载的类都因为实现了+(void)load
方法而被提前加载。
调用+(void)load
方法的时候,如果方法内部再涉及到其他的类,就又会触发相应的类进行加载并执行对应的逻辑。
c++函数其实同上,都是在dyld阶段就会触发调用,因此也会导致相应的类提前加载。
如何查找项目中的+(void)load
方法
在Xcode
的环境变量里添加 OBJC_PRINT_LOAD_METHODS
就可以在控制台看到所有实现了load方法的类。
😂好家伙,我们项目里打印出来了总共50多个load方法,瞬间不淡定了。
关于+(void)load
方法的优化空间。
大部分使用到+(void)load
方法无外乎就是为了实现切面编程,在这个里面进行方法交换。
这里我想提两个注意点。
-
是否真的有必要去优化这个?
假如我为了实现无痕埋点,对UIViewController的viewWillAppear进行了hook,那么我有必要去优化这个+load方法吗? 因为在第一个页面出现的时候,就一定需要去加载UIKit相关的类,那么UIKit相关类的加载无非就是在pre-main阶段或是after-main阶段了,整体时间上并不会减少。 因此,我个人意见是不需要的(但是如果里面有其他的耗时点,可以考虑优化)。
-
如何替换?
网上有很多博客说可以使用+initialize方法进行替换。我不知道他们是否真的实际操作过,我认为是有风险的。 还是拿上面的无痕埋点举例。如果我把hook操作放到+initialize方法里,怎么确保我这个分类的+initialize方法不会被其他分类所覆盖呢? 希望有其他更好方式的人能告知一下,我也迫切需要,因为项目里还有40多个load方法等待替换呢。
after-main
阶段的分析
手动埋点
自己写一个简单的方法,然后在各个位置插入方法的调用,拿到所有启动项或者阶段的耗时时间 (就是当前时间减去上一个时间,拿到时间间隔)
方法实现
用一个宏定义将方法包裹,方便调用
#if DEBUG
#define RecordTimeByName(x,y) [QTAppLifeCycle recordAppLaunchTimeByName:x isSendRemote:y];
#else
#define RecordTimeByName(x,y)
#endif
在需要分析耗时的地方插入代码
运行Xcode就可以得到自己想要分析的耗时点
经过多次操作下来,发现有几个第三方的初始化还是比较耗时的:
开启网络监听 200ms左右
友盟 200ms左右
广告sdk 800ms左右
carplay的初始化 200ms左右
AppsFlyers 200ms左右
……
(真机运行,在调试模式下,这些启动项显示的时间都挺长的,并且每次启动耗时都不相同,需要多次运行,取平均值)
2.利用instrument
来分析。
最新的instrument
中已经有了App Launch
(就是统计5s内App启动时候的time profile
和 thread state trace
)
启动instrument
,得到如下图:
切换线程,可以看到下图,关于Load
方法的耗时。其实也就耗费了84ms
,占比25.5%
(真机 iPhone 11)。
下面是after-main
启动阶段的一部分耗时操作。耗费了177ms
,占比25.3%
优化解决
从上面两种方式,可以看到两种方式获得的数值差别也是很大的。这个猜测是和Xcode运行有关系。 但是不管怎么样,都是可以分析出来相对的耗时点。这对我们来说就足够了。
1. 干掉+(void)load
方法
第三方库中的+(void)load
方法其实并没有办法去优化。
我们能优化项目中的只有自己项目里的相关方法。
这里分享一个Xcode
比较好用的功能。
Xcode
的这个搜索功能很强大呀,可以写一个简单的正则表达式来匹配。
拿到这些方法后,就可以根据业务场景进行处理。
比如:
有些在load方法里进行字体注册的,完全可以延后到对应场景再去注册。
有些在load方法里进行播放事件监听的,完全可以放在第一次播放的时候去添加
有些在load方法里hook类的dealloc方法,只是为了方便移除kvo监听。可以使用RAC等替换逻辑
我们的项目里,最终想办法去掉了11
个。
2. 优化after-main
阶段
优化之前,我们启动阶段的事件有50项左右(三方库初始化,网络请求,单例初始化……)。
2.1 我们根据业务场景将这些事件进行梳理以及分级
2.1.1 移除无用的逻辑
2.1.2 将事件进行分级
根据优先级划分四个档次:
- 第一档: 在App启动阶段,
didFinish
方法里触发。 比如:初始化友盟,初始化热修复,拉取配置等 - 第二档: 首页
didLoad
时候触发调用。 比如:全局UI调整,加载皮肤等 - 第三档: 首页
didAppear
时候触发调用。 比如:上报相关日志,获取iap相关信息等 - 第四档: 首页
出现3s
时候触发调用。比如:预加载其他模块数据等
这样处理后,其实并没有明显的效果,只是业务上更加清晰。
2.2 启动事件异步初始化
既然有些东西不得不在启动阶段就去做,是否可以考虑对那些和UI无关的初始化事件放在子线程里去做?只要不卡主线程,启动时间就节省下来了
启动阶段创建一个串行队列,将所有不需要放到主线程操作的事件都放在这个串行队列里异步执行。比如:友盟,广告sdk,carplay,归因等事件
这样操作后,效果很明显。在当时测试情况下,启动阶段几乎不怎么耗时。通过真机运行发现这一步节省了800ms左右 。
下面是优化后的对比(iPhone11 真机运行)
注:after-main
是在启动的时候输出一个时间,在首页加载成功的时候输出一个时间,做差值(为了分析暂时去掉了广告逻辑)。
因为优化和版本迭代是在同时进行的,最终看下来,整体并没有像在做的过程那样减少很多,总共减少了400-500ms
左右。
整体效果
最终从线上数据看,整体优化了1.1s左右
其他优化措施
- 启动二进制重排 (这个更多的是解决冷启动时间,我们项目中已经运用)
- 删除无用类
- 减少首页相关的图片操作(比如是否可以想办法将一些预置大图先解码后存放本地,图片无损压缩)
- 优化首页逻辑:去掉不必要的操作,异步耗时操作,懒加载……
- 充分利用广告时间,在展示广告的时候,把首页相关的请求和逻辑预先处理掉。
- 减少动态库(真机测试过,一个快手sdk,在启动阶段耗时80ms左右)
- 其他………………
总结
个人觉得,启动时间优化要想出来结果,主要还是要依靠优化业务逻辑。 优化业务逻辑是一个很耗时,风险也比较大的事情。切记优化完成后,一定要通知测试进行回归。