App的启动类型
- 冷启动:系统里没有任何进程的缓存信息,典型的是重启手机后直接启动APP;
- 热启动:系统内存在进程的缓存信息,这时启动App,属于热启动,例如第一次冷启动App后,然后将App进程杀死,立刻再次重新启动App,因为进程缓存还在所以属于App的热启动;
App的启动时间
- App的启动时间划分是将Application的Main()函数作为分割点,分割成两块;
- main()之前的处理时间,称之为pre-main;
- main()及main()之后的处理时间,以首页第一帧画面显示为终点;
pre-main阶段
-
pre-main
这个时间段App的底层执行流程在 iOS 逆向07 -- Dyld动态加载器,iOS底层系列15 -- dyld与objc之间的关联
这两篇文章中做了非常详细的介绍,重要流程包括:Load Dylibs动态库的加载(镜像文件的生成)
,Rebase重定位与Bind绑定链接
,运行时初始化,类的初始化,分类的合并
,Initializers阶段
;
Load Dylibs动态库(镜像文件的加载)
- 过程描述:将动态库(系统或自定义)与主应用程序创建成各自独立的镜像文件image,然后逐步加载进入内存;
- 优化方案:动态库加载越多,启动时间越慢,那么可移除不需要用到的动态库,自定义动态库数量过多,可以考虑进行合并,减少库的数量;
Rebase重定位与Bind绑定
- iOS系统在将Mach-O文件载入内存时,使用了ASLR技术(地址空间布局随机化),会在Mach-O文件的起始内存地址新增一段随机的偏移量,所以Mach-O文件中各指针真实的内存地址=之前的内存地址+ASLR随机生成的偏移量,
Rebase重定位就是来修正这个随机偏移量的,让Mach-O文件内部的指针指向正确的内存地址
,性能消耗主要在于IO; - Bind主要用于符号的地址绑定,我们知道dyld会加载所有需要的动态库,创建为独立的镜像文件,这些镜像文件之间会存在依赖,例如镜像A会调用镜像B中的某个函数,也就是说镜像A会外部引用镜像B中函数地址,在dyld加载各自动态库的时候,镜像A的外部引用指针是假的地址,在动态库进行链接的时候,链接器会计算更新成正确的地址,这个过程就是所谓的符号地址的绑定,即
修正指向镜像外部的指针
,性能消耗主要在于CPU计算; - 优化方案:Rebase重定位与Bind符号绑定主要是涉及指针的修正,那么可尽量减少指针的数量,例如减少ObjC类(class)、⽅法(selector)、分类(category)的数量;
RunTime初始化,类的实现初始化,分类的合并
- RunTime初始化,Objc执行map_images函数,完成所有类的注册,实现,初始化,分类category的方法合并到类class;
- 优化方案:尽量减少类与分类的数量,分类能合并的尽量合并;
Initializers阶段
- Objc完成map_images函数执行,接着会执行load_images函数,完成所有Objc类与分类的load方法调用,调⽤C/C++中的构造器函数(⽤attribute((constructor))修饰的函数),和创建⾮基本类型的C++静态全局变量;
- 优化方案:不要在类的load方法中,执行耗时操作,可将其延迟到initiailize方法中去执行, 减少构造器函数个数,不要在构造函数中执行耗时操作;
pre-main阶段的耗时计算
- 第一种方案:在Xcode中的Edit scheme --> Run --> Arguments 添加环境变量
DYLD_PRINT_STATISTICS
value值为1,运行App,控制台可打印启动时⻓,如下所示:
main()及main()之后的处理阶段
- 从
- (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions
函数开始到首页控制器的- (void)viewDidLoad
函数结束作为此阶段的耗时时长,可利用第三方工具 BLStopwatch,进行统计; - 在工程中分别加入下列代码,如下所示:
- (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions {
[[BLStopwatch sharedStopwatch] start];
//其他逻辑......
[[BLStopwatch sharedStopwatch] splitWithDescription:@"didFinishLaunchingWithOptions"];
return YES;
}
- (void)viewDidLoad {
[super viewDidLoad];
[[BLStopwatch sharedStopwatch] refreshMedianTime];
//其他逻辑......
[[BLStopwatch sharedStopwatch] splitWithDescription:@"HomeVC viewDidLoad"];
[[BLStopwatch sharedStopwatch] stopAndPresentResultsThenReset];
}
- App运行结果如下所示:
- 主要涉及业务逻辑,可根据业务代码进行优化即可;
基于二进制重排优化App的启动速度
- 在阐述二进制重排之前,首先我们需要了解以下知识点;
物理内存与虚拟内存
- 在早期的计算机中,程序时直接运行在物理内存上的,也就是说,程序在运行时所访问的地址都是物理地址,例如假设计算机有128MB内存,程序A运行时需要10MB,程序B需要100MB,程序C需要20MB,现在同时运行程序A和B,那么可直接将前10MB内存分配给程序A,10MB-110MB分配给B,这种简单的内存分配策略存在很多问题;
- 第一点:地址空间不隔离,所有程序都直接访问物理内存地址,那么可通过内存地址的偏移就能访问别的程序进程的内存空间,这样就很容易就能修改其他程序的内存数据,非常的不安全;
- 第二点:内存使用效率低,若现在我们需要运行C程序,但内存已经不够用了,我们只能将A或者B从内存中换出到磁盘,然后将C读入到内存开始运行,在整个过程中有大量数据在换入换出,导致效率非常低下;
- 第三点:程序运行的地址不确定,程序每次装载进入内存时,分配的物理内存地址都是不确定的,而访问数据和指令跳转时的目标地址很多都是固定的,这样就会频繁进行重定位的问题;
- 为了解决上述问题,就引出了
虚拟内存地址
的概念,整体思想是:我们给程序分配虚拟的内存地址,然后通过映射计算,将虚拟地址转换成实际的物理地址,我们只要妥善的控制虚拟地址到物理地址的映射过程,就可以保证任意一个程序所能够访问的物理内存区域与另一个程序相互不重叠,以达到地址空间隔离的效果;
分段(Segment)
- 所谓的分段:将程序的所有数据全部载入内存,若其他程序需要使用内存,可能系统内存不足,就会执行磁盘的换出换入操作,这样内存效率很低;
- 根据程序的局部性原理,当一个程序在运行时,在某个时间段内,它只是频繁地用到一小部分数据,也就是说,程序的很多数据其实在一个时间段内都是不会被用到的,那么我们可以使用更小粒度的内存分割和映射的方法,使得程序的局部性原理得到充分的利用,大大提高内存的使用率,这种方法就是分页(Paging);
分页(Paging)
- 将进程的虚拟地址空间按页进行分割,在Mac OS系统上每页为4KB,在iOS系统上每页为16KB,根据程序的局部性原理将常用的数据和代码页装载到内存中,把不常用的代码和数据保存在磁盘里,当需要用到的时候载把它从磁盘中取出来载入内存中;
- 现有两个进程Process1与Process2,它们进程中的部分虚拟页面被映射到物理页面,例如VP0,VP1,VP7映射到PP0,PP2,PP3,而有部分页面还在磁盘中,例如VP2,VP3位于磁盘的DP0和DP1中,另外还有一些页面VP4,VP5,VP6尚未被访问到,它们暂时处于未使用的状态,在这里我们将虚拟空间的页叫做
虚拟页
,把物理内存中的页叫做物理页
,把磁盘中的页叫做磁盘页
,下列图中的线表示映射关系,可以看到虚拟空间的虚拟页被映射到物理页,且两个进程的虚拟页被映射到同一个物理页,这样就可以实现内存的共享;
页错误(Page Fault)
- 程序装载进入内存,使用的是分页机制,即将需要使用的虚拟页映射到物理页,在上图中的VP2,VP3不在内存中,数据还在磁盘中,当程序运行需要用到这两个页的数据时,就会产生
缺页中断
,也就是所谓的页错误(Page Fault)
,然后操作系统接管进程,负责将VP2和VP3从磁盘中读取出来并且装入物理内存,然后将物理内存中这两个页与VP2与VP3虚拟内存页建立映射关系;
二进制重排
- 程序装载进入内存,使用分页机制,分页机制可能会导致缺页中断(Page Fault),缺页中断发生时,会阻塞当前主线程的执行,先去磁盘中加载需要的页进入物理内存,映射虚拟内存,当需要的页加载完成时,继续执行程序,缺页中断的处理是比较耗性能的,如果App在启动时,出现的页错误比较多,可能造成启动耗时;
- 至于二进制重排是指:将App在启动时所需要的数据页,放到一起,比如统统都放到前10页中,尽可能的减少页错误的次数,从而达到启动优化的目的;
- 我们可通过Instrument的
System Trace
工具,查看App在启动时,产生的缺页中断(Page Fault)的次数,如下所示:
查看本工程的符号顺序和调整符号顺序
- 创建App工程,如下所示:
- 查看App的符号执行顺序,主要是通过查看
LinkMap
文件,其是App编译期间的产物,记录了符号文件的顺序,可通过以下设置,生成LinkMap文件;
- 运行工程,然后在指定路径生成LinkMap文件;
- 首先 , Xcode 是用的链接器叫做 ld , ld 有一个参数叫 Order File , 我们可以通过这个参数配置一个 order 文件的路径,在这个 order 文件中 , 将你需要的符号按顺序写在里面,
当工程 build 的时候 , Xcode 会读取这个文件 , 打的二进制包就会按照这个文件中的符号顺序进行生成对应的 Mach-O; - 在Xcode中我们可以指定
Order File
文件路径,指向我们自己创建的文件lb.order
,设置如下:
- 在
lb.order
文件中我们可以自定义符号顺序,测试内容如下:
-[YYPerson walk]
-[YYPerson run]
-[ViewController viewDidLoad]
-[YYHomeViewController viewDidLoad]
- 是否使用
lb.order
文件,LinkMap文件中的符号顺序对比如下:
- 说明
lb.order
文件确实起到效果,它能改变MachO文件中的符号顺序;
如何获取App启动时的所有符号
- 利用clang插桩,可使用clang本身提供的一个工具
or机制
,实现如下: - 首先添加编译设置
-fsanitize-coverage=trace-pc-guard
,如下所示:
- 在工程中添加hook代码,我当前是添加到控制器
ViewController.m
,如下所示:
//
// ViewController.m
// 二进制重排
//
// Created by liyanyan33 on 2022/1/7.
//
#import "ViewController.h"
@interface ViewController ()
@end
@implementation ViewController
- (void)viewDidLoad {
[super viewDidLoad];
}
- (void)test{
NSLog(@"%s",__func__);
}
void testC(){
}
void(^YYBlock)(void) = ^(void) {
NSLog(@"block");
};
- (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event{
testC();
}
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);
char PcDescr[1024];
//__sanitizer_symbolize_pc(PC, "%p %F %L", PcDescr, sizeof(PcDescr));
printf("guard: %p %x PC %s\n", guard, *guard, PcDescr);
}
@end
- 运行工程,触摸屏幕,会来到
touchesBegan
方法,打下断点,汇编如下所示:
- 说明在touchesBegan方法中,插入了
__sanitizer_cov_trace_pc_guard
此C函数的调用,利用Hopper Disassembler
,查看MachO文件,发现如下:
- 看到所有函数,都被插入
__sanitizer_cov_trace_pc_guard
此C函数的调用; - 其实现原理,以后待叙;
- 为了防止死循环,编译重新配置成
-fsanitize-coverage=func,trace-pc-guard
,如下所示:
- 最后完整的收集符号代码如下所示:
//
// ViewController.m
// 二进制重排
//
// Created by liyanyan33 on 2022/1/7.
//
#import "ViewController.h"
#import <dlfcn.h>
#import <libkern/OSAtomic.h>
@interface ViewController ()
@end
@implementation ViewController
+ (void)load{
}
- (void)viewDidLoad {
[super viewDidLoad];
}
- (void)test{
NSLog(@"%s",__func__);
}
void testC(){
}
void(^YYBlock)(void) = ^(void) {
NSLog(@"block");
};
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)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event{
NSMutableArray<NSString *> * symbolNames = [NSMutableArray array];
while (true) {
//offsetof 就是针对某个结构体找到某个属性相对这个结构体的偏移量
SymbolNode * node = OSAtomicDequeue(&symboList, offsetof(SymbolNode, 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];
//去重
if (![symbolNames containsObject:symbolName]) {
[symbolNames addObject:symbolName];
}
}
//取反
NSArray * symbolAry = [[symbolNames reverseObjectEnumerator] allObjects];
NSLog(@"%@",symbolAry);
//将结果写入到文件
NSString * funcString = [symbolAry componentsJoinedByString:@"\n"];
NSString * filePath = [NSTemporaryDirectory() stringByAppendingPathComponent:@"lb.order"];
NSData * fileContents = [funcString dataUsingEncoding:NSUTF8StringEncoding];
BOOL result = [[NSFileManager defaultManager] createFileAtPath:filePath contents:fileContents attributes:nil];
if (result) {
NSLog(@"%@",filePath);
}else{
NSLog(@"文件写入出错");
}
}
//原子队列
static OSQueueHead symboList = OS_ATOMIC_QUEUE_INIT;
//定义符号结构体
typedef struct{
void * pc;
void * next;
}SymbolNode;
void __sanitizer_cov_trace_pc_guard(uint32_t *guard) {
//if (!*guard) return; // Duplicate the guard check.
void *PC = __builtin_return_address(0);
SymbolNode * node = malloc(sizeof(SymbolNode));
*node = (SymbolNode){PC,NULL};
//入队
// offsetof 用在这里是为了入队添加下一个节点找到 前一个节点next指针的位置
OSAtomicEnqueue(&symboList, node, offsetof(SymbolNode, next));
}
@end
- 运行App,点击屏幕,控制台打印如下:
- 将生成的符号文件,拷贝到工程根目录下,就能实现二进制重排的启动优化了;
参考文章:
iOS 优化篇 - 启动优化之Clang插桩实现二进制重排
iOS App启动优化(四):编译期插桩 && 获取方法符号
iOS 启动优化 + 监控实践