iOS:启动优化(二) Clang插桩及Order文件

4,308 阅读11分钟

启动

启动的过程一般是指从用户点击app图标开始到AppDelegate 的didFinishLaunching方法执行完成为止,其中,启动也分为冷启动和热启动。

  • 冷启动是内存中不包含相关的内存数据,必须从磁盘载入到内存中去,这个过程叫冷启动。
    • 杀掉应用之后 不一定进入到冷启动状态。是系统决定的,或者内存被覆盖的时候。一般可以通过重启手机实现冷启动
  • 热启动:是指杀掉app进程后,数据仍然存在时的启动

这里所说的启动优化,一般是指冷启动情况下的,这种情况下的启动主要分为两部分:

  • T1pre-main阶段,即main函数之前,操作系统加载App可执行文件到内存,执行一系列的加载&链接等工作,简单来说,就是dyld加载过程
  • T2:main函数之后,即从main函数开始,到Appdelegate 的didFinishLaunching方法执行完成为止,主要是构建第一个界面,并完成渲染

所以,T1+T2的过程 就是 从用户点击App图标到用户能看到app主界面的过程,即需要启动优化的部分。

pre-main阶段的优化

OC底层原理09:dyld加载流程中,已经了解过了dyld的加载流程。pre-main阶段的启动时间其实就是dyld加载过程的时间.

针对main函数之前的启动时间,苹果提供了内建的测量方法,在Edit Scheme -> Run -> Arguments ->Environment Variables点击+添加环境变量 DYLD_PRINT_STATISTICS 设为 1),然后运行,以下是iPhone7p正常启动的pre-main时间(以WeChat为例) image.png image.png

说明
pre-main阶段总共用时1.7s

  • dylib loading time(动态库耗时):主要是加载动态库,用时320.32ms

  • rebase/binding time(偏移修正/符号绑定耗时),耗时160.52ms

    • rebase(偏移修正):任何一个app生成的二进制文件,在二进制文件内部所有的方法、函数调用,都有一个地址,这个地址是在当前二进制文件中的偏移地址。一旦在运行时刻(即运行到内存中),每次系统都会随机分配一个ASLR(Address Space Layout Randomization,地址空间布局随机化)地址值(是一个安全机制,会分配一个随机的数值,插入在二进制文件的开头),例如,二进制文件中有一个 test方法,偏移值是0x0001,而随机分配的ASLR是0x1f00,如果想访问test方法,其内存地址(即真实地址)变为 ASLR+偏移值 = 运行时确定的内存地址(即0x1f00+0x0001 = 0x1f01)
    • binding(绑定):,例如NSLog方法,在编译时期生成的mach-o文件中,会创建一个符号!NSLog(目前指向一个随机的地址),然后在运行时(从磁盘加载到内存中,是一个镜像文件),会将真正的地址给符号(即在内存中将地址与符号进行绑定,是dyld做的,也称为动态库符号绑定),一句话概括:绑定就是给符号赋值的过程
  • ObjC setup time(OC类注册的耗时):OC类越多,越耗时

  • initializer time(执行load和构造函数的耗时)

针对这几部,有以下几点优化建议:

  • 尽量少用外部动态库,苹果官方建议自定义的动态库最好不要超过6个,如果超过6个,需要合并动态库
  • 减少OC类,因为OC类越多,越耗时。
  • 将不必须在+load方法中做的事情延迟到+initialize中,尽量不要用C++虚函数
  • 如果是swift,尽量使用struct

main函数之后的优化

在main函数之后的didFinishLaunching方法中,主要是执行了各种业务,有很多并不是必须在这里立即执行的,这种业务我们可以采取延迟加载,防止影响启动时间。

didFinishLaunching中的业务主要分为三个类型

  • 【第一类】初始化第三方sdk
  • 【第二类】app运行环境配置
  • 【第三类】自己工具类的初始化等

