iOS APP启动优化及二进制重排

一、APP启动流程及性能检测

平时一般所说的启动优化指的是冷启动优化。APP的启动分为两个大的阶段。一般来说以APP的入口函数main函数为分界线(点击了解APP启动详细流程):

main函数前

APP启动到main执行前经历很多操作,包括启动前的环境准备等等。但是针对APP启动优化,我们主要就四个阶段,这四个阶段我们可以通过配置工程的环境变量DYLD_PRINT_STATISTICS打印出来:

DYLD_PRINT_STATISTICS.png

运行APP,打印结果如下:

Total pre-main time:  31.70 milliseconds (100.0%)
         dylib loading time:  27.29 milliseconds (86.0%)
        rebase/binding time: 411015771.5 seconds (61552300.3%)
            ObjC setup time:  78.12 milliseconds (246.4%)
           initializer time:  74.97 milliseconds (236.4%)
           slowest intializers :
             libSystem.B.dylib :   6.10 milliseconds (19.2%)
   libBacktraceRecording.dylib :   7.03 milliseconds (22.1%)
               libobjc.A.dylib :   1.69 milliseconds (5.3%)
    libMainThreadChecker.dylib :  57.82 milliseconds (182.3%)

从结果可以看出四个阶段的名称以及他们对应的耗时。

  • dylib loading
    dylib loading阶段是动态链接库dylib加载动态库的阶段,包括系统动态库和我们自己的动态库。这个阶段的优化主要针对的是我们自己的动态库,因此为了优化启动速度,尽量少用或不用动态库,如果存在多个动态库,可以进行动态库和并。苹果官方建议的动态库数量最好超过6个。
  • rebase/binding
    rebase/binding这个阶段实际就是两个操作rebase和binding。rebase就是偏移修正,binding是符号绑定。

rebase它存在的意义是什么呢?
在我们的程序编译生成的二进制文件里面,我们所有的方法、函数调用都有一个地址,这个地址就是它们所在的二进制文件中的偏移地址。当二进制文件加载到内存中运行时,系统会随机分配一个地地址(ASLR),rebase阶段就是把这个随机值和偏移地址相加得到方法、函数在内存中的地址。所以叫偏移修正。

binding 这里主要进行符号绑定,绑定的是外部动态库。

在这一阶段优化的方向主要还是尽量少用动态库,尽量减少文件和方法数等。

  • ObjC setup
    这一阶段主要耗时是OC类的注册,因此优化的方向就是减少OC类的使用。

  • intializers
    这一步的耗时主要是C++的构造函数和+load方法,因此尽量少用这些方法,使用避免进行耗时操作。

main函数之后

main函数调用之后到第一个页面(一般来说是个ViewController)展示的时间。
优化方向:

  • 减少启动时的业务逻辑,能延后的尽量延后;
  • 耗时操作放在子线程去做,不要阻碍主线程;
  • 启动页面尽量不要用xib或者storyboard的;

二、二进制重排

原理

二进制重排原理跟系统的内存分配有关。关于系统是如何分配内存的可以点击蓝色字体。由于我们的系统采用的是分页方式(iOS中每页16KB),应用每次启动的时候都是按页加载的,简单地说就是系统只需要加载应用启动过程需要的内存就够了。但是由于分页方式内存结构是不连续的,也就是说,在启动过程中,可能会加载许多的不连续的页,而且可能每个页里面我们只需要很小的一部分内容。如果每次用到某个页里面的一小部分内容就加载整页数据就显得很浪费资源。
举个例子,启动的时候我需要调用100个函数,而这100个函数可能分布在100页内存中,而每页的内存16KB,而假设平均每一个函数1KB,这样也就是说我们启动过程都要加载90% 的无用内存,这是很没有必要的。
在iOS中,由于我们程序启动时加载的内存跟编译顺序有关,文件编译顺序是按文件排列顺序来的,而文件内方法、函数的编译顺序跟代码的书写顺序一致,而我们通常调用一个函数的时候并不一定要加载整个文件,而启动时加载的代码可能分别在不同的文件里面。所以我们想着能不能通过把这些启动需要调用的函数或方法符号在编译时都排列到一起,让他们在内存上连续分布,这样他就能集中某几个里面,我们启动的时候只需要加载这几个页内存就够了,可以很大程度节省内存和时间,达到启动优化的目的。这时候就需要二进制重排了。二进制重排其实就是将我们要调用的而这些函数或方法符号进行重新排列。

