参考资料:
戴铭大佬《iOS开发高手》
李斌同学《iOS 优化篇 - 启动优化之Clang插桩实现二进制重排》

首先我们先看看iOS系统上App的启动都经历了什么,App 的启动主要包括三个阶段:

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

main() 函数执行前

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

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

  • 减少动态库加载。每个库本身都有依赖关系,苹果公司建议使用更少的动态库,并且建议在使用动态库的数量较多时,尽量将多个动态库进行合并。数量上,苹果公司建议最多使用 6 个非系统动态库
  • 减少加载启动后不会去使用的类或者方法
  • +load() 方法里的内容可以放到首屏渲染完成后再执行,或使用 +initialize() 方法替换掉。因为,在一个 +load() 方法里,进行运行时方法替换操作会带来 4 毫秒的消耗。不要小看这 4 毫秒,积少成多,执行 +load() 方法对启动速度的影响会越来越大
  • 控制 C++ 全局变量的数量

main() 函数执行后
main() 函数执行后的阶段,指的是从 main() 函数执行开始,到 appDelegate 的 didFinishLaunchingWithOptions 方法里首屏渲染相关方法执行完成。
首页的业务代码都是要在这个阶段,也就是首屏渲染前执行的,主要包括了:

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

很多时候,开发者会把各种初始化工作都放到这个阶段执行,导致渲染完成滞后。更加优化的开发方式,应该是从功能上梳理出哪些是首屏渲染必要的初始化功能,哪些是 App 启动必要的初始化功能,而哪些是只需要在对应功能开始使用时才需要初始化的。梳理完之后,将这些初始化功能分别放到合适的阶段进行

首屏渲染完成后
首屏渲染后的这个阶段,主要完成的是,非首屏其他业务服务模块的初始化、监听的注册、配置文件的读取等。从函数上来看,这个阶段指的就是截止到 didFinishLaunchingWithOptions 方法作用域内执行首屏渲染之后的所有方法执行完成。简单说的话,这个阶段就是从渲染完成时开始,到 didFinishLaunchingWithOptions 方法作用域结束时结束。
这个阶段用户已经能够看到 App 的首页信息了,所以优化的优先级排在最后。但是,那些会卡住主线程的方法还是需要最优先处理的,不然还是会影响到用户后面的交互操作。
明白了 App 启动阶段需要完成的工作后,我们就可以有的放矢地进行启动速度的优化了。这些优化,包括了功能级别和方法级别的启动优化

下面引入几个相关的知识点

物理内存和虚拟内存:
image.png

使用物理内存时遗留的问题

  • 安全问题 : 由于在内存条中使用的都是真实物理地址 , 而且内存条中各个应用进程都是按顺序依次排列的 . 那么在 进程1 中通过地址偏移就可以访问到 其他进程 的内存 .
  • 效率问题 : 随着软件的发展 , 一个软件运行时需要占用的内存越来越多 , 但往往用户并不会用到这个应用的所有功能 , 造成很大的内存浪费 , 而后面打开的进程往往需要排队等待 .

为了解决上述两个问题 , 虚拟内存应运而生 .

虚拟内存的工作原理:引用了虚拟内存后 , 在我们认为进程中有一大片连续的内存空间,也就是说从 0x000000 ~ 0xffffff 我们是都可以访问的。但是实际上这个内存地址只是一个虚拟地址,而这个虚拟地址通过一张映射表映射后才可以获取到真实的物理地址。也就是说,系统对真实物理内存访问做了一层限制,只有被写到映射表中的地址才是被认可可以访问的。虚拟地址 0x000000 ~ 0xffffff 这个范围内的任意地址我们都可以访问,但是这个虚拟地址对应的实际物理地址是计算机来随机分配到内存页上的。

  • 当应用被加载到内存中时 ,并不会将整个应用加载到内存中。只会放用到的那一部分。也就是 懒加载 , 换句话说就是应用使用多少 , 实际物理内存就分配多少。
  • 当应用访问到某个地址,映射表中为 0 ,也就是说它并没有被加载到物理内存中时 , 系统就会立刻阻塞整个进程 , 触发一个缺页中断 ,即 Page Fault
  • 当一个缺页中断被触发,操作系统会从磁盘中重新读取这页数据到物理内存上,然后将映射表中虚拟内存指向对应物理内存。 如果当前内存已满,操作系统会通过置换页算法找一页数据进行覆盖。这也是为什么开再多的应用也不会崩掉,但是之前开的应用再打开,就会重新启动的根本原因

实际上在 iOS 系统中,生产环境的应用,在发生缺页中断进行重新加载时 ,iOS 系统还会对其做一次签名验证,因此 iOS 生产环境的 Page Fault 所产生的耗时要更多
对用户而言,使用App时第一个直接体验就是启动 App 时间,而启动时期会有大量的分类三方等等需要加载和执行,此时多个 Page Fault 所产生的的耗时往往是不能小觑的,下面我们就通过二进制重排来优化启动耗时

