这是我参与8月更文挑战的第10天,活动详情查看:8月更文挑战
Hi ?
我的个人项目 | 扫雷Elic 无尽天梯 | 梦见账本 |
---|---|---|
类型 | 游戏 | 财务 |
AppStore | Elic | Umemi |
前言
近两年二进制重排在启动优化上还是经常被提到的,虽然听着很厉害的样子,但其实是个老概念了。
继上一次「iOS官方瘦身方案ODR(二):换肤系统改造|践行 On-Demand Resources」后,再次拿自己个人项目小白鼠「梦见账本」来实践一下。
一、 为什么要二进制重排呢?
1.1 虚拟内存和分页
我们知道,现代操作系统一般都采用虚拟内存管理机制,用分段(segment)
和分页(page)
管理虚拟内存。
分段即是区分数据段
、代码段
、堆内存
、栈内存
等,不同的段数据的读写权限不一样。以 iOS
为例,代码段(_TEXT)
是可读可执行但不能写的。
分页则是为了方便高效的进行内存管理。由于采用了虚拟内存管理机制,就要建立虚拟内存
到物理内存
的映射表
,称为页表
。如果在设计上将每一个字节的虚拟内存和物理内存一一对应,这样粒度足够细,虽然不会产生内存浪费(内存碎片),但需要维护巨大的页表;但如果一页数据过大,比如5M,那么存储1个字节就要分配一个5M的页面,是非常大的浪费。内存页过大或过小都有弊端,目前大多数系统的页大小都设置在了4096字节
,通过页号和页内偏移进行寻址。可以使用pagesize
命令查看当前系统的页大小。
1.2 Page Fault
使用虚拟内存的目的之一是解决物理内存资源紧张的问题。dyld
在加载二进制时,会使用 mmap
将 Mach-O
文件映射到虚拟内存
地址空间中,此时并不会占用过多的物理内存
。当读取一个虚拟内存地址
时,如果该地址在物理内存
中并不存在,会触发一次缺页中断(Page Fault)
,这个时候才将文件内容读取至物理内存中。
缺页中断发生时会执行下面的操作:
分配内存
由内存管理单元
找到空闲内存并分配。
IO操作
从磁盘中读文件并写入内存中。
解密验签
如果是从 AppStore
上下载的 APP
,iOS
系统还有对每一页(仅针对 _TEXT
段的数据,_DATA
段数据不需要)进行解密和签名验证。
以上操作在每一次 Page Fault
时都会发生,如果在启动 APP
时,存在大量的 Page Fault
情况,势必影响启动速度。
二、 什么是二进制重排
频繁的发生 Page Fault
会影响启动速度,那么,是否可以干预 Mach-O
的 _TEXT
段函数的映射顺序,将 APP
启动时需要用到的方法集中在一页或几页呢?答案是肯定的,二进制重排的原理就是字面上的理解,通过减少 Page Fault
发生次数,减少启动耗时。
理论上 Page Fault
确实会影响启动速度,但影响的大小要区分看待。一般来说,是要在常规的优化手段都做完之后,再考虑进行二进制重排。且对于小型APP来说,如果本身启动时执行的方法并不算多,那么二进制重排的意义就不是很大。
对于 iOS 13
系统来说,由于启用了 dyld3
,Page Fault
发生时已经不需要执行解密验签(提前生成了 lauch closure
文件),对性能的影响就更小了。
三、 System Trace 查看耗时
建议重装应用
- 选中指定的设备,选中安装的 App 点击
[*]
按钮,应用第一个页面(非启动页)显示后停止。 - 找到自己的项目
- 选中
Main Thread
- 选中
Virtual Memory
File Backed Page In
次数就是 Page Fault
的次数。「梦见账本」耗时 341ms
。
点击这里的小箭头,可以看到调用堆栈
当然,我们不可能人工的来整理这些。那么有什么办法可以获取到所有调用呢?
四、 获取启动时调用的所有方法
现有方案对比
- hook
objc_msgSend
- 只能捕获基于
objc
的方法调用
- 只能捕获基于
- 静态扫描 MachO 文件里的符号和函数数据 + 解析 Trace 文件
- 容易获取
+load
、C++构造函数
- initialize hook不到
- 部分block hook不到
- C++通过寄存器的间接函数调用静态扫描不出来
- 容易获取
- 编译器插桩
Clang
- 可以拿到
OC
、Swift
、C
、block
全部调用
- 可以拿到
五、 Clang 插桩
5.1 基于 Clang SanitizerCoverage 的方案
SanitizerCoverage
是 Clang
内置的一个代码覆盖工具。它把一系列以 __sanitizer_cov_trace_pc_
为前缀的函数调用插入到用户定义的函数里,借此实现了全局 AOP
。其覆盖了/ Swift/Objective-C/C/C++
等语言,Method/Function/Block
全支持。
开启 SanitizerCoverage
的方法是:
- 在
build settings
里的Other C Flags
中添加-fsanitize-coverage=func,trace-pc-guard
- 如果含有
Swift
代码的话- 需要在
Other Swift Flags
中加入-sanitize-coverage=func
和-sanitize=undefined
- 需要在
- 所有链接到
App
中的二进制都需要开启SanitizerCoverage
,这样才能完全覆盖到所有调用- 例如Pod库,就要在Target里设置
在SanitizerCoverage中可以看到
LLVM
官方对SanitizerCoverage
的详细介绍,包含了示例代码。
5.2 获取 order 文件
这里直接使用了AppOrderFiles来进行获取。就不贴代码了,源码也不多,有兴趣可以自行查看。
在 AppDelegate
中调用:
// 我是放在 `func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool` 最后调用的
AppOrderFiles { path in
if let path = path { print("AppOrderFiles: \(path)") }
}
复制代码
安装运行一次后,从 Xcode
获取应用的设备的 .xcappdata
文件中按照路径,取到 app.order
文件。
5.3 设置 order 文件路径
没必要加入
Bundle
5.4 验证顺序 LinkMap
开启 LinkMap
文件输出
编译获取 LinkMap
先在项目的 Product 文件夹中找到 .app
的目录
再按如图所示路径找到 linkmap
文件
对比 linkmap
和 order
文件
搜索 Address Size File Name
发现顺序是一样的了
5.5 System Trace 检查效果
只有 141ms
了,优化了一大半。具体效果根据不同项目会有所不同。