PageFault

事实上,我们每次运行一个APP,内存中会缓存我们的APP数据,系统一页一页加载APP数据时,他会判断当前内存中是否已经存在这一页的数据,如果存在则不再加载,直接使用。如果不存在,则从磁盘里把这一页的数据加载到内存里面,这个就叫PageFaul(页错误,也叫页中断)。一次启动的时候发生PageFaul的次数越多月消耗时间。理论上来说第一次打开APP时PageFaul应该是比较多的,这也就是我们第一次打开某个app时会比较慢的部分原因。PageFaul实际上是无法避免的,即使内存中已经加载过这个APP,但是当我们打开其他APP内存不够用时,他会把已经加载过的部分未在使用的数据给抹掉,加载当前APP的数据。通过Instruments调试工具,可以查看APP看的PageFaul次数。如下图:

System Trace.png

PageFault 演示.png

这只是一个简单的demo就有300多次的PageFualt,那么大的APP出现的次数可能更多,优化就显得很有必要。

四、二进制重排初体验

那我们如何进行二进制重排呢?那就要利用Order File了。下面通过一个demo来体验一下二进制重排。首先这个demo结构如下:

demo.jpg

这里我们用linkmap.txt文件来记录我们符号表的变化情况。在使用Order File以前的linkmap.txt符号顺序如下:

使用orderfile前.jpg

可以看到没使用Order File重排以前inkmap.txt是按我们工程文件和方法、函数编写顺序排列的:

demo文件顺序.png

方法、函数编写顺序.jpg

以上是文件和方法在工程中的大致排序,跟linkmap.txt基本上是吻合的。
接下来,我们用Order File文件文件对符号进行重排。首先我们创建一个.order文件,叫testfile.order,这里我放在工程目录下面,如下:

demo+orderfile.jpg

然后在testfile.order里面对符号进行重排,如下:

orderfile.jpg

这里我们只对几个符号进行重排,已验证Order File的效果。然后我们在xcode配置order文件为我们的testfile.order文件,如下:


配置orderfile文件.png

重新运行我们的demo,然后打开linkmap.txt,观察变化。如下:

使用orderfile文件之后的linkmap.jpg

可以看到linkmap.txt中的_main到_doSomething这一段的排序跟我们的testfile.order文件的排序是一样的。这样我们就是简单地体验了一下二进制重排了。
那么问题来了,我们如何获取到应用启动过程中的所调用的所有函数和方法以方便进行符号重排呢?因为我们的项目代码是不断变化的,我们能不能通过自动Hook所有启动时调用的函数统一写到我们的Order File里面呢?也许我们可以通过fishhook来hook相关的函数objc_msgSend和部分block(参考抖音研发实践:基于二进制文件重排的解决方案 APP启动速度提升超15%),但是fishhook有它的局限性,无法hook所有方法, 而且实现起来比较麻烦。因此可以采用Clang插装方案。

五、Hook一切的终极武器Clang插装

Clang是LLVM编译器的前端,通过Clang我们可以再编译期每个调用的函数的地方都插入一个特定的函数,这样就实现了Hook所有函数的目的。

Clang插装配置

Clang官方也给我们提供了这样的方式,通过文档说明,我们在xcode配置-fsanitize-coverage=trace-pc-guard 如下:

截屏2021-07-12 上午11.31.33.png

然后在我们启动页面实现以下两个函数:

void __sanitizer_cov_trace_pc_guard_init(uint32_t *start,
                                                    uint32_t *stop) {
}

void __sanitizer_cov_trace_pc_guard(uint32_t *guard) {
}

当我们配置-fsanitize-coverage=trace-pc-guard时,Clang会在编译的时候在相应的地方插入sanitizer_cov_trace_pc_guard_init和__sanitizer_cov_trace_pc_guard两个函数,并且会自动调用,但是函数的实现我们可以自己定义,在任意的文件里面实现。
这其中_sanitizer_cov_trace_pc_guard_init启动时只调用一次,而__sanitizer_cov_trace_pc_guard在每个函数(包括OC方法、block调用等)调用前会被调用一次。
那有了这两个函数之后又是如何获得Hook到的函数符号呢?

拿到函数返回地址

因为每个函数调用的时候都会调用一次__sanitizer_cov_trace_pc_guard函数,也就是说我们只要能够获得调用__sanitizer_cov_trace_pc_guard的函数的函数地址就可以了。__builtin_return_address函数是获取当前函数返回上一个调用的地址,因此在__sanitizer_cov_trace_pc_guard调用__builtin_return_address就能获取到负责调用__sanitizer_cov_trace_pc_guard的源函数地址。

