启动优化-实践篇

272 阅读8分钟

随着业务的迭代,APP越来越大,启动速度越来越慢,我司APP的启动流程链路比较长,所以启动速度比较慢,急切需要治理,但是碍于启动逻辑关系到APP正常使用,所以建议大家在做优化时可以进行小迭代、可降级、多版本,对于用户量比较大的APP来说APP稳定性是最重要的。

本文分为四个部分,分别描述APP启动的过程,启动速度的监测,业务层面的优化和技术层面的优化,希望我在项目中的思考能够帮助到大家,感谢阅读。

一:APP启动的过程

其进行启动优化时,我们首先要确定启动过程

101628178187_.pic_hd.jpg

Pre-main:
  1. 用户点击APP后,系统会首先fork一个进程。
  2. 然后会把主二进制mmap进去,读取load command中的LC_LOAD_DYLINKER,找到dyld路径。
  3. 然后mmap dyld到虚拟内存,并找到dyld的入口_dyld_start,把PC寄存器设置成_dyld_start。
  4. 创建启动闭包。
  5. 实例化主程序。
  6. 加载动态库
  7. 链接主程序。
  8. load_images: 会执行call_load_methods函数,循环调用各个类的load方法
  9. 开始进入主程序的main函数。
main:
  1. 执行main函数
  2. application:didFinishLaunchingWithOptions: -begin
  3. [rootViewController viewDidLoad]
  4. application:didFinishLaunchingWithOptions: -end
  5. [rootViewController viewDidAppear]

二:main函数之后

在启动优化过程中最见效果的往往是业务逻辑层级的优化。

  1. 梳理:梳理启动流程,统计所有任务。
  2. 拆解:大任务 -> 多个小任务 (同步任务 -> 异步任务 + 同步任务)。
  3. 寻找:找出主线中的最小路径(在满足现有业务流程的最小路径)。
  4. 调度:创建启动管理器,将一些任务在子线程预执行。

1. 梳理

首先我们需要梳理一下现阶段启动过程中有所有的业务逻辑,和涉及到的业务模块。因为每个APP的启动逻辑都不一样,我在这里会列举几种情况供大家借鉴一下。 APP启动流程任务如下:

  1. 隐私政策
  2. 假启动图
  3. 开屏广告
  4. 首页
  5. ...

2. 拆解

我的理解是将在主线程执行的任务进行拆解,尽可能将不需要在主线程做的事拆出来,比如接口请求,本地数据,加载图片。

隐私政策 -> 接口请求 + 展示页面 假启动页 -> 图片加载 + 展示页面 开屏广告 -> 本地图片数据 + 展示页面 首页 -> 接口请求 + 展示页面

3. 寻找

在将任务拆解之后,我们就可以去找出主线程中的最小路径,即在启动过程中必须放在主线程所有任务的的集合。

4. 调度

我们找到的最小路径就是在主线程上应该做的事,其它的任务我们抛在子线程就好了,但是有一点要注意:不要无节制的去创建子线程,因为这样子线程会抢占主线程的资源。所以这个时候我们就需要一个启动管理器,在其中需要维护三个队列:

  1. 主线程队列
  2. 串行队列
  3. 并行队列

一些有依赖关系的操作可以放入串行队列中,没有依赖的可以放在并行队列,这样比较方便去控制并发。

实践

有的APP受监管需要,需要在启动时不定期的展示隐私政策,然后展示隐私之后用户才能进行其它操作,并且哪些用户需要展示也是由后台决定的。在这个case里我们需要首先需要请求服务端数据,然后再判断是否展示隐私政策页,我们接下来就拿这个case铺开来说。

691628228900_.pic.jpg

其它

UIImage优化

iOS中有以下三种方式去加载本地图片:

  1. 图片在Asset里面,带缓存。(imageNamed:)
  2. 图片在逻辑目录里面,带缓存。(imageNamed:)
  3. 图片在逻辑目录里面,不带缓存。(imageWithContentsOfFile:)

使用Asset加载图片的性能是最高的,同样大小的图片第一种消耗的时间是第二种的十分之一。 imageNamed:实际上调用的是一个叫做UIAssetManager的类,每个Bundle有一个UIAssetManager。他有一个strong-strong的NSMapTable的属性用来做缓存。如果没有获取到缓存则首先命中的是Assets.car,如果还找不到会搜索@3x @2x @1x .png忽略等规则,一直找到那种非Assets.car的bundle图,然后加载。