main函数阶段的优化建议主要有以下几点:

  • 减少启动初始化的流程,能懒加载的懒加载,能延迟的延迟,能放后台初始化的放后台,尽量不要占用主线程的启动时间
  • 优化代码逻辑,去除非必须的代码逻辑,减少每个流程的消耗时间
  • 启动阶段能使用多线程来初始化的,就使用多线程
  • 尽量使用纯代码来进行UI框架的搭建,尤其是主UI框架,例如UITabBarController。尽量避免使用Xib或者SB,相比纯代码而言,这种更耗时
  • 删除废弃类、方法

下面来着重介绍一个pre-main阶段的优化方案,即二进制重排,这个方案最开始是由于抖音的这篇文章抖音研发实践:基于二进制文件重排的解决方案 APP启动速度提升超15%火起来的。

二进制重排原理

在虚拟内存部分,我们知道,当进程访问一个虚拟内存page,而对应的物理内存不存在时,会触发缺页中断(Page Fault),因此阻塞进程。此时就需要先加载数据到物理内存,然后再继续访问。这个对性能是有一定影响的。

基于Page Fault,我们思考,App在冷启动过程中,会有大量的类、分类、三方等需要加载和执行,此时的产生的Page Fault所带来的的耗时是很大的。以WeChat为例,我们来看下,在启动阶段的Page Fault的次数

  • CMD+i快捷键,选择System Trace image.png

  • 点击启动(启动前需要重启手机,清除缓存数据),第一个界面出来后,停掉,按照下图中操作
    image.png 从图中可以看出WeChat发生的PageFault有2800+次,可想而知,这个是非常影响性能的。

我们自己新建个demo工程,查看方法在编译时期的排列顺序,在ViewController中按下列顺序定义以下几个方法:

@implementation ViewController
void test(){
    block1();
}

int test1(){
    return 0;
}

void(^block1)(void) = ^(void){
    
};


- (void)viewDidLoad {
    [super viewDidLoad];
    
    test();
}

+(void)load
{
    [SwiftTest swiftTest];
}
@end
  • Build Setting -> Write Link Map File设置为YES
    image.png

  • CMD+B编译demo,然后在对应的路径下查找 LinkMap文件,如下所示,可以发现 类中函数的加载顺序是从上到下的,而文件的顺序是根据Build Phases -> Compile Sources中的顺序加载的

    • LinkMap文件位置 image.png image.png
      image.png

从上面的PageFault的次数以及加载顺序,可以发现其实导致PageFault次数过多的根本原因是启动时刻需要调用的方法,处于不同的Page导致的。因此,我们的优化思路就是:将所有启动时刻需要调用的方法,排列在一起,即放在一个页中,这样就从多个PageFault变成了一个PageFault。这就是二进制重排的核心原理如下所示 image.png

注意:在iOS生产环境的app,在发生Page Fault进行重新加载时,iOS系统还会对其做一次签名验证,因此 iOS 生产环境的 Page Fault 比Debug环境下所产生的耗时更多。

二进制重排实践

下面,我们来进行具体的实践,首先理解几个名词

LinkMap
Linkmap是iOS编译过程的中间产物,记录了二进制文件的布局,需要在Xcode的Build Settings里开启Write Link Map File,Link Map主要包含三部分:

  • Object Files 生成二进制用到的link单元的路径和文件编号
  • Sections 记录Mach-O每个Segment/section的地址范围
  • Symbols 按顺序记录每个符号的地址范围

ld

ld是Xcode使用的链接器,有一个参数order_file,我们可以通过在Build Settings -> Order File配置一个后缀为order的文件路径。在这个order文件中,将所需要的符号按照顺序写在里面,在项目编译时,会按照这个文件的顺序进行加载,以此来达到我们的优化.

  • 会自动忽略order文件中不存在方法

所以二进制重排的本质就是对启动加载的符号进行重新排列

到目前为止,原理我们基本弄清楚了,如果项目比较小,完全可以自定义一个order文件,将方法的顺序手动添加,但是如果项目较大,涉及的方法特别多,此时我们如何获取启动运行的函数呢?有以下几种思路

  • 1、hook objc_msgSend:我们知道,函数的本质是发送消息,在底层都会来到objc_msgSend,但是由于objc_msgSend的参数是可变的,需要通过汇编获取,对开发人员要求较高。而且也只能拿到OC 和 swift中@objc 后的方法
  • 2、静态扫描:扫描 Mach-O 特定段和节里面所存储的符号以及函数数据
  • 3、Clang插桩:即批量hook,可以实现100%符号覆盖,即完全获取swift、OC、C、block函数