guard 这个参数实际上就是一个哨兵参数,就是告诉你第几个被调用的,在这里对我们没有实际意义。

以下代码中PC就是我们要HOOK的函数的地址:

void __sanitizer_cov_trace_pc_guard(uint32_t *guard) {
  void *PC = __builtin_return_address(0);//当前函数返回上一个调用的地址
}
通过返回地址拿到符号

拿到函数地址,就可以通过符号表进行地址映射找到对应的符号。这里使用系统提供的结构体Dl_info和函数dladdr()将地址PC映射找到相应的符号信息缓存到Dl_info中。代码如下:

    void *PC = __builtin_return_address(0);//当前函数返回上一个调用的地址
    Dl_info dl_info;//Dl_info是个结构体
    dladdr(PC, &dl_info);//通过Dl_info可以获取
    printf("dli_fname: %s, dli_sname:%s , dli_fbase:%p, dli_saddr:%p\n", dl_info.dli_fname, dl_info.dli_sname, dl_info.dli_fbase, dl_info.dli_saddr);
 //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;
创建队列保存符号

我们先定义一个队列和结构体用来缓存 (void *PC = __builtin_return_address(0);)这个PC:

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

//定义链表头
static PCTraceNode *symbolList = NULL;

//定义当前节点
static  PCTraceNode *currentNode = NULL;
 PCTraceNode;

void __sanitizer_cov_trace_pc_guard(uint32_t *guard) {

    void *PC = __builtin_return_address(0);//当前函数返回上一个调用的地址
    // 创建链表并插入节点
    pthread_mutex_lock(&mutex);
    PCTraceNode *node = malloc(sizeof(PCTraceNode));
    *node = (PCTraceNode){PC,NULL};
    if (symbolList == NULL) {
        symbolList = node;
        currentNode = node;
    }else{
        currentNode->next = node;
        currentNode = node;//
    }
    pthread_mutex_unlock(&mutex);
}

- (void)touchesBegan:(NSSet *)touches withEvent:(UIEvent *)event
{
    // 遍历链表获取符号表
    PCTraceNode *node = symbolList;
    while (node != NULL) {//一次循环也会被HOOK一次
        Dl_info dl_info;
        dladdr(node->pc, &dl_info);

        printf("dli_sname:%s \n", dl_info.dli_sname);
        node = node->next;
    }
}

当实现以上代码,点击屏幕,打印:

dli_sname:+[ViewController load]
dli_sname:main
dli_sname:-[AppDelegate application:didFinishLaunchingWithOptions:]
dli_sname:-[SceneDelegate window]
dli_sname:-[SceneDelegate setWindow:]
dli_sname:-[SceneDelegate window]
dli_sname:-[SceneDelegate window]
dli_sname:-[SceneDelegate scene:willConnectToSession:options:]
dli_sname:-[SceneDelegate window]
dli_sname:-[SceneDelegate window]
dli_sname:-[SceneDelegate window]
dli_sname:-[ViewController viewDidLoad]
dli_sname:-[ViewController createSubviews]
dli_sname:CGRectMake
dli_sname:doSomething
dli_sname:testBlock_block_invoke
dli_sname:-[ViewController viewWillAppear:]
dli_sname:-[SceneDelegate sceneWillEnterForeground:]
dli_sname:-[SceneDelegate sceneDidBecomeActive:]
dli_sname:-[ViewController viewDidAppear:]
dli_sname:-[ViewController touchesBegan:withEvent:]
dli_sname:-[ViewController touchesBegan:withEvent:]
dli_sname:-[ViewController touchesBegan:withEvent:]
dli_sname:-[ViewController touchesBegan:withEvent:]
dli_sname:-[ViewController touchesBegan:withEvent:]
dli_sname:-[ViewController touchesBegan:withEvent:]
dli_sname:-[ViewController touchesBegan:withEvent:]
dli_sname:-[ViewController touchesBegan:withEvent:]
dli_sname:-[ViewController touchesBegan:withEvent:]
dli_sname:-[ViewController touchesBegan:withEvent:]
dli_sname:-[ViewController touchesBegan:withEvent:]
...

我们发现程序进入死循环,这是因为每次执行一次while循环都会被hook一次。

解决死循环问题

