冷启动优化&二进制重排

1,171 阅读11分钟

iOS冷启动优化 - 二进制重排 & Clang插桩

1.冷启动

1.1 什么是冷启动?

冷启动是指内存中不包含该应用程序相关的数据,必须要从磁盘载入到内存中的启动过程。

注意:重新打开 APP, 不一定就是冷启动。

  1. 当内存不足,APP被系统自动杀死后,再启动就是冷启动。
  2. 如果在重新打开 APP 之前,APP 的相关数据还存储在内存中,这时再打开 APP,就是热启动
  1. 冷启动与热启动是由系统决定的,我们无法决定。
  2. 当然设备重启以后,第一次打开 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部分:

  1. dylib loading time : 是指动态库加载的耗时,系统的动态库做过优化,耗时较少。 苹果官方推荐最多不要超过6个外部动态库,多余6个,需要考虑合并动态库,合并动态库对于启动时期的优化,非常有效。 像微信的动态库早期有八九个,现在也优化成6个了。
  2. rebase/binding
  • rebase:是指地址的 偏移修正耗时。
    • 在编译生成二进制文件的时候,每个函数都有一个地址,这个地址是相对于二进制文件的偏移地址
    • 在启动时,也就是在二进制文件在加载到虚拟内存的时候,为了安全起见,苹果有个安全机制(ASLR),会在整个二进制文件的最前面随机加一个偏移值
    • 比如 A 函数,相对于二进制文件的偏移值是 0x003。 启动时,整个二进制文件被分配了一个随机值0x100。 那么 A 函数在内存中的实际地址是 0x003 + 0x100 = 0x103。
    • 偏移修正指的就是计算方法在虚拟内存中的地址的过程!
  • binding: 动态库的方法绑定,是指将方法名字与方法的实现进行绑定过程的耗时。
    • 比如 NSLog 方法,在加载的时候需要先找到Foundation库,再找到库里的NSLog的方法的实现,将方法名字和方法实现绑定在一起。
  1. Objc setup time: 注册所有 OC类 耗时, 类越多耗时越多,有人统计过2万个自定义的OC的类,大概耗时800毫秒。删除不用的类,可以减少耗时。
  2. initializer time: load方法 和 C++构造函数的耗时. 减少重写load方法,尽量将事情延迟到 main 方法以后,可以减少耗时。
  1. slowest intializers : 指出了最耗时的几个库是下面的6个库(最后一个是我的项目)。

1.2.2 pre-main阶段耗时优化方法总结:

  • 减少外部动态库的数量
  • 不用的类和方法,删除
  • 类尽量使用懒加载,也就是尽量不要重写load方法。
  • 启动时加载的数据使用多线程
  • 使用纯代码。不用xib storyboard(要额外进行代码解析转换和页面的渲染)

以上方法,都是和自己的项目代码息息相关的优化方案。不同项目具体是实施动作不一样。

还有一个优化方法,不管是什么项目,实施动作都一样 ,对什么项目都有效,那就是二进制重排!



2. 二进制重排

学习二进制重排,首先要知道数据是如何加载到内存中的

数据是如何加载到内存中的

我们已经知道 数据加载到内存的过程,当虚拟内存页还没有对应的物理内存页时,会出现 缺页异常(PageFault)。当冷启动时,物理内存中是没有数据的,这时会出现大量的缺页异常,这时的耗时会比较多。这里有没有 优化空间呢?接下来就是优化方案:二进制重排!

在了解二进制重排之前,再了解下在项目编译生成二进制文件的时候,类及其内部方法实现的排列顺序是什么样的呢?

2.2.1 二进制文件中方法实现排序是什么样的?

  1. 在 viewController 中,先随便写几个方法。

  1. 再看下源文件的编译顺序

接下来查看 Link Map文件查看符号顺序, 查看方式:

  1. 打开link map

****

  1. 编译生成link map 文件
  2. 找到link map 文件
  • 项目目录中,生成的 app 右键,show in Finder

  • 找到 app 的上上级目录

  • 进入Intermediates.noindex -> TraceDemo.build -> Debug-iphonesimulator -> TraceDemo.build -> TraceDemo-LinkMap-normal-x86_64.txt
  1. 打开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)
    

也就是说,每个方法在执行的时候,都会调用上面这个方法。 接下来:

      1. 我们要实现这个方法,并在这个方法里,获取到本方法结束后要返回的地址
// 获取到本方法结束后,要返回的地址去,这个地址包含在被hook的方法内部,但不是被hook 的方法的首地址
void *PC = __builtin_return_address(0);

         

      1. 并将地址保存一个系统的原子队列( ( 底层实际上是个栈结构 , 利用队列结构 + 原子性来保证顺序 ))中,使用原子队列,是为了防止多线程资源抢夺。原子队列的存值方法如下:
// 将结构体存入到原子队列中。
// 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:

  1. 查看一下项目的缺页异常数量。注意需要卸载 APP 或者重启手机,来保证这个APP完全没有被加载到内存中,因为如果物理内存中有该APP的数据,
  2. 打开 Instrument -> System Trace

3.选择真机、项目、点击启动,当第一个页面显示出来后,点击停止。

4.搜索main thread, 选择Virtual MemoryFile Backed Page in 就是缺页异常的数量

优化前:项目的缺页遗产数量是427


优化后:

优化前:项目的缺页遗产数量是286


至此,本文结束~ 减少了启动时大概40%的缺页异常~