使用二进制重排 & Clang插桩技术点来进行iOS冷启动进行优化
1.冷启动
1.1 什么是冷启动?
冷启动是指内存中不包含该应用程序相关的数据,必须要从磁盘载入到内存中的启动过程。
注意:重新打开 APP, 不一定就是冷启动。
- 当内存不足,APP被系统自动杀死后,再启动就是冷启动。
- 如果在重新打开 APP 之前,APP 的相关数据还存储在内存中,这时再打开 APP,就是热启动
- 冷启动与热启动是由系统决定的,我们无法决定。
- 当然设备重启以后,第一次打开 APP 的过程,一定是冷启动。
1.2 如何统计冷启动耗时?
一般来讲,统计 APP 启动时长,以 main 函数为节点 ,分两个大阶段:
- main 函数之后的代码,是我们自己写的,我们可以自行统计进入 main 函数到第一个界面显示的耗时。
-
- 在 main 函数里打印一下当前的时间,
- 在第一个要显示的控制器的 viewDidLoad 方法中打印一下当前时间
- 两个时间的差值,即为main函数后的加载时长。
- main 函数之前,为 pre-main 阶段,由于是系统在做事情,这段时间的 耗时,我们没办法直接统计,需要查 看系统给我们的反馈。
1.2.1 pre-main阶段都做了什么?
接下来看一下项目中的 pre-main 阶段的耗时。
- 查看系统给的反馈需要 增加一个环境变量,
- 增加路径:在 Xcode -> Edit Scheme -> Run -> Arguments -> Environment Variables 中,
- 增加一个环境变量 DYLD_PRINT_STATISTICS:1。
下图是我项目的加载耗时:
耗时过程分为以下4部分:
- dylib loading time : 是指动态库加载的耗时,系统的动态库做过优化,耗时较少。 苹果官方推荐最多不要超过6个外部动态库,多余6个,需要考虑合并动态库,合并动态库对于启动时期的优化,非常有效。 像微信的动态库早期有八九个,现在也优化成6个了。
- rebase/binding
- rebase:是指地址的 偏移修正耗时。
-
- 在编译生成二进制文件的时候,每个函数都有一个地址,这个地址是相对于二进制文件的偏移地址。
- 在启动时,也就是在二进制文件在加载到虚拟内存的时候,为了安全起见,苹果有个安全机制(ASLR),会在整个二进制文件的最前面,随机加一个偏移值。
- 比如 A 函数,相对于二进制文件的偏移值是 0x003。 启动时,整个二进制文件被分配了一个随机值0x100。 那么 A 函数在内存中的实际地址是 0x003 + 0x100 = 0x103。
- 偏移修正指的就是计算方法在虚拟内存中的地址的过程!
- binding: 动态库的方法绑定,是指将方法名字与方法的实现进行绑定过程的耗时。
-
- 比如 NSLog 方法,在加载的时候需要先找到Foundation库,再找到库里的NSLog的方法的实现,将方法名字和方法实现绑定在一起。
- Objc setup time: 注册所有 OC类 耗时, 类越多耗时越多,有人统计过2万个自定义的OC的类,大概耗时800毫秒。删除不用的类,可以减少耗时。
- initializer time: load方法 和 C++构造函数的耗时. 减少重写load方法,尽量将事情延迟到 main 方法以后,可以减少耗时。
- slowest intializers : 指出了最耗时的几个库是下面的6个库(最后一个是我的项目)。
1.2.2 pre-main阶段耗时优化方法总结:
- 减少外部动态库的数量
- 不用的类和方法,删除
- 类尽量使用懒加载,也就是尽量不要重写load方法。
- 启动时加载的数据使用多线程
- 使用纯代码。不用xib storyboard(要额外进行代码解析转换和页面的渲染)
以上方法,都是和自己的项目代码息息相关的优化方案。不同项目具体是实施动作不一样。
还有一个优化方法,不管是什么项目,实施动作都一样 ,对什么项目都有效,那就是二进制重排!
2. 二进制重排
学习二进制重排,首先要知道数据是如何加载到内存中的 。
☞内存加载机制:数据是如何加载到内存中的
我们已经知道数据加载到内存的过程,当虚拟内存页还没有对应的物理内存页时,会出现缺页异常(PageFault)。
当冷启动时,物理内存中是没有数据的,这时会出现大量的缺页异常,在iOS生产环境的app,在发生Page Fault进行重新加载时,iOS系统还会对其做一次签名验证,因此 iOS 生产环境的 Page Fault 比Debug环境下所产生的耗时更多
这里有没有优化空间呢?接下来就是优化方案:二进制重排!
在了解二进制重排之前,再了解下在项目编译生成二进制文件的时候,类及其内部方法实现的排列顺序是什么样的呢?
2.2.1 二进制文件中方法实现排序是什么样的?
- 在 viewController 中,先随便写几个方法。
- 再看下源文件的编译顺序
接下来查看 Link Map文件查看符号顺序, 查看方式:
- 打开link map
****
- 编译生成link map 文件
- 找到link map 文件
- 项目目录中,生成的 app 右键,show in Finder
- 找到 app 的上上级目录
- 进入Intermediates.noindex -> TraceDemo.build -> Debug-iphonesimulator -> TraceDemo.build -> TraceDemo-LinkMap-normal-x86_64.txt
- 打开link map 文件,找到自己的类及方法的名字
5.我们可以直观的看出 link map中符号的顺序,类是以源文件的编译顺序,从上到下按序排列。方法名是以类中方法的书写顺序,由上到下排序。
2.2.2 为什么需要二进制重排?
从源码的执行顺序上看,应该是 load -> test2 -> viewDidLoad -> test1.
但是二进制文件中符号的顺序是方法从上到下的书写顺序,没有按照调用顺序去排列。
在冷启动分页加载二进制文件时,发现很多页中都有启动时需要用到的方法,那么即使页里面也存在启动时不需要的方法,但是由于内存是分页管理的,要加载就要整页加载。这样就导致了大量不需要在 pre-main 阶段执行的方法,也会被加载到内存中,增加了启动的耗时。
\
例如,启动需要加载100个页,每个页可以包含20个方法。但是每个页里只有2个方法是启动时后用到的。这样实际上启动时必须要的方法是2 * 100 = 200个,如果将这200个方法紧挨着放在一起,那么只需要2页。比100个页,减少了98页。这样耗时就会大大降低。
2.2.3 如何进行二进制重排?
1. 二进制重排的方法
在项目编译生成二进制文件的时候,找到启动时需要的方法,并且将它们放在一起 重新排序,这就是二进制重排。
两个关键点: 找到启动时需要方法 & 方法 的重排序
2.方法的重排序:
重排序其实很简单。xcode已经为我们提供了这个机制,它使用的链接器叫做 ld, ld有一个参数叫做Order File, 我们可以通过配置order文件,来使编译时生成的二进制的文件的Link Map种的符号顺序,按照我们指定的顺序排列生成。而且 libobjc 实际上也做了二进制重排 。
【第一步】在项目根目录下建一个xxx.order的文件,里面写上按照自己想排列的顺序,写上方法或者函数的名字。(如果写了一个不存在的符号,也不会报错,会被自动过滤掉~)
【第二步】在 Build Settings 搜索order file 的文件。将项目根目录创建的文件,设置上去。
【第三步】重新编译,查看 Link Map 文件的顺序,果然,按照我们指定的顺序排列啦!
3. 静态插桩 - 找到冷启动时的所有方法
接下来,需要做的就是写入 order 文件里的符号了,我们不可能手写上所有的启动时需要的执行的符号,这里的所有符号包括,调用的方法、函数、C++构造方法、swift方法、block。
这里使用 LLVM 内置的简单代码覆盖率检测工具(SanitizerCoverage)。它在边缘、 函数、基本块 级别上插入对用户定义函数的调用。
edge
(默认):检测边缘(所有的指令跳转都会被插入对用户定义函数的调用, 如循环、分支判断、方法函数等)。bb
:检测基本块。func
:仅将检测每个 功能的输入块(这个就是我们要重排序的符号)。
按照文档,
- 【第1步】搜索并设置 Other C Flags/ Other C++ Flags 为 -fsanitize-coverage=func,trace-pc-guard (这里要用func, 不能用默认的edge, 不然会造成死循环)。
- 如果有swift ,需要设置 Other Swift Flags 设置为 **** -sanitize-coverage=func -sanitize=undefined
- 【第2步】编译器将插入对模块构造函数的调用,所以我们要实现这个方法:
__sanitizer_cov_trace_pc_guard_init(uint32_t *start, uint32_t *stop);
通过打印start, stop 地址的内容,从 start 地址开始,到 stop 地址的前4位,存储的是 uint32 的 1-19的数字。
我们可以从这个函数中知道, 当前项目中自定义的功能输入块的数量。
- 【第3步】编译器会在生成二进制文件的时候,在每个func调用之初,插入以下代码:
__sanitizer_cov_trace_pc_guard(&guard_variable)
也就是说,每个方法在执行的时候,都会调用上面这个方法。 接下来:
-
-
- 我们要实现这个方法,并在这个方法里,获取到本方法结束后要返回的地址
-
// 获取到本方法结束后,要返回的地址去,这个地址包含在被hook的方法内部,但不是被hook 的方法的首地址
void *PC = __builtin_return_address(0);
-
-
- 并将地址保存一个系统的原子队列( ( 底层实际上是个栈结构 , 利用队列结构 + 原子性来保证顺序 ))中,使用原子队列,是为了防止多线程资源抢夺。原子队列的存值方法如下:
-
// 将结构体存入到原子队列中。
// offsetof(type,member) 返回结构体中成员的偏移值,由于指针PC是8字节,所以这里返回8字节。
// 详见下图
OSAtomicEnqueue(&symbolList, node, offsetof(SYNode, next));
每个 SYNode 首地址都距离上一个偏移 PC 所占的字节数。这样做的妙处就是,每个 SYNode 的 next 的地址,恰巧就是下一个结构体的地址。这样方便获取队列里面的所有数据。
- 【第4步】我们在点击屏幕的事件中
-
- 把存储到原子队列中的地址遍历出来,
- 根据地址获取当前地址所在的方法的名称并存入数组中,
typedef struct dl_info {
const char *dli_fname; /* 所在文件 */
void *dli_fbase; /* 文件地址 */
const char *dli_sname; /* 符号名称 */
void *dli_saddr; /* 函数起始地址 */
} Dl_info;
//这个函数能通过函数内部地址找到函数符号
int dladdr(const void *, Dl_info *);
-
- 由于原子队列是栈结构,先进后出,所以我们需要将数组倒序排列
- 由于方法可能会被多次调用,我们需要去重
- 再将最后我们当前点击屏幕的方法删除掉
- 将方法名字的数组,转成字符串,写到沙盒文件中
完整代码如下:
//
// ViewController.m
// TraceDemo
//
// Created by hank on 2020/3/16.
// Copyright © 2020 hank. All rights reserved.
//
#import "ViewController.h"
#import <dlfcn.h>
#import <libkern/OSAtomic.h>
#import "TraceDemo-Swift.h"
@interface ViewController ()
@end
@implementation ViewController
+(void)initialize
{
}
void(^block1)(void) = ^(void) {
};
void test(){
block1();
}
+(void)load
{
}
- (void)viewDidLoad {
[super viewDidLoad];
[SwiftTest swiftTestLoad];
test();
}
-(void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event
{
NSMutableArray <NSString *> * symbolNames = [NSMutableArray array];
while (YES) {
SYNode * node = OSAtomicDequeue(&symbolList, offsetof(SYNode, next));
if (node == NULL) {
break;
}
Dl_info info;
dladdr(node->pc, &info);
NSString * name = @(info.dli_sname);
BOOL isObjc = [name hasPrefix:@"+["] || [name hasPrefix:@"-["];
NSString * symbolName = isObjc ? name: [@"_" stringByAppendingString:name];
[symbolNames addObject:symbolName];
}
//取反
NSEnumerator * emt = [symbolNames reverseObjectEnumerator];
//去重
NSMutableArray<NSString *> *funcs = [NSMutableArray arrayWithCapacity:symbolNames.count];
NSString * name;
while (name = [emt nextObject]) {
if (![funcs containsObject:name]) {
[funcs addObject:name];
}
}
//移除本方法
[funcs removeObject:[NSString stringWithFormat:@"%s",__FUNCTION__]];
//将数组变成字符串
NSString * funcStr = [funcs componentsJoinedByString:@"\n"];
NSString * filePath = [NSTemporaryDirectory() stringByAppendingPathComponent:@"demo.order"];
NSData * fileContents = [funcStr dataUsingEncoding:NSUTF8StringEncoding];
[[NSFileManager defaultManager] createFileAtPath:filePath contents:fileContents attributes:nil];
NSLog(@"%@",funcStr);
}
void __sanitizer_cov_trace_pc_guard_init(uint32_t *start,
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.
}
//原子队列
static OSQueueHead symbolList = OS_ATOMIC_QUEUE_INIT;
//定义符号结构体
typedef struct {
void *pc;
void *next;
}SYNode;
void __sanitizer_cov_trace_pc_guard(uint32_t *guard) {
// 会导致load 方法被return
// if (!*guard) return;
// 获取到本方法结束后,要返回的地址去,这个地址包含在被hook的方法内部,但不是被hook 的方法的首地址
void *PC = __builtin_return_address(0);
SYNode *node = malloc(sizeof(SYNode));
*node = (SYNode){PC,NULL};
//进入
OSAtomicEnqueue(&symbolList, node, offsetof(SYNode, next));
}
@end
2.2.4 如何验证二进制重排的效果?
1.查看缺页异常数量Page Fualt:
- 查看一下项目的缺页异常数量。注意需要卸载 APP 或者重启手机,来保证这个APP完全没有被加载到内存中,因为如果物理内存中有该APP的数据,
- 打开 Instrument -> System Trace
3.选择真机、项目、点击启动,当第一个页面显示出来后,点击停止。
- xcode 12搜索main thread, 选择Virtual Memory,File Backed Page in 就是缺页异常的数量
优化前:项目的缺页遗产数量是427
优化后:
优化前:项目的缺页遗产数量是286
减少了启动时大概40%的缺页异常~
3.自动更新order 文件
随着代码迭代,order文件需要更新,每次手动更新很麻烦,所以需要自动更新。
brew install ios-deploy
APP_ORDER_DIR=appOrderDir
APP_ORDER=./$APP_ORDER_DIR/Documents/app.order
mkdir $APP_ORDER_DIR
ios-deploy --download=/Documents --bundle_id $PRODUCT_BUNDLE_IDENTIFIER --to ./$APP_ORDER_DIR
if [ -e $APP_ORDER ] ;then
cp -f $APP_ORDER ./Resource/app.order
fi
rm -r $APP_ORDER_DIR
【补充xcode13】查看缺页异常的方式
选择真机、项目、点击启动,当第一个页面显示出来后,点击停止。