Clang 插桩

llvm内置了一个简单的代码覆盖率检测(SanitizerCoverage)。它在函数级、基本块级和边缘级插入对用户定义函数的调用。我们这里的批量hook,就需要借助于SanitizerCoverage

关于 clang 的插桩覆盖的官方文档如下 : clang 自带代码覆盖工具 文档中有详细概述,以及简短Demo演示。

  • 【第一步:配置】开启 SanitizerCoverage

    • OC项目,需要在:在 Build Settings 里的 “Other C Flags” 中添加 -fsanitize-coverage=func,trace-pc-guard.意思就是需要clang跟踪 会在编译阶段在每个方法、函数、block中添加一个__sanitizer_cov_trace_pc_guard函数
    • 如果是Swift项目或者OC汇编了swift,还需要额外在 “Other Swift Flags” 中加入-sanitize-coverage=func-sanitize=undefined
    • 所有链接到 App 中的二进制都需要开启 SanitizerCoverage,这样才能完全覆盖到所有调用。
    • 也可以通过podfile来配置参数
      post_install do |installer|
       installer.pods_project.targets.each do |target|
         target.build_configurations.each do |config|
           config.build_settings['OTHER_CFLAGS'] = '-fsanitize-coverage=func,trace-pc-guard'
           config.build_settings['OTHER_SWIFT_FLAGS'] = '-sanitize-coverage=func -sanitize=undefined'
         end
       end
      end
      

    如图配置后,编译有个报错 image.png image.png 编译有2个报错,也就是说 只要配置那个参数 就会调用上图中报错的2个函数 那我们来实现一下上面2个函数。

  • 【第二步:重写方法】在ViewController.m,重写两个方法:__sanitizer_cov_trace_pc_guard_init方法和__sanitizer_cov_trace_pc_guard方法。 代码如下:

    #import "ViewController.h"
    
    #include <stdint.h>
    #include <stdio.h>
    #include <sanitizer/coverage_interface.h>
    #import <dlfcn.h>
    #import <libkern/OSAtomic.h>
    #import "Test-Swift.h"
    
    @interface ViewController ()
    
    @end
    @implementation ViewController
    
    void test(){
        block1();
    }
    
    int test1(){
        return 0;
    }
    
    void(^block1)(void) = ^(void){
    
    };
    
    
    - (void)viewDidLoad {
        [super viewDidLoad];
    
        test();
    }
    
     +(void)load
    {
        [SwiftTest swiftTest];
    }
    
    //原子队列,其目的是保证写入安全,线程安全
    static  OSQueueHead symbolList = OS_ATOMIC_QUEUE_INIT;
    //定义符号结构体,以链表的形式
    typedef struct {
        void *pc;
        void *next;
    }MMNode;
    
    /*
     - start:起始位置
     - stop:并不是最后一个符号的地址,而是整个符号表的最后一个地址,最后一个符号的地址=stop-4(因为是从高地址往低地址读取的,且stop是一个无符号int类型,占4个字节)。stop存储的值是符号的
     */
    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;
        }
    
    }
    
    /*
     可以全面hook方法、函数、以及block调用,用于捕捉符号,是在多线程进行的,这个方法中只存储pc,以链表的形式
    
     - guard 是一个哨兵,告诉我们是第几个被调用的
     */
    void __sanitizer_cov_trace_pc_guard(uint32_t *guard) {
    //    if (!*guard) return;//将load方法过滤掉了,所以需要注释掉
    
        //获取PC
        /*
         - PC 当前函数返回上一个调用的地址
         - 0 当前这个函数地址,即当前函数的返回地址
         - 1 当前函数调用者的地址,即上一个函数的返回地址
        */
        void *PC = __builtin_return_address(0);
        //创建node,并赋值
        MMNode *node = malloc(sizeof(MMNode));
        *node = (MMNode){PC, NULL};
    
        //加入队列
        //符号的访问不是通过下标访问,是通过链表的next指针,所以需要借用offsetof(结构体类型,下一个的地址即next)
        OSAtomicEnqueue(&symbolList, node, offsetof(MMNode, next));
    }
    @end
    
  • __sanitizer_cov_trace_pc_guard_init方法

    • 参数1 start 是一个指针,指向无符号int类型,4个字节,相当于一个数组的起始位置,即符号的起始位置(是从高位往低位读 image.png
    • 参数2 stop,由于数据的地址是往下读的(即从高往低读,所以此时获取的地址并不是stop真正的地址,而是标记的最后的地址,读取stop时,由于stop占4个字节,stop真实地址 = stop打印的地址-0x4
      image.png
    • stop内存地址中存储的值表示什么?在增加一个方法/块/c++/属性的方法(多3个),发现其值也会增加对应的数,例如增加一个test1方法 image.png
  • __sanitizer_cov_trace_pc_guard方法 ,主要是捕获所有的启动时刻的符号,将所有符号入队

    • 参数guard是一个哨兵,告诉我们是第几个被调用的
    • 符号的存储需要借助于链表,所以需要定义链表节点MMLNode
    • 通过OSQueueHead创建原子队列,其目的是保证读写安全
    • 通过OSAtomicEnqueue方法将node入队,通过链表的next指针可以访问下一个符号
  • 【第三步:获取所有符号并写入文件】 -while循环从队列中取出符号,处理非OC方法的前缀,存到数组中

    • 数组取反,因为入队存储的顺序是反序的
    • 数组去重,并移除本身方法的符号
    • 将数组中的符号转成字符串并写入到沙盒tem文件夹下mm.order文件中 我们写在touch方法里:
    -(void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event
        {
            //定义数组
            NSMutableArray<NSString *> * symbolNames = [NSMutableArray array];
    
            while (YES) {//一次循环!也会被HOOK一次!!
               MMNode * node = OSAtomicDequeue(&symbolList, offsetof(MMNode, next));
    
                if (node == NULL) {
                    break;
                }
                Dl_info info = {0};
                dladdr(node->pc, &info);
        //        printf("%s \n",info.dli_sname);
                NSString * name = @(info.dli_sname);
                free(node);
    
                //是否为OC函数 不是在前面加"_"
                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"];
            //字符串写入文件
            //文件路径 temp 真机
            NSString * filePath = [NSTemporaryDirectory() stringByAppendingPathComponent:@"mm.order"];
            //文件内容
            NSData * fileContents = [funcStr dataUsingEncoding:NSUTF8StringEncoding];
            [[NSFileManager defaultManager] createFileAtPath:filePath contents:fileContents attributes:nil];
    }
    

    需连接真机,点击一下。 然后下载到本地 image.png
    image.png image.png

  • 【第四步:拷贝mm.order文件,放入指定位置,并配置路径】一般将该文件放入主项目路径下,并在Build Settings -> Order File中配置./mm.order.

下面是配置order前后的对比(上边是配置前的LinkMap,下边是配置后LinkMap符号顺序的)

之前的: image.png 之后的: image.png

注意点:避免死循环

  • Build Settings -> Other C Flags的如果配置的是-fsanitize-coverage=trace-pc-guard,在while循环部分会出现死循环(我们在touchBegin方法中调试) image.png

  • 我们打开汇编调试,发现有3个__sanitizer_cov_trace_pc_guard的调用
    image.png

  • 第一次是bl 是 touchBegin image.png

  • 第二次 bl 是因为while 循环。 即 只要是跳转,就会被hook,即有 b(无条件跳转)bl(条件跳转)的指令,就会被hook
    image.png

  • 第三次 bl 是 printf

解决方式:将BuildSetting中的other C Flags的-fsanitize-coverage=trace-pc-guard ,改成-fsanitize-coverage=func,trace-pc-guard

参考链接