The Weekend Blog

I hope it will continue to be updated

App启动速度

一般情况下,App 的启动分为冷启动和热启动。

  • 冷启动是指, App 点击启动前,它的进程不在系统里,需要系统新创建一个进程分配给它启动的情况。这是一次完整的启动过程。
  • 热启动是指 ,App 在冷启动后用户将 App 退后台,在 App 的进程还在系统里的情况下,用户重新启动进入 App 的过程,这个过程做的事情非常少。

App 的启动时间,指的是从用户点击 App 开始,到用户看到第一个界面之间的时间。

App 的启动主要包括三个阶段:

  • main() 函数执行前;
  • main() 函数执行后;
  • 首屏渲染完成后。

main() 函数执行前

在 main() 函数执行前,系统主要会做下面几件事情:

  • 加载可执行文件(App 的.o 文件的集合);
  • 加载动态链接库,进行 rebase 指针调整和 bind 符号绑定;
  • Objc 运行时的初始处理,包括 Objc 相关类的注册、category 注册、selector 唯一性检查等;
  • 初始化,包括了执行 +load() 方法、attribute((constructor)) 修饰的函数的调用、创建 C++ 静态全局变量。

这个阶段对于启动速度优化来说,可以做的事情包括:

  • 减少动态库加载。每个库本身都有依赖关系,苹果公司建议使用更少的动态库,并且建议在使用动态库的数量较多时,尽量将多个动态库进行合并。数量上,苹果公司建议最多使用6个非系统动态库。
  • 动态库是指可以共享的代码文件、资源文件、头文件等的打包集合体。在Xcode->Targets->General->Link Binary With Libraries可以检查自己的库

  • 减少加载启动后不会去使用的类或者方法。

  • +load() 方法里的内容可以放到首屏渲染完成后再执行,或使用 +initialize() 方法替换掉。因为,在一个 +load() 方法里,进行运行时方法替换操作会带来 4 毫秒的消耗。不要小看这 4 毫秒,积少成多,执行 +load() 方法对启动速度的影响会越来越大。
1
2
3
4
5
6
7
8
9
10
11
12
执行顺序:
 load -> attribute((constructor)) -> main -> initialize
 
  + (void)initialize{
    // 在类被第一次使用的时候调用
  }
  + (void)load{
    // 在类被运行时加载时调用
  }
  __attribute((constructor)) void beforeMain(){
  //是GCC的扩展语法(黑魔法),由它修饰过的函数,会在main函数之前调用
  }
  • 控制 C++ 全局变量的数量。

查看main()函数执行前的耗时:

在Product->Scheme->Edit Scheme->Run->Arguments->Environment Variables->DYLD_PRINT_STATISTICS设置为YES,就可以在控制台中查看main函数执行前总共花费的多长时间。


main() 函数执行后

main() 函数执行后的阶段,指的是从 main() 函数执行开始,到 appDelegate 的 didFinishLaunchingWithOptions 方法里首屏渲染相关方法执行完成。

优化方向:从功能上梳理出哪些是首屏渲染必要的初始化功能,哪些是 App 启动必要的初始化功能,而哪些是只需要在对应功能开始使用时才需要初始化的。梳理完之后,将这些初始化功能分别放到合适的阶段进行。

alt text


首屏渲染完成后

首屏渲染后的这个阶段,指的是didFinishLaunchWithOptions方法作用域内执行首屏渲染之后的所有方法执行完成,即从 设置了self.window.rootViewController开始 到 didFinishLaunchWithOptions方法作用域结束。

这个阶段用户已经能够看到 App 的首页信息了,所以优化的优先级排在最后。但是,那些会卡住主线程的方法还是需要最优先处理的,不然还是会影响到用户后面的交互操作。


功能级别的启动优化

功能级别的启动优化,就是要从 main() 函数执行后这个阶段下手。

main() 函数开始执行后到首屏渲染完成前只处理首屏相关的业务,其他非首屏业务的初始化、监听注册、配置文件读取等都放到首屏渲染完成后去做。

方法级别的启动优化

首先优化前需要先精准监控到都哪些方法需要优化,就是哪些方法更耗时 对 App 启动速度的监控,主要有两种手段。

  • 第一种方法是,定时抓取主线程上的方法调用堆栈,计算一段时间里各个方法的耗时。Xcode 工具套件里自带的 Time Profiler ,采用的就是这种方式。
  • 第二种方法是,对 objc_msgSend 方法进行 hook 来掌握所有方法的执行耗时。

第一种方法

Time Profiler

Time Profiler每隔1ms会对线程的调用栈采样,然后用统计学的方式去做出分析。
Time Profiler用来分析代码的执行时间,主要用来分析CPU使用情况。 注意:要在release模式下分析。

缺点:

  • 定时间隔设置得长了,会漏掉一些方法,从而导致检查出来的耗时不精确;
  • 而定时间隔设置得短了,抓取堆栈这个方法本身调用过多也会影响整体耗时,导致结果不准确。

这个定时间隔如果小于所有方法执行的时间(比如 0.002 秒),那么基本就能监控到所有方法。但这样做的话,整体的耗时时间就不够准确。一般将这个定时间隔设置为 0.01 秒。这样设置,对整体耗时的影响小,不过很多方法耗时就不精确了。但因为整体耗时的数据更加重要些,单个方法耗时精度不高也是可以接受的,所以这个设置也是没问题的。

总结来说,定时抓取主线程调用栈的方式虽然精准度不够高,但也是够用的。

实现类似Time Profiler的检测工具

要获取线程的调用栈,github上有一个开源轻量级工具BSBacktraceLogger

第二种方法实现

主要是搬戴铭大佬的成果:
objc_msgSend 本身是用汇编语言写的,这样做的原因主要有两个:

  • 一个原因是,objc_msgSend 的调用频次最高,在它上面进行的性能优化能够提升整个 App 生命周期的性能。而汇编语言在性能优化上属于原子级优化,能够把优化做到极致。所以,这种投入产出比无疑是最大的。
  • 另一个原因是,其他语言难以实现未知参数跳转到任意函数指针的功能。

objc_msgSend 方法执行的逻辑是:先获取对象对应类的信息,再获取方法的缓存,根据方法的 selector 查找函数指针,经过异常错误处理后,最后跳到对应函数的实现。

怎么hook objc_msgSend 方法?

Facebook 开源了一个库,可以在 iOS 上运行的 Mach-O 二进制文件中动态地重新绑定符号,这个库叫 fishhook

fishhook 实现的大致思路是,通过重新绑定符号,可以实现对 c 方法的 hook。dyld 是通过更新 Mach-O 二进制的 __DATA segment 特定的部分中的指针来绑定 lazy 和 non-lazy 符号,通过确认传递给 rebind_symbol 里每个符号名称更新的位置,就可以找出对应替换来重新绑定这些符号。

有了fishhook后还需要实现两个方法 pushCallRecord 和 popCallRecord,来分别记录 objc_msgSend 方法调用前后的时间,然后相减就能够得到方法的执行耗时。

具体实现参考戴铭老师的github中的SMCallTrace