OC底层探索26-App启动时间优化OC底层探索26-App启动时间优化

2021-08-09 11:07:27 浏览数 (1)

本文中所说的启动都指:冷启动。 冷启动:内存中不包含APP的数据,所有数据都需要从Mach-o载入到内存中,提供给应用使用。 热启动:内存中仍然存在APP的数据,数据不需要重新载入内存。

1、启动耗时

1.1 冷启动4个阶段
  1. dyld:动态库链接、初始化;
  2. runtime中:所有类加载 load方法执行C 相关函数
  3. main函数:call main()
  4. main函数之后:AppDelegate中的方法直到第一个页面显示完成;
1.2 启动耗时查看

想要优化启动时间,就需要要知道启动时app都做了什么?通过添加环境变量可以打印出APP的启动时间分析(Edit Scheme -> Run -> Arguments)

真机测试结果:

  • main函数之前可以看到大概需要四个步骤:
  1. dylib loading、
  2. rebase/binding、
  3. ObjC setup、
  4. initializers

那么这四步分别做了什么呢?这里让我先盗个图...

1.3 提高main()函数之前的加载时间
代码语言:javascript复制
1.动态库加载越多,启动越慢。

2.ObjC类,方法越多,启动越慢。
3.ObjC的 load越多,启动越慢。

4.C的constructor函数越多,启动越慢。
5.C  静态对象越多,启动越慢。

2、耗时优化策略

2.1 删除无用代码,合并一些同样功能的类

OC类的注册耗时 (OC类越多,越耗时),swift的类不会存在这个问题。

检测iOS项目中未使用的方法文中有详细的介绍,工具和使用方式。

2.2 减少 load方法

方法交换等好多操作多多少少的会使用 load方法来执行一些操作,但是并不是每个方法都需要在 load那么早。建议部分操作可以延迟到 initialize中.

2.3 合并动态库

减少dyly动态库的使用,苹果建议动态库不超过6个

  • 可执行文件Mach-O->显示包内容->Frameworks中可以查看项目中使用到的动态库。
  • 因为项目是swift项目,所以有一些swift的系统库。不过61个库还是吓自己一跳。
2.4 rebase/binding

减少重定向绑定操作的耗时;

  • rebase:通过aslr加密技术所有使用到的符号重定向
  • binding绑定:将aslr加密后的地址绑定给对应的符号

ASLR(Address space layout randomization)地址空间配置随机加载,每次载入虚拟内存后,需要将原地址加上ASLR随机偏移值来进行内存读取.

3、虚拟内存与物理内存

  • 物理内存:真实内存条。物理内存的地址叫做物理地址(真实存在的);
  • 虚拟内存:(一张表 保存虚拟地址和物理地址对照表,也称为页表) 用来管理应用虚拟地址和物理内存地址的映射关系,存在物理内存的操作系统模块中需要硬件的支持;5大分区都是存在虚拟内存地址;
  • 内存分页管理:所有的内存数据都被分割成 一页为单位的页,应用的虚拟内存被分为一页一页,首地址都为0。
  • 内存页大小: MacOS 4k iOS 16k。
  • 虚拟空间大小:每个应用(进程)默认可以分配4G大小。但它实际只是一张页表,记录映射关系就可以
  • 安全性提高:通过页表也解决的安全问题,当前进程只能访问系统分配的页表地址,无法访问真实的物理地址、以及其他页面的内容;

恰巧看到一个很贴切的比喻: 比如你1T空间的百度网盘,你用了200M,网盘给你200M的空间资源,然后将这个资源地址和你的网盘账号关联起来。而你的网盘账号只是记录了每个资料和资料存放地址的映射关系列表,并不会占用你电脑空间。 百度网盘:物理内存 网盘账号:虚拟内存、虚拟页表

4、二进制重排:

目的:二进制重排就是为了把启动用到的这些数据,按调用顺序整合到一起。这样启动用到的数据()都在前面。就可以减少很多次pageFault,提高启动速度。 思路:获取启动时的符号调用顺序查看Mach-O中符号加载到虚拟页表的顺序(link map)进行排列。

4.1 查看pageFault
  • 缺页异常(pageFault):读取到没有加载到物理内存中一页时触发;多次的pageFault也会造成启动时间的加长;
  • iOS中每一页是16K大小,但是16K中,可能真正在启动时刻需要用到的,可能不到1K。 但是启动需要访问到这1K数据,不得不把整页都加载

image.png

  • 可以看到pageFault出现了3040次,看起来还是挺少的。注:这是热启动的结果.
4.2 查看Mach-O中符号加载到虚拟页表的顺序(link map)

Linkmap是iOS编译过程的中间产物,记录了二进制文件的布局.

查看包内容:

  • 函数顺序:(书写顺序)
  • 如果这个符号加载顺序和符号调用顺序一致就解决了这个问题;
4.3 oreder.file-调整符号加载顺序

使用oreder.file,把启动时的方法调用顺序进行排列。

  • 使用oreder.file可以调整方法的加载顺序;
