当你从手机桌面点击APP,然后左等右等页面还没有出现的时候,你是否会抓狂,甚至是卸载掉。因此,基于用户体验和用户留存,良好的启动速度也是我们必须面对的一个环节。
一:启动类型
"冷启动"和"热启动"
- 冷启动:
App点击启动前,此时App的进程还不在系统里。 需要系统新创建一个进程分配给App。(这是一次完整的App启动过程)。
启动最佳时间是 400ms 以内,因为启动动画时长是 400ms。
- 热启动:
App在冷启动后用户将App退回后台,此时App的进程还在系统里。 用户重新返回App的过程。(热启动做的事较少)。
我们所说的启动优化也是针对APP的冷启动来说。
冷启动主要分为三个阶段:
main()函数执行前(pre-main阶段)。main()函数执行后(从main函数执行,到设置self.window.rootViewController执行完成)。- 首屏渲染完成后(从
self.window.rootViewController执行完成到didFinishLaunchWithOptions方法作用域结束)。
启动时间(pre-main阶段)
在pre-main阶段我们可以通过添加环境变量来获取耗时。
在Xcode的菜单中选择Project→Scheme→Edit Scheme...,然后找到 Run → Environment Variables →+,添加name为DYLD_PRINT_STATISTICS value为1的环境变量。
下图是实际项目的启动时间:
解读:
main()函数之前总共使用了1.7s
-
dylib loading time:动态库加载 耗时64.05ms-
动态库的载入肯定会存在耗时,并且动态库会存在依赖关系。系统动态库存在于共享缓存,但自定义动态库无法做到共享缓存,那么就会消耗更多的时间。因此苹果官方建议不要超过
6个自定义动态库,超过可进行多个动态库合并,以此来优化动态库加载的耗时; -
动态库的合并,需要源码才能进行。所以我们只能合并自己开发的动态库,日常使用的三方SDK可能无法合并。
-
-
rebase/binding time:指针偏移修正/符号绑定 耗时213.88ms-
rebase:系统采用ASLR技术,保证地址空间随机化。所以在运行时,需要通过rebase进行重定位符号,使用ASLR+偏移地址; -
binding:使用外部符号,编译时无法找到函数地址。所以在运行时,dyld加载共享缓存,加载链接动态库之后,进行binding操作,重新绑定外部符号。
-
-
ObjC setup time:类的初始化 耗时789.76-
注册
OC类的过程,读取二进制的data段找到OC的相关信息,然后注册OC类。应用启动时,系统会生成类和分类的两张表,OC类和分类的注册,会插入到这两张表中,所以会造成一定的时间消耗; -
这部分时间很难优化,除非减少项目中类和分类的定义;
-
减少类和所属分类
load方法的使用,让类以懒加载的方式加载。
-
-
initializer time:执行load和构造函数 耗时726.45- 尽可能使用
initialize方法代替load方法。
- 尽可能使用
整个过程如下图所示:
工具
Time Profiler
我们可以使用Instruments的TimeProfile来统计启动时的主要方法耗时,Call Tree->Hide System Libraries过滤掉系统库可以查看主线程下方法的耗时`。
底部状态
但Time Profiler 其实只适合粗粒度的分析,为什么这么说呢?我们来看下它的实现原理:
默认 Time Profiler 会 1ms 采样一次,只采集在运行线程的调用栈,最后以统计学的方式汇总。比如下图中的 5 次采样中,method3 都没有采样到,所以最后聚合到的栈里就看不到 method3。所以 Time Profiler 中的看到的时间,并不是代码实际执行的时间,而是栈在采样统计中出现的时间。
System Trace
System Trace 可以支持精细化的分析。
既然要精细化分析,那么我们就需要标记出一小段时间,可以用 Point of interest 来标记。除此之外,System Trace 分析虚拟内存和线程状态都很管用:
- Virtual Memory:主要关注 Page In这个事件,因为启动路径上有很多次 Page In,且相对耗时
- Thread State:主要关注挂起和抢占两个状态,记住主线程不是一直在运行的
- System Load 线程有优先级,高优先级的线程不应该超过系统核心数量
二:实践
pre-main阶段
减少动态库
减少动态库数量可以加减少启动闭包创建和加载动态库阶段的耗时,官方建议动态库数量小于 6 个。
推荐的方式是动态库转静态库,因为还能额外减少包大小。另外一个方式是合并动态库,但实践下来可操作性不大。最后一点要提的是,不要链接那些用不到的库(包括系统),因为会拖慢创建闭包的速度。
Tips:如何查看动态库?
- 在项目的
Product文件夹找到我们的工程.app文件,右键选择Show in Finder。 - 来到相应目录后右键选择
显示包内容。 - 找到
Frameworks文件夹,打开即可。
删除无用的类和代码
下线代码可以减少 Rebase & Bind & Runtime 初始化的耗时,当然也可以尝试合并功能类似的类和扩展(Category)
检测无用代码可以使用AppCode。
+load迁移
+load 除了方法本身的耗时,还会引起大量 Page In
-
方案一:如果可能的话,将
+load中的内容,放到渲染完成后做。 -
方案二:使用
+initialize()的方法代替+load(),注意把逻辑移动到+initialize()时,要注意避免+initialize()的重复调用问题,可以使用dispatch_once()让逻辑只执行一次。
静态初始化迁移
静态初始化和 +load 方法一样也会引起大量 Page In,一般来自 C++代码,减少C++静态全局变量。
main之后
图片资源
启动难免会用到很多图,有没有办法优化图片加载的耗时呢?
用 Asset 管理图片而不是直接放在 bundle 里。Asset 会在编译期做优化,让加载的时候更快,此外在 Asset 中加载图片是要比 Bundle 快的,因为 UIImage imageNamed 要遍历 Bundle 才能找到图。加载 Asset 中图的耗时主要在在第一次找图,因为要建立索引,可以通过把启动的图放到一个小的 Asset 里来减少这部分耗时。
每次创建 UIImage 都需要 IO,在首帧渲染的时候会解码。所以可以通过提前子线程预加载(创建 UIImage)来优化这部分耗时。
如下图,启动只有到了比较晚的阶段“RootWindow 创建”和“首帧渲染”才会用到图片,所以可以在启动的早期开预加载的子线程启动任务。
分阶段加载
didFinishLaunchingWithOptions中必然存在一些初始化工作,这里面的初始化是必须执行的,但是我们可以适当的根据功能的不同对应的适当延迟启动的时机。对于我们项目,我将初始化分为三个类型:
- 日志、统计等必须在 APP 一起动就最先配置的事件
- 项目配置、环境配置、用户信息的初始化 、推送、IM等事件
- 其他 SDK 和配置事件
对于第一类,由于这类事件的特殊性,所以必须第一时间启动,仍然把它留在 didFinishLaunchingWithOptions 里启动。第二类事件,这些功能在用户进入 APP 主体的之前是必须要加载完的,所以我们可以把它放在第二批,也就是用户已经看到广告页面,再进行广告倒计时的时候再启动。第三类事件,由于不是必须的,所以我们可以放在第一个界面渲染完成以后的 viewDidAppear 方法里,这里完全不会影响到启动时间。
首页UI使用纯代码
纯代码方式而不是storyboard加载首页UI,storyboard启动起来更加消耗资源。
三:二进制重排
物理内存和虚拟内存
物理地址
物理内存指的是内存条上的内存,早期一个进程的数据是全部加载在物理内存上,CPU直接通过物理内存地址来访问进程数据。这种方式会产生以下几个问题:
内存不够用:启动的应用过多,全部加载会导致内存条的空间不够用。内存占用浪费:当应用越来越大的时候,用户可能只用到部分功能,此时如果全部加载到内存,会导致内存占用浪费。内存数据的安全问题:通过访问物理地址,可以直接修改物理内存上的数据。
为了解决物理内存的这几个问题,CPU访问进程数据就不能直接通过物理内存地址,而是通过虚拟内存来间接访问。
虚拟内存 虚拟内存是处于进程和物理内存之间的一个中间层,由系统生成,内部作分页管理,结构如下图所示:
一个虚拟内存对应一个进程,大小为4GB,虚拟内存里会分为很多页(page),每页的大小在iOS中为16kb,其他系统中为4kb。Page里的每一格对应进程中的某一项数据,会记录该数据的虚拟内存地址和物理内存地址,因此虚拟内存本质上是一张关联进程各项数据的虚拟内存地址和物理内存地址的映射表。
采用虚拟内存后,CPU访问进程数据的情况如下:
- 进程启动后,系统会为进程建立一个对应的虚拟内存,里面记录了进程每项数据的虚拟内存地址,此时进程还未加载到物理内存中,所以
page记录的各项数据的物理内存地址为0x00000...。 - 当进程的某部分活跃后,
CPU根据这部分数据的虚拟内存地址找到其对应的物理内存地址,再通过物理地址访问到物理内存上的数据。 - 如果在
page上没有找到对应的物理地址时,说明此page上所关联的进程数据没被加载到物理内存中,此时会触发缺页异常(Page Fault),中断当前进程,先将当前页所对应的进程数据加载到物理内存中,然后page会记录每项数据的物理地址,CPU再通过物理地址来访问内存上的数据。
因此,相比直接访问物理内存,虚拟内存的优势如下:
内存使用更高效:进程的数据经过分页管理后,只将活跃的page所关联的数据加载在物理内存中,当物理内存都被占用的时候,此时会覆盖掉不活跃的内存,加载当前活跃的page数据,这样就能提高对内存的使用效率。内存数据更安全:每次启动进程,系统都会重新建立对应的虚拟内存,并为虚拟内存分配一个ASLR随机值(Address Space Layout Randomization),数据的虚拟地址即为:ASLR随机值+偏移值,这样数据的虚拟地址每次都会变,并且CPU是通过虚拟内存来间接访问物理内存的,在这个过程中物理内存地址没有暴露出来,所以就能保证内存数据的安全性。
程序的代码在不修改的情况下,每次加载到虚拟内存中的地址都是一样的,这样的方式并不安全。为了解决地址固定的问题,出现了ASLR技术。
ASLR
ASLR(Address space layout randomization):是一种针对缓冲区溢出的安全保护技术,通过对堆、栈、共享库映射等线性区布局的随机化,通过增加攻击者预测目的地址的难度,防止攻击者直接定位攻击代码位置,达到阻止溢出攻击的目的。
大部分主流的操作系统已经实现了ASLR:
Linux:在内核版本2.6.12中添加ASLR;Windows:Windows Server 2008、Windows 7、Windows Vista、Windows Server 2008 R2,默认情况下启用ASLR,但它仅适用于动态链接库和可执行文件;Mac OS X:Apple在Mac OS X Leopard10.5(2007年十月发行)中某些库导入了随机地址偏移,但其实现并没有提供ASLR所定义的完整保护能力。而Mac OS X Lion10.7则对所有的应用程序均提供了ASLR支持。Apple宣称为应用程序改善了这项技术的支持,能让32及64位的应用程序避开更多此类攻击。从OS X Mountain Lion10.8开始,核心及核心扩充(kext)与zones在系统启动时也会随机配置;iOS(iPhone、iPod touch、iPad):Apple在iOS4.3内导入了ASLR;Android:Android 4.0提供地址空间配置随机加载(ASLR),以帮助保护系统和第三方应用程序免受由于内存管理问题的攻击,在Android 4.1中加入地址无关代码(position-independent code)的支持.
当系统访问虚拟内存时,发现数据还未加载到物理内存中,会触发缺页中断(Page Fault),造成进程阻塞。此时系统会先将数据加载到物理内存中,进程才能继续运行。虽然每一页数据加载到内存的速度很快,毫秒级别,但在应用冷启动时,可能会出现大量的缺页中断,对启动速度带来一定的时间消耗。
二进制重排的原理
我们先来看一个例子
#import "ViewController.h"
@interface ViewController ()
@end
@implementation ViewController
+ (void)test1 {
NSLog(@"test1");
}
- (void)viewDidLoad {
[super viewDidLoad];
[self test2];
}
- (void)test2 {
NSLog(@"test2");
}
+ (void)load {
[self test1];
}
@end
这段代码启动之后,方法的执行顺序是load,test1,viewDidLoad,test2
但是实际的符号排列顺序是怎么样的?
Link Map File文件,保存了项目在编译链接时的符号顺序,以方法/函数为单位排列,进行相应配置,可以查看符号顺序,如下图:
我们将代码的编辑顺序和最终所有代码顺序对比来看
我们发现代码的编译顺序和最终代码的排列顺序一致,而每个文件中的代码的书写顺序也是从上到下来排列的,也是就是说,最终的代码是先按编译顺序向下排列,每个文件中的方法按照书写顺序排列。
而这种排列顺序也导致启动时候的代码分布在多个不同的page,而启动的方法并没有集中在一起,从而造成大量缺页中断。
二进制重排要做的就是将所有启动时刻需要调用的方法排列在一起。
查看PageFault次数
打开Instruments:
选择我们的手机和对应的工程,点击开始当首页出来后停止,选择Main Thread,选择虚拟内存Virtual Memory,File Backed Page in对应的2451即为缺页次数。
Clang插桩
Clang文档
其实这个技术苹果已经在用了,关键在于一个.order的文件,链接器最终会按照这个文件里符号的顺序来排列方法。
下图是objc4-750中的.order文件中的符号
步骤一: Build Setting配置
使用上面的demo,我们在工程目录下创建.order文件,添加符号如下,它和demo中实际方法执行顺序一致,我们重新编译看下link文件
+[ViewController load]
+[ViewController test1]
-[ViewController viewDidLoad]
-[ViewController test2]
最终的符号顺序与我们在.order中的顺序一致
那么怎么让项目加载.order文件呢?
进行如下配置:
另外在Build Setting --> Other C Flags中,增加-fsanitize-coverage=trace-pc-guard的配置
这里为什么要使用-fsanitize-coverage=trace-pc-guard?
通过前文我们知道,OC使用的是Clang(前端)+LLVM(后端),代码都需要通过Clang进行前期的词法分析,语法分析,生成语法树等一系列操作,添加-fsanitize-coverage=trace-pc-guard相当于告诉Clang,我们需要有跟踪方法的功能,那么Clang会在方法(包含函数和Block)的边缘插入__sanitizer_cov_trace_pc_guard函数,最终导致每个方法的调用都会来到__sanitizer_cov_trace_pc_guard方法,完成插桩,相当于编译期间的HOOK操作。
步骤二: 添加辅助代码
按照文档添加__sanitizer_cov_trace_pc_guard_init和__sanitizer_cov_trace_pc_guard
Build工程正常情况是会报错的,注释掉。
按照文档中示例添加这两个方法
#import "ViewController.h"
#include <stdint.h>
#include <stdio.h>
#include <sanitizer/coverage_interface.h>
@interface ViewController ()
@end
@implementation ViewController
- (void)viewDidLoad {
[super viewDidLoad];
// Do any additional setup after loading the view.
}
// 🌹 官方代码
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;
// 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);
}
__sanitizer_cov_trace_pc_guard_init
根据官网注释:编译器将此回调作为模块构造函数插入到每个DSO中。start和stop对应的是整个二进制文件(可执行文件或DSO)的开始和结束部分,也就是说这个函数反映了符号调用的次数。
start:0x104ded4c0
stop:0x104ded4f8
stop地址的基础上减去4字节才是最后地址的值,从start到stop存储的是1-14,通过多次多次操作,只要代码新增方法(函数,Block),多一个方法,都会+1。
__sanitizer_cov_trace_pc_guard
添加touchesBegan方法,并在打印的位置查看断点,打开汇编
-(void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event {
NSLog(@"touchesBegan 方法");
}
从汇编来看,在方法执行之前都会bl跳转到__sanitizer_cov_trace_pc_guard函数,之所以会这样是,全因我们上面在Other C Flags中,增加了-fsanitize-coverage=trace-pc-guard。
步骤三: 获取符号并生成.order文件
上面说了那么多,然而并没有什么卵用,我们的目的是什么,是拿到启动时候的所有符号,下面我们打开__sanitizer_cov_trace_pc_guard中注释掉的void *PC = __builtin_return_address(0),并加上相应的断点。
打印PC,地址为0x0000000102355eb8
打印对应的堆栈信息
打开汇编看一下
我们发现PC的地址和main函数的地址一样,从上文我们知道,Clang是会在每个方法的边缘位置插入__sanitizer_cov_trace_pc_guard函数,那么__sanitizer_cov_trace_pc_guard执行完肯定还是会返回这个方法,而上图中__sanitizer_cov_trace_pc_guard执行完成之后的地址就是0x0000000102355eb8,又回到了main函数中。
__builtin_return_address函数的作用:获取当前返回地址,也就是调用者的函数地址(上图位置的main)
接着我们导入<dlfcn.h>,通过dl_info来获取函数的信息
typedef struct dl_info {
const char *dli_fname; /* Pathname of shared object */
void *dli_fbase; /* Base address of shared object */
const char *dli_sname; /* Name of nearest symbol */
void *dli_saddr; /* Address of nearest symbol */
} Dl_info;
这个dli_sname不就是我们要的符号嘛!!!
保存符号 通过原子队列去取
#import "ViewController.h"
#include <stdint.h>
#include <stdio.h>
#include <sanitizer/coverage_interface.h>
#import <dlfcn.h>
#import <libkern/OSAtomic.h>
@interface ViewController ()
@end
@implementation ViewController
// 原子队列
static OSQueueHead symbolList = OS_ATOMIC_QUEUE_INIT;
// 符号结构体
typedef struct {
void *pc; // 保存PC地址
void *next; // 指向下一个节点
}WJNode;
void test1(void){
NSLog(@"test1调用");
testBlock();
}
void(^testBlock)(void) = ^(void) {
NSLog(@"block调用");
};
- (void)viewDidLoad {
[super viewDidLoad];
test1();
}
void __sanitizer_cov_trace_pc_guard_init(uint32_t *start,
uint32_t *stop) {
static uint64_t N;
if (start == stop || *start) return;
printf("INIT: %p %p\n", start, stop);
for (uint32_t *x = start; x < stop; x++)
*x = ++N;
}
void __sanitizer_cov_trace_pc_guard(uint32_t *guard) {
if (!*guard) return;
void *PC = __builtin_return_address(0);
/*
const char *dli_fname;
void *dli_fbase;
const char *dli_sname;
void *dli_saddr;
*/
Dl_info info;
dladdr(PC, &info);
// 创建结构体
WJNode *node = malloc(sizeof(WJNode));
*node = (WJNode){PC,NULL};
// 加入结构体
OSAtomicEnqueue(&symbolList, node, offsetof(WJNode, next));
}
-(void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event {
NSLog(@"touchesBegan 方法");
while (YES) {
WJNode *node = OSAtomicDequeue(&symbolList, offsetof(WJNode, next));
if (node == NULL) {
break;
}
Dl_info info = {0};
dladdr(node->pc, &info);
printf("%s \n",info.dli_fname);
}
}
这里出现一个问题,死循环了,愿意在于while循环也被HOOK了
解决方案:Other C Flags中改为-fsanitize-coverage=func,trace-pc-guard
问题解决了,但是好像又没有完全解决,方法出现了重复,并且main也缺少下划线_。
-(void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event {
NSLog(@"touchesBegan 方法");
NSMutableArray<NSString *> *symbolArray = [NSMutableArray new];
while (YES) {
WJNode *node = OSAtomicDequeue(&symbolList, offsetof(WJNode, next));
if (node == NULL) {
break;
}
Dl_info info = {0};
dladdr(node->pc, &info);
NSString *name = @(info.dli_sname);
// 加下划线
BOOL isObjc = [name hasPrefix:@"+["] || [name hasPrefix:@"-["];
NSString * symbolName = isObjc ? name : [@"_" stringByAppendingString:name];
[symbolArray addObject:symbolName];
}
// 取反
NSEnumerator *enu = [symbolArray reverseObjectEnumerator];
// 新集合用于去重
NSMutableArray *funs = [NSMutableArray new];
NSString *funcName;
while (funcName = [enu nextObject]) {
if (![funs containsObject:funcName]) {
[funs addObject:funcName];
}
}
// 🌹去掉自己 就是当前的touchesBegan方法
[funcs removeObject:[NSString stringWithFormat:@"%s",__FUNCTION__]];
for (NSString *str in funs) {
NSLog(@"%@",str);
}
// 🌹生成.order文件
//数组转成字符串
NSString * funcStr = [funcs componentsJoinedByString:@"\n"];
//字符串写入文件
//文件路径
NSString * filePath = [NSTemporaryDirectory() stringByAppendingPathComponent:@"wj.order"];
//文件内容
NSData * fileContents = [funcStr dataUsingEncoding:NSUTF8StringEncoding];
[[NSFileManager defaultManager] createFileAtPath:filePath contents:fileContents attributes:nil];
}
我们看下结果
获取到.order我们就可以直接放到项目根目录使用它了。
tips:先将
.order文件放到我们项目的根目录下,Build Settings -> Order File中配置.order文件路径
使用前符号顺序
使用后符号顺序
参考
性能优化(一)APP 启动优化
抖音研发实践:基于二进制文件重排的解决方案 APP启动速度提升超15%
iOS 优化篇 - 启动优化之Clang插桩实现二进制重排
优化 App 的启动时间
iOS App启动优化(三)—— 自己做一个工具监控App的启动耗时