iOS性能优化之启动优化

这一篇写一写关于iOS的启动,都是前人的文章,自己实践记录一下,加深理解和认识,文末附上参考链接。

App启动流程

main() 函数作为一个应用程序的入口,从App的icon 被点击,到程序进入main函数这段时间内,系统已经完成了很多工作。iOS的启动过程大致可分为两部分:pre-main time ,post-main time

Pre-main time

下图是Apple在WWDC上展示pre-main 阶段的流程图。

  1. Load dylibs

    加载动态库,系统首先会可执行文件,然后加载动态链接库dyld,dyld是一个用来专门加载动态链接库的库(😂)。加载完dyld,dyld从可执行文件的依赖开始,递归的加载所有依赖的动态链接库。

  2. Rebase & Bind

    由于ASLR(address space layout randomization)的存在,可执行文件和动态链接库在虚拟内存中的加载地址每次启动都不固定,所以需要这两步来修正。Rebase就是用来修正动态库内部指针的指向。而动态库之间存在依赖关系,在刚加载完所有动态库后,它们之间还是相互独立的状态。Bind就是将动态库内部符号表指向与所依赖的动态库的资源

  3. Objc

    这一步开始进入Objc阶段,启动Runtime。这一步的主要工作有:注册Objc类,把category的定义插入方法列表,确保每一个selector的唯一

  4. Initializers

    这一步的工作主要有:Objc的+load()函数、C++的构造函数属性函数 形如attribute((constructor)) void DoSomeInitializationWork()、非基本类型的C++静态全局变量的创建(通常是类或结构体)(non-trivial initializer) 比如一个全局静态结构体的构建,如果在构造函数中有繁重的工作,那么会拖慢启动速度

  • 动态库的load方法早于主二进制的所有load方法被调用

post-main time

post-main time的统计口径:从进入main函数开始,到willFinishLaunch结束。大多数App都会在这个阶段完成相关SDK的初始化工作。

如何精确测量App启动速度

Xcode 测量 pre-main 耗时

Xcode 提供了一个很赞的方法,只需要在 Edit scheme -> Run -> Arguments 中将环境变量 DYLD_PRINT_STATISTICS 设为 1,就可以看到 main 之前各个阶段的时间消耗。

Total pre-main time: 2.1 seconds (100.0%)
         dylib loading time: 106.81 milliseconds (4.9%)
        rebase/binding time: 192.29 milliseconds (8.9%)
            ObjC setup time: 855.42 milliseconds (39.7%)
           initializer time: 995.53 milliseconds (46.2%)
           slowest intializers :
             libSystem.B.dylib :  29.33 milliseconds (1.3%)
    libMainThreadChecker.dylib : 101.49 milliseconds (4.7%)
          libglInterpose.dylib : 297.92 milliseconds (13.8%)
         libMTLInterpose.dylib :  60.73 milliseconds (2.8%)
                       ModelIO :  69.89 milliseconds (3.2%)
              MediaLibraryCore :  44.31 milliseconds (2.0%)
                   WDBuyerUniv : 363.40 milliseconds (16.9%)

以上为买家版的pre-main阶段的详细耗时,可以看到大头是在ObjC setup和initializer阶段

如何统计线上 pre-main 时间

统计全部load方法的耗时

dyld会优先初始化动态库,然后初始化App的可执行文件。只要在最早加载的动态库中Hook App中全部的load函数,然后打点统计就可以了。那么问题就变成了如何找到最早加载的动态库?

动态库的 load 顺序是与 Load Commands 顺序和依赖关系息息相关的。如图所示:

如何调整Load Command 顺序, 让自定义的动态库率先被加载?

思路1:Mach-O 本质是还是一个文件,想要让上图中的AAAAAHookLoad被第一个加载,那么只要把Load Command 与第一个动态库的Load Command 替换即可。该方案需要对Mach-O的文件格式有足够的了解

思路2:编译过程,指定Load Command的顺序。按理说,Mach-O中Load Command的顺序应该是按照一定的规则写入的。只要能找到修改响应规则的方法,应该比思路1容易得多。目前还没找到(😂)

使用思路1的方案调整Load Command 加载顺序后,AAAAAHookLoad 被调整到了最前面

自定义动态库,已Embed framework的方式加入App工程 -> 自定义的某个class load 方法中 hook 全部类的load方法 -> hook方法中统计load方法耗时 -> 输出

注意事项:

1. 不能为了统计性能,自己却造成了性能问题,获取所有的类并且 Hook load 函数还是比较耗时的,控制不好反而增加了启动时间

2. 不当某个class 与其 category 同时实现了 load方法时,只能hook category 中的方法,需要考虑如何同时hook。

度量 C++ Static Initializers

Runtime 会从 mod_init_func 这个 section 中读取所有的函数指针,然后执行函数调用,这些函数指针对应的正是 C++ Static Initializers 和 attribute((constructor))修饰的函数。

因为它们的执行顺序在 load 函数之后,所以可以在 load 函数中把 mod_init_func 中的地址都替换成我们的 hook 函数指针,然后再把原函数指针保存到一个全局数据中,当执行我们的 hook 函数时,从全局数组中取出原函数地址执行。

在这里张贴下主要代码,更多可以参考这个链接:https://github.com/everettjf/Yolo/blob/master/HookCppInitilizers/hook_cpp_init.mm

优化

pre-main 阶段优化

  1. Load dylibs 阶段

    • 这一阶段,优化点主要在减少不必要的动态库,因为动态链接比较耗时,
    • 另外一点就是,尽可能把多个动态库合并成一个。系统的动态库有共享缓存等优化方案,但是我们的动态库变多了的话会非常耗时,所以合并动态库是一个有效且可行的方案
    • 也可以尝试对一些非启动阶段所必须的动态库,延时加载(仅想法,未验证)。
  2. Rebase & Bind

    • 对于Objc,减少class,selector和category这些元数据的数量。
    • 对于C++,减少虚方法,因为虚方法会创建vtable。
  3. Initializers

    • 减少load方法的耗时,尽量将load方法的工作移到initialize。
    • 不要使用 atribute((constructor)) 将方法显式标记为初始化器,而是让初始化方法调用时才执行。比如使用 dispatch_once(),pthread_once() 或 std::once()。也就是在第一次使用时才初始化,推迟了一部分工作耗时。

main 阶段优化

启动任务分层,不需要及时初始化,不需要在主线程初始化的,都选择异步延时加载。

参考文章

如何精确度量 iOS App 的启动时间

优化 App 的启动时间

今日头条iOS客户端启动速度优化