4.4-获取符号调用顺序
  • 有了oreder.file这个手段,只要再知道符号调用顺序就完美了,继续往下看。

5、获取调用顺序-Clang插桩获取调用顺序

注:也可以使用fishHook:系统函数 -- objc_msgSend,但是swift方法和c 函数无法hook;

llvm内置了一个简单的代码覆盖率检测(SanitizerCoverage)。它在编译期对函数级、基本块级和边缘级插入对用户定义函数的调用。 clang官方文档

5.1 开启SanitizerCoverage
  • 开启OC项目: Build Settings-> Other C Flags 中添加 -fsanitize-coverage=func,trace-pc
  • 开启Swift项目: Build Settings-> Other Swift Flags 中添加 --sanitize-coverage=func-sanitize=undefined

在汇编阶段只要有b,bl都会被hook,包含for、while循环(很坑)。 所以需要在命令里加上:coverage=func;

编译之后会报2个错误:

  • 说是__sanitizer_cov_trace_pc_guard_init,__sanitizer_cov_trace_pc_guard两个符号没有找到,那我们就自己写一个。这就是Clang的核心方法。
5.2 __sanitizer_cov_trace_pc_guard调用时机

查看调用时机,就需要借助汇编,在ViewController中的touchesBegand打下一个端点并且开启汇编;

  • 每一个方法、block、函数在调用前,都会被clang在编译阶段将__sanitizer_cov_trace_pc_guard符号插入方法的函数调用栈
5.3 获取所有符号地址
代码语言:javascript复制
// clang依赖库
#include <stdint.h>
#include <stdio.h>
#include <sanitizer/coverage_interface.h>
// dl_info
#import <dlfcn.h>
// 原子队列
#import <libkern/OSAtomic.h>

@implementation ClangTools
//定义原子队列
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 %pn", start, stop);
    for (uint32_t *x = start; x < stop; x  )
      *x =   N;
}
// 核心方法!!!!
void __sanitizer_cov_trace_pc_guard(uint32_t *guard) {
//    if (!*guard) return; 系统方法哨兵,这里不需要
    //__builtin_return_address(0); 0表示当前函数的栈返回地址,也就是调用该函数的方法地址;
    void *PC = __builtin_return_address(0);
    SYNode *node = malloc(sizeof(SYNode));
    *node = (SYNode){PC,NULL};
    
    //加入队列
    // offsetof两个作用:1. 获取SYNode内存大小 2. 移动SYNode大小后的地址赋值给next
    // offsetof方便链表使用
    OSAtomicEnqueue(&symbolList, node, offsetof(SYNode, next));
}
@end
  • 通过原子队列将所有符号的地址存入一个链表结构
5.4 将符号名称写成order.file
代码语言:javascript复制
 (void)clangDataForWriteFile {
    //定义数组
    NSMutableArray<NSString *> * symbolNameList = [NSMutableArray array];
    
    while (YES) {
        // 从队列中取出SYNode
        SYNode * node = OSAtomicDequeue(&symbolList, offsetof(SYNode, next));
        
        if (node == NULL) {
            break;
        }
        
        Dl_info info = {};
        // 根据符号地址获取符号信息
        dladdr(node->pc, &info);
        NSString * tempName = @(info.dli_sname);
        free(node);
        // 除OC方法,其他方法头需要加上_
        BOOL isObjc = [tempName hasPrefix:@" ["]||[tempName hasPrefix:@"-["];
        NSString * symbolName = isObjc ? tempName : [@"_" stringByAppendingString:tempName];
        [symbolNameList addObject:symbolName];
    }
    // 数组取反
    NSEnumerator * enumerator = [symbolNameList reverseObjectEnumerator];
    NSMutableArray * funcs = [NSMutableArray arrayWithCapacity:symbolNameList.count];
    //去重
    NSString * ttempName;
    while (ttempName = [enumerator nextObject]) {
        if (![funcs containsObject:ttempName]) {
            [funcs addObject:ttempName];
        }
    }
    // 当前函数并非属于启动函数
    [funcs removeObject:[NSString stringWithFormat:@"%s",__FUNCTION__]];
    //写文件
    NSString * filePath = [NSTemporaryDirectory() stringByAppendingPathComponent:@"HRTest.order"];
    NSString * funcStr = [funcs componentsJoinedByString:@"n"];
    NSData * fileData = [funcStr dataUsingEncoding:NSUTF8StringEncoding];
    [[NSFileManager defaultManager] createFileAtPath:filePath contents:fileData attributes:nil];
    
    NSLog(@"successful-clangTotal : %i",funcs.count);
}
  • 在第一个页面出现的位置调用,获取到启动符号的执行顺序,将HRTest.order文件导出。
  • 根据本文中4.3,修改项目oreder.file配置
demo下载

用在我自己的项目中,冷启动平均减少了50毫秒的启动时间。其实还是不错~

参考链接: AppOrderFiles iOS优化篇之App启动时间优化

0 人点赞