iOS启动优化之二进制重排

App启动优化之二进制重排

如果要问2019年年底iOS最热门技术是哪些,那当然是少不了8月底抖音发布一篇关于启动优化的文章,其原理文章也说得蛮清楚的。链接如下:

抖音文章

简单总结就是

  • 二进制重排优化的是pre-main之前的时间
  • 因为Mach-O文件的时候是分页加载的,当用到某页数据时才会去加载(类似懒加载),当进程访问一个虚拟内存Page而对应的物理内存却不存在时(例如函数和方法),就会产生Page Fault(缺页中断)去加载该页,虽然中断时间很短,但是当page fault次数庞大时,耗时比想象的更多,而且通过App Store渠道分发的App,Page Fault还会进行签名验证。

查看pre-main耗时

可以通过给xcode设置参数来查看pre-main耗时检测

具体操作如下

  • Edit Scheme -- 选择一个Scheme(比如:Run)-- 选择Arguments -- Environment Variables -- 点击添加 -- 设置 name: DYLD_PRINT_STATISTICS value : 1
iOS启动优化之二进制重排_第1张图片
1.png

启动app可以看到以下打印

pre-main time: 1.3 seconds (100.0%)
dylib loading time: 161.47 milliseconds (12.2%)
rebase/binding time:  25.11 milliseconds (1.8%)
ObjC setup time: 444.37 milliseconds (33.5%)
initializer time: 691.82 milliseconds (52.2%)

从打印可以看出pre-main之前的耗时操作有如下这些:

  • dylib loading :加载可执行文件(App 的.o 文件的集合),加载动态链接库
  • rebase/binding :对动态链接库进行rebase 指针调整和bind符号绑定;
  • Objc setup :Objc 运行时的初始处理,包括 Objc 相关类的注册、category 注册、selector 唯一性检查等;
  • initializer:包括了执行 +load() 方法、attribute((constructor)) 修饰的函数的调用、创建 C++ 静态全局变量

那我们的优化方案可以从以下几方面入手:

  • 减少动态库的个数,如果太多就使用合并的方式控制,这样可以节约dylib loadingrebase/binding的时间
  • 清理项目中未用到的类、类别、方法等,这样可以节约Objc setup的时间
  • 对于可以不在+load中处理的逻辑可以放到其他的函数中去处理,比如:+initialize;控制 C++ 全局变量的数量;这样可以节约initializer的时间

除了以上优化手段,我们还可以借助LLVM为我们提供的优化方式,也就是今天的主题:二进制重排

查看app启动产生的Page Faults

  • 打开xcode的Instruments,选择System Trace工具
iOS启动优化之二进制重排_第2张图片
2.png

重排

编译器在生成二进制代码的时候,默认按照链接的Object File(.o)顺序写文件,按照Object File内部的函数顺序写函数。(静态库文件.a就是一组.o文件的ar包,可以用ar -t查看.a包含的所有.o)

iOS启动优化之二进制重排_第3张图片
3.png

上图,假设我们只有两个page:page1/page2,其中绿色的method1和method3启动时候需要调用,为了执行对应的代码,系统必须进行两个Page Fault。

优化:如果我们把method1和method3排布到一起,那么只需要一个Page Fault即可,这就是二进制文件重排的核心原理,如下图

iOS启动优化之二进制重排_第4张图片
4.png

Xcode相关配置

  1. order file文件路径设置

有了上面的理论知识,那么我们需要将启动时候调用的函数进行重排,让它们尽可能的分配在同一个页。比如load方法我们就将其找出来,放到一起。LLVM支持我们通过设置order来达到这个效果,如下图

iOS启动优化之二进制重排_第5张图片
5.png

至于order file的文件内容是那些启动时候需要调用的函数和方法,如下图

iOS启动优化之二进制重排_第6张图片
6.png

看到这里你可能会有疑问,我们如何知道启动时需要调用那些函数和方法呢?其实这也是二进制重排最难的地方。方法很多,抖音的文章也详细说了,我们今天采用终极方案clang全局静态插桩方法,下面会详说。

  1. Write Link Map File设置

这个主要是用来描述可执行文件的构造部分,包括了代码段和数据段的分布情况。一句话总结就是能查看函数、方法的内存顺序,我们重排的就是这些顺序。 具体设置如下图

iOS启动优化之二进制重排_第7张图片
7.png

设置完成后command + b编译一下,再去对应的路径就能看到生成的mapFile文件,直接打开(或者借助第三库界面工具Link Map打开),如图:

iOS启动优化之二进制重排_第8张图片
8.png

mapFile可以看到load等启动时需要调用的方法和函数分散得很开,当启动执行的方法和函数很多时,那就可能分散在不同的页。待会我们重排完之后再回来对比看重排前和重排后的区别。

clang全局静态插桩

现在我们开始获取启动时候调用的所有函数和方法

clang官方文档

首先在主项目Target--Build Settings中添加编译选项

Other C Flags增加-fsanitize-coverage=func,trace-pc-guard,添加完之后编译时编译器会帮我们在所有函数和方法(包括block)中插入调用__sanitizer_cov_trace_pc_guard函数的代码。如下图:

9.png

(添加完-fsanitize-coverage=func,trace-pc-guard选项后,如果不添加下面这段代码的话,就会编译错误,原因就是找不到__sanitizer_cov_trace_pc_guard_init__sanitizer_cov_trace_pc_guard

那么我们只要在__sanitizer_cov_trace_pc_guard函数搞事情就好,代码如下(把以下代码复制到项目,我复制到工程首页类):


#import 
#import 

//原子队列
static  OSQueueHead symbolList = OS_ATOMIC_QUEUE_INIT;

//定义符号结构体
typedef struct {
    void *pc;
    void *next;
}CKNode;

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)
{
    // 这个打开了就会跳过load方法
    //if (!*guard) return; 
    //利用__builtin_return_address(0)来获得当前函数返回地址,也就是调用方的地址。
    void *PC = __builtin_return_address(0);
    CKNode *node = malloc(sizeof(CKNode));
    *node = (CKNode){PC,NULL};
    //进入
    OSAtomicEnqueue(&symbolList, node, offsetof(CKNode, next));
}

//只是为了方便随便放置
-(void)viewDidAppear:(BOOL)animated
{
    [super viewDidAppear:animated];
    [self getOrderFile];
}

//保存orderFile文件
-(void)getOrderFile
{
    NSMutableArray  * symbolNames = [NSMutableArray array];
    while (YES) {
        CKNode * node = OSAtomicDequeue(&symbolList, offsetof(CKNode, next));
        if (node == NULL) {
            break;
        }
        Dl_info info;
        //通过dladdr来将指针解析成Dl_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 *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:@"ckTest.order"];
    NSData * fileContents = [funcStr dataUsingEncoding:NSUTF8StringEncoding];
    [[NSFileManager defaultManager] createFileAtPath:filePath contents:fileContents attributes:nil];
    NSLog(@"%@",funcStr);
}

/** 代码相关细节总结:
    1、增加-fsanitize-coverage=func,trace-pc-guard,添加完之后编译时编译器会帮我们在所有函数和方法(包括block)中插入调用`__sanitizer_cov_trace_pc_guard`函数的代码。
    2、利用__builtin_return_address(0)来获得当前函数返回地址,也就是调用方的地址。
    3、通过dladdr来将指针解析成Dl_info结构体信息,其中dli_sname就是符号的名称。

    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;
*/

执行以上代码最后会在app的沙盒的tmp目录下生成orderFile文件(需要改路径的可以自己修改一下代码),打开查看,如下图

iOS启动优化之二进制重排_第9张图片
10.png

有了这份orderFile文件,我们可以去xcode里设置(就是按照上面说的xcode相关配置 -> 1. order file文件路径设置)。

设置完,再编译下,再看下link map file文件,如图

iOS启动优化之二进制重排_第10张图片
11.png

接着我们看下重排后的Page Fault耗时和pre-main的时间

iOS启动优化之二进制重排_第11张图片
12.png

从上图可以看到page Fault的次数从3000+减少到2000+,时间从900+ms到400+ms

再看下pre-main

Total pre-main time: 926.43 milliseconds (100.0%)
dylib loading time: 150.69 milliseconds (16.2%)
rebase/binding time:  32.49 milliseconds (3.5%)
ObjC setup time:  88.75 milliseconds (9.5%)
initializer time: 654.27 milliseconds (70.6%)

跟之前的1.3s对比明显优化了0.4s左右(可能我用来测试的项目类比较多),效果还是挺明显的,但是这个时间每次都是有差异的,只能算大概。

注意事项说明

  1. 因为优化都是毫秒级别的,所以可能很难从肉眼上面看出来。
  2. 优化效果跟项目"大小"有关。
  3. 主要优化的是“冷启动”,平常我们认为把app从后台删掉,再重新启动就是冷启动,但是如果从“从后台删掉”到“重新启动”这中间时间间隔短的话,其实app的数据还是在内存里,这时候启动会很快,因为app数据还在内存里(通过Page Fault的次数就能证明),所以要想更好的测试重排优化的时间,建议把app从后台删掉后,多开几个其他的app后,再去启动你要测试的app,这时再去看Page Fault耗时和pre-main的时间。
  4. 我们刚才hook的只是我们工程的那些源码文件,像静态库那些已经编译好的库,我们是没有hook的,也就是说想要做到极致,也要把静态库那些重排,但是这个可能比较难,因为有可能静态库那些是别人提供的。
  5. 上面page fault的优化时间跟pre-main实际打印的时间有差异,感觉以pre-main实际加载的时间为准好点。
  6. orderFile里面列举的方法和函数名称如果在源码里找不到对应的方法和函数也是没关系的,编译器会直接忽略这些,clang还是很智能的。
  7. 每次时间都会有差异,只能大概估算。
  8. 文章很多内容都是从其他博客借鉴的,本文只是把个人觉得好的那些凑在一块,简单总结下,如有错请指正,不喜勿喷,谢谢。

你可能感兴趣的:(iOS启动优化之二进制重排)