@implementation 写在头文件的坑

千万不要在头文件里写@implementation

在维护过程中遇到了启动时间骤增的情况,与之前版本比较是启动过程中UIView操作的耗时增加,但是启动之后UIView的耗时恢复正常。

最终发现是有一位小伙伴在预编译文件中import了View+MASShorthandAdditions.h,这个类中写了@implementation

当我们import "xx.h"文件时,预处理器在处理的时候会把这一行替换成对应头文件的文本,在上述情况下打开经过预处理之后的文件会发现View+MASShorthandAdditions.h中的以下代码被copy很多次

@interface MAS_VIEW (MASShorthandAdditions)

- (NSArray *)makeConstraints:(void(^)(MASConstraintMaker *make))block;
...

@end

@implementation MAS_VIEW (MASShorthandAdditions)

- (NSArray *)makeConstraints:(void(NS_NOESCAPE ^)(MASConstraintMaker *))block {
    return [self mas_makeConstraints:block];
}
...

@end

每个引用View+MASShorthandAdditions.h都在向UIView的methodList中插入makeConstraints:等方法,导致methodList的数组的长度爆炸式增加,而且在分类中方法会插在methodList的最前面,在启动过程中调用UIView的系统方法时都要遍历到数组最后面才能拿到,因此对启动造成了影响。在启动过程中常用的UIView方法都已执行过,之后的页面再去使用时用的都是cache,故此造成了一开始说的现象。

四:Pre-main优化

减少使用+load

尽量减少在+load里面执行逻辑,方法里的内容可以放到首屏渲染完成后再执行,或使用 +initialize() 方法替换掉,如果确实需要也尽可能的放在子线程里去执行。

很多项目中会引入类似SafeKit的安全库,通过hook的方式去保证一般的数组操作不会崩溃,这里比较建议通过封装通用safe方法的方式去替代使用hook,因为每个hook都会增加启动耗时,并且极其不建议使用hook来保证UI在主线程进行操作,因为每个UI操作都会增加耗时,可以使用XCode的Main Thread Checker来进行检查。

尽可能的减少使用分类

分类不但影响启动速度,当分类的使用没有约束好时,很容易发生方法相互覆盖的情况。

闭包

团队从两周一版转化为一周一版时,启动速度的波动比较明显。究其原因是,iOS13之后使用的是dyld3,dyld3会通过启动闭包来提升启动速度,其在重启手机或者更新/下载App的第一次启动才会创建。闭包中缓存着依赖的动态库列表,bind & rebase的地址,初始化调用顺序和OC的元数据。

二进制重排

如下图所示,当进程访问它的虚拟地址空间中的Page时,如果这个Page目前不在物理内存中,此时CPU是不能干活的,系统会产生一个hard page fault中断,系统需要从磁盘将对应的数据Page读入物理内存中,并建立地址与虚拟地址空间的Page的映射关系,此时进程才能访问这部分虚拟地址空间的内存,TEXT页的数据也需要解密和验签。

741628328070_.pic.jpg

启动APP时,系统不会将程序的代码数据全部加载,而是按需加载。在启动过程中会触发多次hard page fault,如以下场景而外触发了三次page fault,只要我们把这几个方法排列到一起就只会触发一次page fault,从而减少启动时间。

731628327104_.pic.jpg

之后会专门出一篇文章来详细讲述二进制重排的实现过程。

fishhook

  1. 编译时Mach-O文件_DATA段的符号表会为系统C函数建立一个指针,这个指针用于动态绑定时重新定位到共享库中的函数实现。
  2. 运行时系统C函数会被绑定,将此指针指向外部函数。
  3. 从_DATA段中的lazy符号指针表查找某个符号,获取这个符号的偏移量。
  4. 在rebind_symbols注册加载镜像的回调函数,记录需要hook的方法和函数指针。
  5. 将指向系统方法的指针重新绑定向内部函数/自定义C函数。
  6. 将内部函数的指针在动态链接时指向系统方法的地址

mach-O

方法耗时计算

  1. 使用fishhook去hook objc_msgSend方法。
  2. 首先把寄存器存入栈中,保存现场,保存lr寄存器地址(下一条代码执行的地址)。
  3. 执行before_objc_msgSend。
  4. 还原上下文调用objc_msgSend原方法
  5. 记录上下文调用after_objc_msgSend,还原lr