优化启动时间的实践(1)

1,265 阅读7分钟

首先要知道怎么精确测量启动时间

从使用上,就是点击到看到首页消耗的时间
从代码上主要分为 main 之前的启动时间和 main 之后的启动时间。
至于main之后的启动时间,有的人把 main 到 didFinishLaunching 结束的这一段时间作为指标,有的人把 main 到第一个 ViewController 的 viewDidAppear 作为考量指标。这里我选择第二种情况来进行统计。

1.1、测量pre-main时间

首先是Xcode,这个很简单,只需要在 Edit scheme -> Run -> Arguments 中将环境变量 DYLD_PRINT_STATISTICS 设为 1,就可以看到 main 之前各个阶段的时间消耗。

Total pre-main time: 496.47 milliseconds (100.0%)
         dylib loading time:  40.69 milliseconds (8.1%)
        rebase/binding time:  35.19 milliseconds (7.0%)
            ObjC setup time:  38.35 milliseconds (7.7%)
           initializer time: 382.22 milliseconds (76.9%)
           slowest intializers :
             libSystem.B.dylib :   7.49 milliseconds (1.5%)
    libMainThreadChecker.dylib :  47.75 milliseconds (9.6%)
          libglInterpose.dylib : 202.67 milliseconds (40.8%)
                    GoodDriver : 175.70 milliseconds (35.3%)

还有一个方法获取更详细的时间,只需将环境变量 DYLD_PRINT_STATISTICS_DETAILS 设为 1 就可以。

total time: 1.2 seconds (100.0%)
  total images loaded:  423 (411 from dyld shared cache)
  total segments mapped: 45, into 1750 pages
  total images loading time: 536.83 milliseconds (43.0%)
  total load time in ObjC:  38.35 milliseconds (3.0%)
  total debugger pause time: 496.14 milliseconds (39.7%)
  total dtrace DOF registration time:   0.00 milliseconds (0.0%)
  total rebase fixups:  350,980
  total rebase fixups time:  35.20 milliseconds (2.8%)
  total binding fixups: 593,656
  total binding fixups time: 251.17 milliseconds (20.1%)
  total weak binding fixups time:   3.61 milliseconds (0.2%)
  total redo shared cached bindings time: 254.79 milliseconds (20.4%)
  total bindings lazily fixed up: 0 of 0
  total time in initializers and ObjC +load: 382.22 milliseconds (30.6%)
                         libSystem.B.dylib :   7.49 milliseconds (0.6%)
               libBacktraceRecording.dylib :   7.26 milliseconds (0.5%)
                           libobjc.A.dylib :   1.85 milliseconds (0.1%)
                libMainThreadChecker.dylib :  47.75 milliseconds (3.8%)
                      libglInterpose.dylib : 202.67 milliseconds (16.2%)
                       libMTLCapture.dylib :   9.24 milliseconds (0.7%)
                                GoodDriver : 175.70 milliseconds (14.0%)
total symbol trie searches:    1424652
total symbol table binary searches:    0
total images defining weak symbols:  47
total images using weak symbols:  113

image
图是 Apple 在 WWDC 上展示的 PPT,是对 main 之前启动所做事的一个简单总结

在实际测试中,pre-main的时间为500-1200 ms波动

对应上方的输出,各个步骤的含义

1、dylib loading:

load dylib 加载动态库,动态链接库的加载步骤具体分为5步:

1.load dylibs image 读取库镜像文件  
2.Rebase image  
3.Bind image  
4.Objc setup  
5.initializers  

在项目优化实践中可以

1.减少非系统库的依赖
2.合并非系统库
3.使用静态资源,比如把代码加入主程序
2、rebase/bind

可执行文件和动态链接库在虚拟内存中的加载地址每次启动都不固定,所以需要这2步来修复镜像中的资源指针,来指向正确的地址(是因为ASLR机制)。rebase修复的是指向当前镜像内部的资源指针; 而bind指向的是镜像外部的资源指针。rebase步骤先进行,需要把镜像读入内存,并以page为单位进行加密验证,保证不会被篡改,所以这一步的瓶颈在IO。bind在其后进行,由于要查询符号表,来指向跨镜像的资源,加上在rebase阶段,镜像已被读入和加密验证,所以这一步的瓶颈在于CPU计算。 通过命令行可以查看相关的资源指针:

