IOS冷启动监控与针对二进制重排的启动优化
背景
随着项目的越来越大,所用的各种库,无论是动态库还是静态库越来越多,哪怕是我们的项目使用的是Flutter,也不可避免使得冷启动的时间慢慢拉长,那么针对冷启动的时间监控与相对的优化势在必行,下面我主要分三个方面对最近的工作做个总结:
- 冷启动时间监控
- 对动态库各个类hook静态load函数计算加载数量与其二进制大小
- 二进制重排减少page fault优化启动时间
注: 关于常规的优化启动的方法有很多,很全,而且我们是Flutter项目,大部分方法并不适用,在这我就不介绍了
冷启动时间监控
流程
说到冷启动监控必须要了解整个冷启动,IOS系统都做了哪些事情:
其实从头说就是:
(1)pre-main阶段
1、当手点击APP的时候,进程会收到信息,会调用镜像进程,执行exec()
2、加载应用的可执行文件
3、加载dyld(Dynamic Linking Loader)动态链接库加载器
4、进行 rebase指针调整,和bind符号绑定:
rebase:主要是在地址偏移后对自身内部函数地址调整
bind:是外部指向的指针做偏移调整
5、object-c的runtime库初始化(ObjC setup): OC相关的Class注册、category方法插入对应Class method列表、selector唯一性检查等
6、初始化(Initializers): 执行 +load 方法、attribute((constructor))修饰的函数调用、创建C++静态全局变量等
(2)main()阶段:
1、dyld调用main
2、调用UIApplicationMain()
3、调用applicationWillFinishLaunching
4、调用didFinishLaunchingWithOptions
监控
苹果公司并没有直接向开发者提供内部统计时间字段以供开发者直接获取App等启动时刻开始时刻点,目前行业内主要有两种标准标准作为APP的启动时间点:
标准
第一种标准: Initializers阶段 +load方法被调用时的时间点,通过hook所有动态库的+load方法来统计时间,但是缺点明显Initializers之前时间没有统计,但是有借鉴意义
第二种标准:获取进程创建开始时间开始计算然后
注:获取进程信息的方法,还用来APP破解方面的应用大家感兴趣可以参考:www.jianshu.com/p/2bbec8c8c…
实现
#import "ASAppLaunchTime.h"
#import <sys/sysctl.h>
#import <mach/mach.h>
double __time1__; // 创建进程时间
double __time2__; // before main
double __time3__; // didFinish
double __time4__; // renderFinish
@implementation ASAppLaunchTime
+ (CFAbsoluteTime)processStartTime {
if (__time1__ == 0) {
struct kinfo_proc procInfo;
int pid = [[NSProcessInfo processInfo] processIdentifier];
int cmd[4] = {CTL_KERN, KERN_PROC, KERN_PROC_PID, pid};
size_t size = sizeof(procInfo);
if (sysctl(cmd, sizeof(cmd)/sizeof(*cmd), &procInfo, &size, NULL, 0) == 0) {
return procInfo.kp_proc.p_un.__p_starttime.tv_sec * 1000.0 + procInfo.kp_proc.p_un.__p_starttime.tv_usec / 1000.0;
}
}
return __time1__;
}
+ (void)didFinshTime {
dispatch_async(dispatch_get_main_queue(), ^{ // 确保didFihish代码执行后调用
if (__time3__ == 0) {
__time3__ = CFAbsoluteTimeGetCurrent() + kCFAbsoluteTimeIntervalSince1970;
}
});
}
+ (void)renderFinish {
double __time1__ = [ASAppLaunchTime processStartTime];
dispatch_async(dispatch_get_main_queue(), ^{ // 确保didFihish代码执行后调用
if (__time4__ == 0) {
__time4__ = CFAbsoluteTimeGetCurrent() + kCFAbsoluteTimeIntervalSince1970;
}
double pret = __time2__ - __time1__ / 1000;
double didFinish = __time3__ - __time2__;
double renderFinish = __time4__ - __time3__;
double total = __time4__ - __time1__ / 1000;
NSLog(@"----------App启动---------耗时:pre-main:%f",pret);
NSLog(@"----------App启动---------耗时:didfinish:%f",didFinish);
NSLog(@"----------App启动---------耗时:renderFinish:%f",renderFinish);
NSLog(@"----------App启动---------耗时:total:%f",total);
});
}
@end
void static __attribute__ ((constructor)) before_main() {
if (__time2__ == 0) {
__time2__ = CFAbsoluteTimeGetCurrent() + kCFAbsoluteTimeIntervalSince1970;
}
}
我主要分为四个阶段:
1、main函数之前
2、执行didFinishLaunchingWithOptions函数
3、渲染完毕,主ViewController执行viewDidAppear
Hook load方法得到load加载时间
这个要提前说明下IOS的加载机制,一个Class什么时候加载,现在IOS将Class分成no lazy、lazy类型,一般的类与分类,都是lazy类型,按字面意思就可以看出,OC的类是动态加载的,而不是启动时一口气加载出来,但是如果一个类时 lazy类型,但是如果重写了+load或是分类重写了+load,则这个Class,将变成no lazy类型,所以我们第一时间想到的借用objc_copyClassNamesForImage和objc_getClass来hook所有的类的+load的方式是行不通的,因为一旦调用了objc_getClass,这个类将触发realize操作,将原本lazy类型转化为no lazy类型,从而拉长启动时间,那该怎么做呢?
方法:
- 其实我们可以读取编译时写入mach-o文件DATA段段
__objc_nlclslist和__objc_nlcatlist节,这两节分别用来保存no lazy class 列表和 no lazy category 列表,是no lazy结构,这里面就定义了+load方法的类和分类,这样我们就可以愉快的不用担心误差问题了
// 拿到+load
static NSArray <LMLoadInfo *> *getNoLazyArray(const struct mach_header *mhdr) {
NSMutableArray *noLazyArray = [NSMutableArray new];
unsigned long bytes = 0;
Category *cats = getDataSection(mhdr, "__objc_nlcatlist", &bytes);
for (unsigned int i = 0; i < bytes / sizeof(Category); i++) {
LMLoadInfo *info = [[LMLoadInfo alloc] initWithCategory:cats[i]];
if (!shouldRejectClass(info.clsname)) [noLazyArray addObject:info];
}
bytes = 0;
Class *clses = (Class *)getDataSection(mhdr, "__objc_nlclslist", &bytes);
for (unsigned int i = 0; i < bytes / sizeof(Class); i++) {
LMLoadInfo *info = [[LMLoadInfo alloc] initWithClass:clses[i]];
if (!shouldRejectClass(info.clsname)) [noLazyArray addObject:info];
}
return noLazyArray;
}
// hook +load
static void hookAllLoadMethods(LMLoadInfoWrapper *infoWrapper) {
unsigned int count = 0;
Class metaCls = object_getClass(infoWrapper.cls);
Method *methodList = class_copyMethodList(metaCls, &count);
for (unsigned int i = 0; i < count; i++) {
Method method = methodList[i];
SEL sel = method_getName(method);
const char *name = sel_getName(sel);
if (!strcmp(name, "load")) {
IMP imp = method_getImplementation(method);
LMLoadInfo *info = [infoWrapper findLoadInfoByImp:imp];
if (!info) {
info = [infoWrapper findClassLoadInfo];
if (!info) continue;
}
swizzleLoadMethod(infoWrapper.cls, method, info);
}
}
free(methodList);
}
// 在main函数执行前统计时间
__attribute__ ((constructor)) static void LoadMeasure_Initializer(void) {
CFAbsoluteTime begin = CFAbsoluteTimeGetCurrent();
unsigned int count = 0;
const struct mach_header **mhdrList = copyAllSelfDefinedImageHeader(&count);
NSDictionary <NSString *, LMLoadInfoWrapper *> *groupedWrapperMap = prepareMeasureForMhdrList(mhdrList, count);
for (NSString *clsname in groupedWrapperMap.allKeys) {
hookAllLoadMethods(groupedWrapperMap[clsname]);
}
free(mhdrList);
LMLoadInfoWappers = groupedWrapperMap.allValues;
CFAbsoluteTime end = CFAbsoluteTimeGetCurrent();
printf("\n\t\t\t\t\tLoad Measure Initializer Time: %f ms\n", (end - begin) * 1000);
}
LinkMap
怎么找到
LinkMap链接完库之后的产物,在xcode默认是不生产的要在项目的Build Settings设置Write Link Map File为YES,才会生成,如图:
那要怎么找到呢,在工程找到APP:
右键show in Finder
在Intermediates.noindex文件夹下,剩下按照路径找就行了
LinKMap结构
文件结构分四部分大概是这样:
其实我们研究LinkMap可以很容易看出它经过编译后的每个文件二进制文件大小内存大小,例如每个对象的方法的偏移量和方法个事就能算出这个类的二进制大小,由于时间有限我在这就不做过多介绍了
针对二进制重排的启动优化
重排原理
Page Fault
进程如果能直接访问物理内存无疑是很不安全的,所以操作系统在物理内存的上又建立了一层虚拟内存。为了提高效率和方便管理,又对虚拟内存和物理内存又进行分页(Page)。当进程访问一个虚拟内存Page而对应的物理内存却不存在时,会触发一次缺页中断(Page Fault),分配物理内存,有需要的话会从磁盘mmap读人数据。
通过App Store渠道分发的App,Page Fault还会进行签名验证,所以一次Page Fault的耗时比想象的要多:
Page Fault
重排
编译器在生成二进制代码的时候,默认按照链接的Object File(.o)顺序写文件,按照Object File内部的函数顺序写函数。
假设我们只有两个page:page1/page2,其中绿色的method1和method3启动时候需要调用,为了执行对应的代码,系统必须进行两个Page Fault。
但如果我们把method1和method3排布到一起,那么只需要一个Page Fault即可,这就是二进制文件重排的核心原理。
优化一个Page Fault,启动速度提升0.6~0.8ms
核心问题
为了完成重排要考虑以下几个问题:
- 怎么获取page fault次数
- 拿到当前二进制的函数布局
- 怎么获取启动时用到了哪些函数
- 怎么指定生产我们想要的二进制文件
获取page fault次数:System Trace
日常开发中性能分析是用最多的工具无疑是Time Profiler,但Time Profiler是基于采样的,并且只能统计线程实际在运行的时间,而发生Page Fault的时候线程是被blocked,所以我们需要用一个不常用但功能却很强大的工具:System Trace。
选中主线程,在VM Activity中的File Backed Page In次数就是Page Fault次数,并且双击还能按时序看到引起Page Fault的堆栈:
二进制的函数布局
前文说的LinkMap就可以
获取启动时用到了哪些函数: Clang 插桩
这里需要用到 Tracing PCs文档.根据文档里面的提示,在Build Settings 里面搜索 Other C Flags 添加
-fsanitize-coverage=func,trace-pc-guard
因为还需要知道有哪些swift函数所以搜索 Other swift Flags, 添加-sanitize-coverage=func、
-sanitize=undefined
然后在在任意文件添加___sanitizer_cov_trace_pc_guard_init、___sanitizer_cov_trace_pc_guard两个方法,这两个函数在全局的每个方法或事block调用时最后绑定的回掉方法,在通过DI_info我们可以轻易拿到每一个函数名,为了方便我们用一个链表来记录所有调用的函数,因为有些函数是异步调用我们还需要定义一个原子队列
#import "ASTestView.h"
#import <sanitizer/coverage_interface.h>
#import <dlfcn.h>
#import <libkern/OSAtomic.h>
static OSQueueHead symbolQue = OS_ATOMIC_QUEUE_INIT;
typedef struct {
void * pc;
void * next;
} ASNode;
// 得到符号的总个数和总大小
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.
}
// 所有的函数名
void __sanitizer_cov_trace_pc_guard(uint32_t *guard) {
if (!*guard) return; // Duplicate the guard check.
void * PC = __builtin_return_address(0);
// 1.
// Dl_info info;
// dladdr(PC, &info);
// printf("fanme: %s\n fbase: %s \n sanem: %s \n saddr: %p\n", info.dli_fname, info.dli_fbase, info.dli_sname, info.dli_saddr);
// 2.
// printf("%s \n", info.dli_sname);
// 3.
ASNode * node = malloc(sizeof(ASNode));
*node = (ASNode){PC, NULL};
// 结构体入队列
OSAtomicEnqueue(&symbolQue, node, offsetof(ASNode, next));
}
指定生产我们想要的二进制文件:.order
在Build Settings 里面搜索order
发现是空的,我们自己创建一个order文件
我们再在ASTestView 创建3个方法
build之后LinkMap中查找方法名
顺序是在中间位置
我们重新编辑test.order文件并把它的路径加入到Order File上
重新build
果然是有效的
那么我们需要将拿到的函数名写进文件导出来就ok了
@implementation ASTestView
- (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event {
NSMutableArray<NSString *> * symbolNames = [NSMutableArray array];
ASNode * node;
while ((node = OSAtomicDequeue(&symbolQue, offsetof(ASNode, next)))) {
Dl_info info;
dladdr(node -> pc, &info);
// printf("dli_sname == %s \n", info.dli_sname);
NSString * name = @(info.dli_sname);
bool isOC = [name hasPrefix:@"+["] || [name hasPrefix:@"-["];
NSString * symbolName = isOC ? name : [@"_" stringByAppendingString:name];
[symbolNames insertObject:symbolName atIndex:0];
// NSLog(@"symbolName == %@", symbolName);
}
// 去重
NSEnumerator * em = [symbolNames objectEnumerator];
NSMutableArray * funcs = [NSMutableArray arrayWithCapacity:symbolNames.count];
NSString * name;
while (name = [em nextObject]) {
if (![funcs containsObject:name]) {
[funcs addObject:name];
}
}
// 去掉自己
[funcs removeObject:[NSString stringWithFormat:@"%s", __func__ ]];
NSLog(@"funcs == %@", funcs);
// 写入文件
/// 变成字符串
NSString * funcsStr = [funcs componentsJoinedByString:@"\n"];
/// 生产路径
NSString * filePath = [NSTemporaryDirectory() stringByAppendingString:@"/as.order"];
NSData * file = [funcsStr dataUsingEncoding:NSUTF8StringEncoding];
/// 写入
[[NSFileManager defaultManager] createFileAtPath:filePath contents:file attributes:nil];
}
等等写的文件在哪呢怎么找:
选中工程,点击设置
点击中间那个选项
放在想放的位置
双击查看包内容
重排成功查看结果
这样就拿到order文件了,我们用生成文件试试
对比上面大概减少了 800次的page fault 和142ms,这还只是debug状态下