Instruments(System Trace):借助工具可以查看App启动所消耗的page Falut。
1880020-6ae010a4a6567f93.png
File Backed Page In:即为 Page Fault,对应的有count,一页Page Fault最大耗时,最小耗时等参数。每次检测时,最好是卸载重装,这是因为内存管理机制,杀掉进程时,他所占用的物理内存空间,如果没有被覆盖使用,那么这部分内存有很大可能一直存在。重新打开,内存就不需要全部初始化。所以 冷热启动的界定不能以是否后台杀死来简单判断

Order File:1880020-7a9653240955f6a0.png
image.png
在这个 order 文件中,将你需要的符号按顺序写在里面。当工程 build 的时候,Xcode 会读取这个文件,打的二进制包就会按照这个文件中的符号顺序进行生成对应的 mach-O

二进制重排原理
image.png

如何查看项目符合顺序

  • 可以设置 Write Link Map File 来设置是否输出,默认是 noLink Map 是编译期间产生的 ,( ld 的读取二进制文件顺序默认是按照 Compile Sources 里的顺序 ),它记录了二进制文件的布局。
  • 修改 Write Link Map FileYES,然后clean项目并重新编译
  • Products -> show in finder,上上层文件夹,然后找到一个xxxxx-LinkMap-normal-arm64.txt 的txt文件。1880020-cdcd2ca8fb21b3a3.png

获取启动执行的函数

  • hook objc_MsgSend:只能拿到 oc 以及 swift @objc dynamic 后的方法,并且由于可变参数个数,需要用汇编来获取参数 。
  • 静态扫描 machO 特定段和节里面所存储的符号以及函数数据,(静态扫描 , 主要用来获取 load 方法 , c++ 构造(有关 c++ 构造 , 参考 从头梳理 dyld 加载流程 这篇文章有详细讲述和演示 ) .
  • clang 插桩:完全拿到 swiftoccblock 全部函数

clang插桩实践:

  • 首先 , 添加编译设置 .

    发现从 0e 变成了 0f . 也就是说存储的 114 这个序号变成了 115 .
    直接搜索 Other C Flags 来到 Apple Clang - Custom Compiler Flags 中 , 添加
    -fsanitize-coverage=trace-pc-guard 复制代码

  • 添加 hook 代码 ```objectivec void __sanitizer_cov_trace_pc_guard_init(uint32_t *start,

    1. uint32_t *stop) {

    static uint64_t N; // Counter for the guards. if (start == stop || start) return; // Initialize only once. printf(“INIT: %p %p\n”, start, stop); for (uint32_t x = start; x < stop; x++) *x = ++N; // Guards should start from 1. }

void __sanitizer_cov_trace_pc_guard(uint32_t guard) { if (!guard) return; // Duplicate the guard check.

void PC = builtin_return_address(0); char PcDescr[1024]; //sanitizer_symbolize_pc(PC, “%p %F %L”, PcDescr, sizeof(PcDescr)); printf(“guard: %p %x PC %s\n”, guard, guard, PcDescr); }

```

  • 运行工程 , 查看打印 iOS - 二进制重排优化启动速度 - 图7

代码命名 INIT 后面打印的两个指针地址叫 start 和 stop . 那么我们通过 lldb 来查看下从 start 到 stop 这个内存地址里面所存储的到底是啥 .
iOS - 二进制重排优化启动速度 - 图8
发现存储的是从 114 这个序号 . 那么我们来添加一个 oc 方法 .
- (void)testOCFunc{
}
再次运行查看
iOS - 二进制重排优化启动速度 - 图9
那么我们再添加一个 c 函数 , 一个 block , 和一个触摸屏幕方法来看下
iOS - 二进制重排优化启动速度 - 图10
同样发现序号依次增加到了 18 个 , 那么我们得到一个猜想 , 这个内存区间保存的就是工程所有符号的个数 .
其次 , 我们在触摸屏幕方法调用了 c 函数 , c 函数中调用了 block . 那么我们点击屏幕 , 发现如下 :
iOS - 二进制重排优化启动速度 - 图11
发现我们实际调用几个方法 , 就会打印几次 guard : .
实际上就类似我们埋点统计所实现的效果 . 在触摸方法添加一个断点查看汇编 :
iOS - 二进制重排优化启动速度 - 图12
iOS - 二进制重排优化启动速度 - 图13
通过汇编我们发现 , 在每个函数调用的第一句实际代码 ( 栈平衡与寄存器数据准备除外 ) , 被添加进去了一个 bl 调用到 __sanitizer_cov_trace_pc_guard 这个函数中来

tips:
二进制重排只能优化pre-main阶段的时间

查看pre-main阶段耗时方法:
image.png