xcrun dyldinfo -rebase -bind -lazy_bind myApp.App/myApp

__DATA  __got            0x1014C8658  pointer  0x101360C46
__DATA  __got            0x1014C8660  pointer  0x101360A46
…………
__DATA  __la_symbol_ptr  0x1014CB200 0x7E09 libSystem        _write
__DATA  __la_symbol_ptr  0x1014CB208 0x7E18 libSystem        _writev

优化该阶段的关键在于减少__DATA segment中的指针数量。我们可以优化的点有:

1、减少Objc类数量, 减少selector数量
2、减少C++虚函数数量
3、转而使用swift stuct(其实本质上就是为了减少符号的数量)
3、Objc

Objc setup的主要工作是:

注册Objc类 (class registration)
把category的定义插入方法列表 (category registration)
保证每一个selector唯一 (selctor uniquing)

这一步目前似乎没什么可做的。

4、initializers

以上三步属于静态调整,这里开始在堆栈中写入内容。
这一步的工作:

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

Objc的load函数和C++的静态构造函数采用由底向上的方式执行,来保证每个执行的方法,都可以找到所依赖的动态库。

image
上图是在自定义的类TrasnportVC的+load方法断点的调用堆栈,清楚的看到整个调用栈和顺序:

1、dyld 开始将程序二进制文件初始化
2、交由 ImageLoader 读取 image,其中包含了我们的类、方法等各种符号
3、由于 runtime 向 dyld 绑定了回调,当 image 加载到内存后,dyld 会通知 runtime 进行处理
4、runtime 接手后调用 map_images 做解析和处理,接下来 load_images 中调用 call_load_methods 方法,遍历所有加载进来的 Class,按继承层级依次调用 Class 的 +load 方法和其 Category 的 +load 方法

至此,可执行文件中和动态库所有的符号(Class,Protocol,Selector,IMP,…)都已经按格式成功加载到内存中,被 runtime 所管理,再这之后,runtime 的那些方法(动态添加 Class、swizzle 等等才能生效)。

整个事件由 dyld 主导,完成运行环境的初始化后,配合 ImageLoader 将二进制文件按格式加载到内存,动态链接依赖库,并由 runtime 负责加载成 objc 定义的结构,所有初始化工作结束后,dyld 调用真正的 main 函数。

这个时候回看一下之前打印的时长统计,最多的用时还是在image加载和OC类的初始化

总结一下:对于main()调用之前的耗时我们可以优化的点有:

1、减少不必要的framework,因为动态链接比较耗时;
2、check framework应当设为optional和required,如果该framework在当前App支持的所有iOS系统版本都存在,那么就设为required,否则就设为optional,因为optional会有些额外的检查;
3、合并或者删减一些OC类,删减一些无用的静态变量;
4、将不必须在+load方法中做的事情延迟到+initialize中。

在实际应用中,业务变动频繁,会留下不少无用的代码,无用的图片资源。经过简单删除之后,initializers大约减少了80ms(从380到300,具体代码的优化牵涉太多,需要慎重,此处只是删除了确定无用的类)

删掉一个类之后似乎是减少了1-10ms(每次启动时间在波动,所以取最快的那次统计,数值并不准确)。

main()调用之后的加载时间

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

1、首屏初始化所需配置文件的读写操作;
2、首屏列表大数据的读取;
3、首屏渲染的大量计算等。

很多的初始化工作会被放在这个阶段执行,导致渲染完成滞后。更好的方法应该是从功能上梳理出哪些是首屏渲染必要的初始化功能,哪些是App启动必要的初始化功能,哪些是只需要在对应功能开始使用时才需要初始化的。

首屏渲染完成之后

这个阶段主要是其他非首屏业务模块的初始化、监听的注册,配置文件的读取等。这个阶段需要各自去捋顺代码

经过删除资源,删除无用类,整理初始化代码之后,pre-main的速度从接近500ms下降到了420左右,didFinishLaunchingWithOptions耗时从1.2秒下降到了0.2秒,测试包从70m下降到了65m。

本文是一次简单治理过程,后续还会有一次方法优化。