启动优化之动静态与被神化的二进制重排

665 阅读7分钟

前言

启动分为3种类型,分别为 Cold 冷启动,Warm 热启动,Resume 重新回到活跃状态

image-20220402170917754.png

这三种启动,对应的时机以及资源在内存中状态,如下图

image-20220402172052135.png

启动流程

从用户点击图标到第一帧的展示,整个流程的App lifeCyle是怎么样的?如下图。

image-20220402172637548.png

PS: 如果你用过Instuments的App launch工具来观察整个启动的生命周期话,会发现有一个差异。 System Interface之前还有一个阶段,这个阶段实际只是消耗了5ms,其它的几百毫秒的消耗, 都是app launch工具本身为了追踪数据而产生的额外耗时,可以不用理会。

image-20220402172901177.png image-20220402173126946.png

流程可以按2个部分来看,系统的初始化和App的初始化。 也就是常说的pre-main和after main

Pre-main

分为2个阶段

  • System Interface
  • Runtime Init
    System Interface
    • dyld初始化
    • dyld加载动态库
    • rebase/binding 符号的重定向与符号绑定
    Runtime Init
    • runtime的初始化 (catoray加载)
    • 所有类的load方法执行

After-main

分为3个阶段

  • UIKit Init
  • Application Init
  • Inital Frame Render
    UIKit Init
    • 实例化Application
    • 系统事件的处理,包括runloop
    Application Init
    • Application的初始化
    • 调用application:willFinishLaunchingWithOptions: application:didFinishLaunchingWithOptions:
    Inital Frame Render
    • 布局计算
    • 第一帧渲染

以上就是整个启动流程所做的事情,这些阶段我们能够控制的其实不多。

在pre-main这个阶段

我们能够影响的只有动静态库的数量以及load方法的执行时间。

动静态库

区别

产物层面
库的类型格式大小Framework格式
静态库.a .framework大(多个mach-o)Headers + .a + 签名 + 资源文件
动态库.framework小(只有一个mach-o)Headers + .dylib + 签名 + 资源文件
编译进宿主App层面
库的类型编译进App,对App大小影响的比较链接时机
静态库小 1.过滤掉了没有引用的头文件的代码 2.编译阶段已经链接进App中,所以会少一些链接过程需要的数据编译阶段
动态库运行阶段

目标文件是如何确定调用方法的地址的

正常一个文件中调用另外一个文件的方法,比如main.c中调用Dog.c 中的run() 函数,那么会在main.o文件中生成一个叫做run的(undefine symbol)未定义的符号,等到链接的时候再确定这个符号的地址

而OC由于动态语言的特性,比如main.m 调用 Dog.m中的方法时,[[Dog alloc]init],main.o文件只会生成Dog(undefine symbol),而不会生成 alloc 和 init的 未定义符号表。原因是OC方法最终实现时在运行时才确定的。

上述的动静态库的这些知识,其实已经说明了为什么动静态库的数量为什么会影响启动时间

  1. 首先dyld加载动态库的时间,如果动态库数量太多,显然加载时间就比较长

  2. 再者就是动态库太多,那么rebinding(动态绑定)的时间也会增加

  3. 改为静态库的话,对应的rebase时间就会增加。

    • 由于ALSR的存在,需要修正内部的符号地址
    • 静态库文件越多,需要内部修正的符号也越多
  4. 总而言之,rebase和rebinding是一对时间上互斥的操作

  5. 但是Dyld load + rebinding 的耗时会大于rebase,所以减少动态库才会对启动时间优化有效

在main之后这个阶段

尽量将耗时操作放到子线程中处理,并且减少Page Fault。

二进制重排

计算机是如何运行程序的?

最开始的计算机,是直接使用物理内存,并且是一次性把程序的数据加载到内存中。 uTools_1649772155090.png

在那个内存容量还不大的年代,这样的做的话,会导致要打开一个新的应用,需要先销毁掉原来的应用。内存的利用率比较低。

于是乎,后来将应用程序进行了分页处理。每次运行到需要的程序数据时,再将数据加载到内存中来。这样解决了内存利用率低的问题。 uTools_1649772093411.png

随着发展,又出现了一个问题,就是直接使用物理内存,很容易就修改到其它程序的内存地址。 uTools_1649771412992.png

于是迎来了虚拟内存的时代,系统给每个程序都开辟了相同的大小的虚拟内存,虚拟内存的地址不和物理内存地址直接对应,由系统决定映射关系。这样一来就隔绝了直接访问的问题。 uTools_1649771764847.png

但是又出现了一个问题,系统分配给每个应用的虚拟内存地址,每次都是相同的(可以见到理解为从0开始到最大的虚拟地址)。这样会导致每个应该很容易就被hook掉。所以就出现了随机偏移地址,在程序每次启动的时候会随意一个地址加到虚拟内存地址前,这样每次的内存地址就都是随机的。

这个就是ASLR,这也是为什么每次启动都需要rebase的原因。 uTools_1649771884097.png

Page Fault(分页异常中断)

由于要提高内存的利用率,所以将程序的数据分页处理,每次要从硬盘中找到对应二进制数据再加载到内存中,会有一个异常,也就是所说的Page Fault。当然一个Page Fault的阻塞时间都是在几毫秒间,正常情况下根本感知不到这个阻塞。但是,如果在启动的时候,有几百成千个Page Fault,那么这个时间就会被感知。这也就是为什么解决Page Fault会提高启动速度的原因了。

如何减少启动阶段Page Fault?

单个或少量的Page Fault,在用户感知层面几乎是无感知的。只有在启动的时候,程序会去初始化很多数据的时候,才会产生大量的Page Fault,因此只需要将启动阶段调用到方法的二进制数据放在一起,就可以减少查找和加载的时间。

iPhone 6s 之前,一个分页是4KB,iPhone 6s 之后,一个分页是16KB

Clane静态插桩

知道了如何减少Page Fault,那么现在该如何知道启动阶段调用了哪些方法呢?

刚好Clane和Swiftc 这2个前端编译器都已经是支持我们获取调用的任意函数。

如果想深入研究一下编译到底是如何支持获取调用任意函数的话,可以通过汇编去追踪一下。这里我说结论,就是编译阶段,编译器为我们的函数插入了这2个方法(这个代码可以在 Clane 的官网找到)

void __sanitizer_cov_trace_pc_guard_init(uint32_t *start,
                                        uint32_t *stop)
void __sanitizer_cov_trace_pc_guard(uint32_t *guard)

既然要获取到调用了哪些方法,那么就需要有一个截止保存时间。这个要根据业务的不同调整,可以在applicationDidFinishLaunching 后截止,当然也可以在第一帧展示的时候。

最后获得最终调用的方法的字符串列表文件,然后通过配置,让编译器根据这份文件重新排列一下二进制文件的顺序,从而达到重排的效果

优化结果

动态库

动态库的加载.png

改静态库 静态库的加载.png

以优化的结果的数据来看

  1. 大部分动态库改为静态库后,启动时间确实有了200-300毫秒的提升。
  2. 二进制重排并没有网上说的效果,甚至说我根本没法去验证有没有提升。

PS:以上验证优化的步骤,都是重启手机后,没有任何进程,进行的测试。毕竟像苹果在wwdc大会上说的,🍎要等于🍎

分析了一下二进制重排效果不明显的原因

  1. 可能现在编译器已经进行了优化
  2. 项目本身在main到didFinishLaunching这期间,初始化的数据不算多。毕竟能延后执行的,都延后执行了。

二进制重排的流程就不累赘了。至于动态库改静态库,还是有一些骚操作的。后续单独再讲讲。