回到我们之前的xcode配置那里,这是因为配置-fsanitize-coverage=trace-pc-guard变量表示的是每一次跳转,都会进行hook。因此我们要改成-fsanitize-coverage=func,trace-pc-guard,表示只hook函数调用,参考Clang官方也给我们提供了这样的方式:

官网截图.jpg

这样在while循环的时候就不会进行hook了,就能解决了死循环的问题。配置如下:

-fsanitize-coverage=func,trace-pc-guard.jpg

再次打印:

dli_sname:+[ViewController load]
dli_sname:main
dli_sname:-[AppDelegate application:didFinishLaunchingWithOptions:]
dli_sname:-[SceneDelegate window]
dli_sname:-[SceneDelegate setWindow:]
dli_sname:-[SceneDelegate window]
dli_sname:-[SceneDelegate window]
dli_sname:-[SceneDelegate scene:willConnectToSession:options:]
dli_sname:-[SceneDelegate window]
dli_sname:-[SceneDelegate window]
dli_sname:-[SceneDelegate window]
dli_sname:-[ViewController viewDidLoad]
dli_sname:-[ViewController createSubviews]
dli_sname:CGRectMake
dli_sname:doSomething
dli_sname:testBlock_block_invoke
dli_sname:-[ViewController viewWillAppear:]
dli_sname:-[SceneDelegate sceneWillEnterForeground:]
dli_sname:-[SceneDelegate sceneDidBecomeActive:]
dli_sname:-[ViewController viewDidAppear:]
dli_sname:-[ViewController touchesBegan:withEvent:]

生成数组并去重

从我们获取的最终符号表里面可以看到,符号表有些事重复的,因此我们要进行去重。直接看demo:

- (void)touchesBegan:(NSSet *)touches withEvent:(UIEvent *)event
{
    NSMutableArray *arr = [[NSMutableArray alloc] init];
    // 遍历链表获取符号表
    PCTraceNode *node = symbolList;
    while (node != NULL) {
        Dl_info dl_info;
        dladdr(node->pc, &dl_info);
        NSString *sname = [NSString stringWithFormat:@"%s", dl_info.dli_sname];
        if (![sname hasPrefix:@"+["] && ![sname hasPrefix:@"-["]) {
            // 如果是不是OC方法则添加下划线
            sname = [@"_" stringByAppendingString:sname];
        }
        if (![arr containsObject:sname]) {// 去重
            [arr addObject:sname];
        }
        NSLog(@"sname:%@ \n", sname);
        node = node->next;
    }
}

创建一个新的数组,缓存符号表。以便后面生成order file。

生成order文件

最后我们要把符号列表自动写入到.order文件里面,这样我们就可以直接使用了。代码如下:

- (void)touchesBegan:(NSSet *)touches withEvent:(UIEvent *)event
{
    NSMutableArray *arr = [[NSMutableArray alloc] init];
    // 遍历链表获取符号表
    PCTraceNode *node = symbolList;
    while (node != NULL) {
        Dl_info dl_info;
        dladdr(node->pc, &dl_info);
        NSString *sname = [NSString stringWithFormat:@"%s", dl_info.dli_sname];
        if (![sname hasPrefix:@"+["] && ![sname hasPrefix:@"-["]) {
            // 如果是不是OC方法则添加下划线
            sname = [@"_" stringByAppendingString:sname];
        }
        if (![arr containsObject:sname]) {// 去重
            [arr addObject:sname];
        }
        NSLog(@"sname:%@ \n", sname);
        node = node->next;
    }
    
    //数组转成字符串
    NSString * funcStr = [arr componentsJoinedByString:@"\n"];
    //字符串写入文件
    //文件路径
    NSString * filePath = [NSTemporaryDirectory() stringByAppendingPathComponent:@"testfile.order"];
    //文件内容
    NSData * fileContents = [funcStr dataUsingEncoding:NSUTF8StringEncoding];
    [[NSFileManager defaultManager] createFileAtPath:filePath contents:fileContents attributes:nil];
}

最后得到testfile.order:

oc.png

生成testfile.order文件之后我们就可以回到 四、二进制重排初体验去使用了。

Hook swift方法

swift 跟OC流程都是一样的,只是配置有所区别。想要hook 到swift方法,需要在Other swift flag 配置编译选项-sanitize-coverage=func -sanitize=undefined。如下图所示:

swift编译配置.png

配置完成并运行成功。接下来查看testfile.order文件:

swift.png

红色波浪线部分就是swift符号,至此二进制重排流程就算完了。

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