目的:
减少App冷启动时间, 经业界实际反馈, 可降低约15%的启动耗时
解释:
将启动代码重新排列到可执行文件的前面几页, 这种技术就叫做二进制重排
原理:
虚拟内存及物理内存
早期计算机没有虚拟内存, 都是直接加载到物理内存的,而且进程是按顺序排列的,这样做特别占用内存空间而且很不安全,别的进程只需要在自己的地址上加一些就能进入别的进程,例如早期的外挂.
所以虚拟内存出现了, 软件打开后, 会有一个虚拟内存,再映射到物理内存(页表)
看如下图示例:
说明:
假如开启了两个进程1和进程2, 运行的时候会开辟一块内存空间.但是并不是直接加载到物理内存的, 而是通过页表映射到不同的物理空间, 这个过程也叫地址翻译. 因为其他进程无法访问此进程的映射表, 因此别的进程也访问不到真实的物理内存,这样做很安全.
另外, 页表是以页为单位做分页管理的,macos是4k,iOS是16k.应用启动的时候, 数据其实不是一次都加载到内存的,而是懒加载的,访问到哪块数据,就先看页表, 如果页表中数据为0, 说明此数据不在物理内存,此时, 系统会阻塞进程,然后将数据加载到物理内存(如果物理内存没有空间, 会覆盖其他进程数据),最后将虚拟内存指向物理内存, 这个过程叫**缺页中断(pagefault)**;如果页表中数据为1,说明之前加载过这块数据, 物理内存中有;
注意: 这样做解决了物理内存不够用的情况,但是同时也有其他问题.首先虚拟内存在编译链接的时候就确定了,黑客同样可能拿到虚拟内存去操作;其次就是缺页中断带来的问题就是这个操作很耗时,不仅要将数据加载到内存, 同时还要对这也做签名(单页耗时差不多0.1-0.8ms,如果启动加载了很多页,这个时间还是比较长)
第一个问题苹果通过**ASLR(地址空间随机化解决)**,暂时先跳过;第二个问题就是我们要说的二进制重排:代码在macho可执行文件中的位置不是根据调用顺序来的,而是通过文件编译顺序来的(xcode中build phases - compile sources中文件的顺序),如果启动的时候加载了其他没用代码, 会造成页表的浪费,同时也会造成太多没必要的缺页中断, 所以二进制重排就是在启动的时候将需要的代码集中放在一页或两页, 这样就会大大降低缺页中断带来的耗时,这就叫**二进制重排**.如下图:
说明: 优化掉1个page,大概能节省0.6-0.8ms
实践:
统计
首先先学会如何统计premian耗时,添加环境变量 DYLD_PRINT_STATISTICS (iOS15之前可用), 如图:
启动app后, 控制台会打印启动耗时, 如下所示:
说明, premain过程分4步:
- 动态库加载链接 (减少动态库/进行合并)
- 位移纠偏/符号绑定 (ASLR篇章讲)
- OC类初始化 (去掉没用的类)
- load方法、c++函数初始化 (延后到main之后、少用c++)
iOS15之后, 系统把这个禁止了, 但是可以通过instrument检测到:
iOS 15 and macOS Monterey have a new version of dyld. You can see the man page for dyld in macOS Monterey to see the current set of environment variables that you can use.
Several years ago, the Time Profiler instrument gained the ability to profile the time spent during app launch before main(). You should profile your app launches this way instead.翻译后意思就是:iOS15和macOS Monterey有一个新版本的dyld,可以让你用profile里面的instrument工具去监测
如图:
调试
其次先知道怎么调试, 如何查看pagefault的次数: 打开instrument- system trace(查看线程数据), 开始后搜索Main thread, 并选中我们的App, 然后点击Main thread , 选择virtual memory: 会看到有一个File Backed Page in就是page fault的次数, 如图所示:
注意: 此过程需要将App杀死后再启动, 因为不是冷启动的话有一部分数据系统会缓存;
技巧: 可以多次尝试尽可能的多打开其他应用后再打开我们的App, 会发现由于物理内存不够用pagefault的次数明显提高.
优化
知道了怎么查看, 如何调试后, 最关键的就是怎么优化
配置
查看可执行文件中函数及符号的排列顺序:
xcode中build setting中找到 link map file, 设置成yes, 如图:
编译后在app中可找到linkmap文件,如图:
打开后可看到函数及符号排列顺序, 如图:
仔细观察后可以发现, 这个符号顺序是按照Compile Sources的文件顺序来排列的 .启动加载的前几页数据中有很多是无用的
重写.order文件
将启动的所有函数符号写入.order文件
clang插桩(静态插桩): 最完美的解决办法
原理:
编译期在每一个函数内部添加hook代码(__sanitizer_cov_trace_pc_guard),实现全局方法hook
思路:
利用clang本身提供的机制/工具来获取所有符号的需求, clang可以hook任意(包括+load, block,循环) 官网地址:https://clang.llvm.org/docs/SanitizerCoverage.html#tracing-pcs
- 先配置Other C Flags: -fsanitize-coverage=trace-pc-guard (只对function 做hook)
原因: 一个while循环也会被hook, 会被静态加入__sanitizer_cov_trace_pc_guard多次调用, 导致死循环 - 添加hook代码:
#import <dlfcn.h>
// 定义原子队列
// 由于项目的方法有可能在不同的线程执行, 因此 __sanitizer_cov_trace_pc_guard 有可能受多线程
// 影响, 而且会特别多次调用, 使用锁的话会有性能影响, 所以可以用底层原子队列(栈结构)
static OSQueueHead symbolList = OS_ATOMIC_QUEUE_INIT;
// 定义符号结构体
typedef struct{
void *pc;
void *next;
} SYNode;
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;
}
// 真正的静态hook方法, 所有方法执行前都会先执行这个
void __sanitizer_cov_trace_pc_guard(uint32_t *guard) {
// 注意: +load 想要被hook, 需要注释掉此行代码
// if (!*guard) return;
// 当前函数返回到上一个调用的地址!!
void *PC = __builtin_return_address(0);
// 创建结构体!
SYNode * node = malloc(sizeof(SYNode));
*node = (SYNode){PC,NULL};
// 加入结构!
OSAtomicEnqueue(&symbolList, node, offsetof(SYNode, next));
}
+ (void)mkOrderFile {
// 定义数组
NSMutableArray<NSString *> * symbolNames = [NSMutableArray array];
while (YES) {//一次循环!也会被HOOK一次!!
SYNode * node = OSAtomicDequeue(&symbolList, offsetof(SYNode, next));
if (node == NULL) {
break;
}
Dl_info info = {0};
// 这个函数通过地址找到函数符号
dladdr(node->pc, &info);
// printf("%s \n",info.dli_sname);
// sname就是我们要的函数符号
NSString * name = @(info.dli_sname);
free(node);
// .order文件格式要求c函数 ,block调用前面还需要加 _ , 下划线
BOOL isObjc = [name hasPrefix:@"+["]||[name hasPrefix:@"-["];
NSString * symbolName = isObjc ? name : [@"_" stringByAppendingString:name];
[symbolNames addObject:symbolName];
/*
if ([name hasPrefix:@"+["]||[name hasPrefix:@"-["]) {
// 如果是OC方法名称直接存!
[symbolNames addObject:name];
continue;
}
//如果不是OC直接加个_存!
[symbolNames addObject:[@"_" stringByAppendingString:name]];
*/
}
// 反向数组 (多线程处理, 先进后出, 需要倒序一下)
// symbolNames = (NSMutableArray<NSString *>*)[[symbolNames reverseObjectEnumerator] allObjects];
NSEnumerator * enumerator = [symbolNames reverseObjectEnumerator];
// 创建一个新数组
NSMutableArray * funcs = [NSMutableArray arrayWithCapacity:symbolNames.count];
NSString * name;
// 去重!
while (name = [enumerator nextObject]) {
if (![funcs containsObject:name]) {//数组中不包含name
[funcs addObject:name];
}
}
[funcs removeObject:[NSString stringWithFormat:@"%s",__FUNCTION__]];
// 数组转成字符串
NSString * funcStr = [funcs componentsJoinedByString:@"\n"];
// 结果写入文件
// 文件路径
NSString * filePath = [NSTemporaryDirectory() stringByAppendingPathComponent:@"hank.order"];
// 文件内容
NSData * fileContents = [funcStr dataUsingEncoding:NSUTF8StringEncoding];
[[NSFileManager defaultManager] createFileAtPath:filePath contents:fileContents attributes:nil];
}
这行代码的意思的拿到执行hook函数的上一个调用的地址, 由于每个函数执行前都会先调用此hook函数, 所以这行代码拿到的其实就是我们想要的每一个函数的地址.
在汇编中, 函数嵌套调用,在跳转子函数的时候, 会首先在下x30寄存器中保存下一条指令的地址, 跳转子函数return的时候,会读取这个地址, 执行下一条指令,而下一条指令地址其实就是一开始调用子函数的原函数, 所以执行完子函数后, 会顺利跳回原函数.
三方库
以上仅仅针对主工程代码有效, 如果需要获取三方pod库的符号, 需要在pod下的每一个target里配置build setting, 如果核心代码做成组件库, 还得配置每一个target的依赖及search path. 不过如果我们的项目有太多三方pod, 这种方法显然太费劲. 解决方案如下:
- 使用下面即将提到的组件库: KCCLHook
- podfile中添加如下代码:
post_install do |installer|
require './Pods/xxx/xxx/xxx.rb'
symbol_tracker(installer)
end
xxx.rb代码如下:
def symbol_tracker(installer)
pods_project = installer.pods_project
name = 'xxx'
symbol_tracker_target = pods_project.targets.find { |t| t.name == name }
if !symbol_tracker_target.nil?
pods_target_config(pods_project, name, symbol_tracker_target)
end
end
def pods_target_config(pods_project, name, symbol_tracker_target)
symbol_tracker_target
build_settings = Hash[
'OTHER_CFLAGS' => '-fsanitize-coverage=func,trace-pc-guard',
#'OTHER_SWIFT_FLAGS' => '-sanitize=undefined -sanitize-coverage=func',
'ENABLE_BITCODE' => 'NO']
isDynamic = symbol_tracker_target.product_type.include?('framework')
if isDynamic
build_settings['FRAMEWORK_SEARCH_PATHS'] = '${PODS_CONFIGURATION_BUILD_DIR}/' + name
end
symbol_tracker = symbol_tracker_target.product_reference
pods_project.targets.each do |target|
if target.name != name and !target.name.include?('Pods-')
if isDynamic and !target.instance_of? Xcodeproj::Project::Object::PBXAggregateTarget
# 添加依赖
dependency = target.dependency_for_target(symbol_tracker_target)
if dependency
puts "[WARN] Already depended on #{target.name}"
else
target.add_dependency(symbol_tracker_target)
end
# 添加framework
build_phase = target.frameworks_build_phase
if build_phase
build_phase.add_file_reference(symbol_tracker)
end
end
# 修改build_settings
target.build_configurations.each do |config|
build_settings.each do |pair|
key = pair[0]
value = pair[1]
if config.build_settings[key].nil?
config.build_settings[key] = ['$(inherited)']
end
if !config.build_settings[key].include?(value)
config.build_settings[key] << value
end
end
end
puts '[Interpose]: ' + target.name + ' success.'
end
end
end
-
重新跑一遍工程, 可以看到.order中已有三方依赖的方法符号, 截图示例如下:
image.png
配置.order文件
xcode-buildseting-搜order, 如下图:
将生成的.order文件放入项目根目录, 同时配置好build setting
组件化
使用过程中可以将这些代码封装成私有组件, 此处就省略了