App 启动优化

231次阅读  |  发布于3年以前

App的启动过程

App的启动一般是指从用户点击App开始到AppDelegatedidFinishLaunching方法执行完成为止,一般又将启动分为冷启动和热启动。

App启动优化

上文也说了一般启动优化主要优化的是冷启动的过程,热启动做的事情也非常少。所以这里只讲解冷启动过程的优化。冷启动过程又被分为main函数执行之前和main函数执行之后- ##### main函数执行之前

操作系统加载App可执行文件到内存,执行一系列的加载&链接工作,可以通过添加添加环境变量DYLD_PRINT_STATISTICS来查看main函数执行之前都做了什么,同时也可以看出对应消耗的时间

不难发现main函数执行之前主要做了以下几种事情

  1. 只处理首屏渲染相关的任务,其他非首屏的业务例如初始化、注册监听、配置文件的读取等等都放在首页渲染完成之后去做,当然也可以开辟一个线程去处理这些事情。尽量不要占用主线程
  2. 自己的业务逻辑的优化,已经废弃的不需要用的逻辑代码、方法、函数都删除掉,减少每个流程的耗时
  3. 启动时期的页面尽量避免使用xib、storyboard(中间会有个转换的过程也是需要耗时的)UI的主框架尽量使用纯代码

二进制重排基础知识

上文主要是针对特定的阶段做一些优化处理,除了删除的优化方案还有一种优化,就是二进制重排,在讲解二进制重排之前先将几个概念性的东西:

  1. 物理内存

就是运行内存,是指计算机上安装的内存,通俗的将其实就是内存条的大小。 早期的操作系统没有虚拟内存,程序寻址用的都是物理地址,所以没启动一个程序开辟一个进程都要相应的分配一段物理内存给这个程序,这就造成了如下几个问题:

a. 当物理内存被分配完成的时候此时其他程序就不能再被加载到内存(也就是不能运行),此时就需要等待其他程序退出释放内存,此时才能运行新的程序

b. 程序指令都是在物理内存上操作的,那么我这个进程就可以修改其他进程的数据,甚至会修改内核地址空间的数据针对以上的问题也就引出了虚拟内存

  1. 虚拟内存

指的是把硬盘中的一部分空间用来当做内存使用 进程和物理内存之间增加一个中间层,这个中间层就是所谓的虚拟内存,主要用于解决当多个进程同时存在时,对物理内存的管理。提高了CPU的利用率,使多个进程可以同时、按需加载。

所以虚拟内存其本质就是一张虚拟地址和物理地址对应关系的映射表.每个进程都有一个独立的虚拟内存,其地址都是从0开始,大小是4G固定的。 进程开始要访问一个地址,它可能会经历下面的过程:

a. 每次我要访问地址空间上的某一个地址,但是进程间是无法互相访问的,保证了进程间数据的安全(一个进程只能访问给定的这篇虚拟内存的地址)。都需要把地址翻译为实际物理内存地址

b. 所有进程共享这整一块物理内存,每个进程只把自己目前需要的虚拟地址空间映射到物理内存上

c. 每个虚拟内存会划分一个一个页存储(页的大小在iOS中是16K,其他的是4K),进程需要知道哪些地址空间上的数据在物理内存上,哪些不在(可能这部分存储在磁盘上),还有在物理内存上的哪里,这就需要通过页表来记录

d. 页表的每一个表项分两部分,第一部分记录此页是否在物理内存上,第二部分记录物理内存页的地址(如果在的话)

e. 当进程访问某个虚拟地址的时候,就会先去看页表,如果发现对应的数据不在物理内存上,就会发生缺页异常

f. 缺页异常的处理过程,操作系统立即阻塞该进程,并将硬盘里对应的页换入内存,然后使该进程就绪,如果内存已经满了,没有空地方了,那就找一个页覆盖,至于具体覆盖的哪个页,就需要看操作系统的页面置换算法是怎么设计的了。

如下图所示,虚拟内存与物理内存间的关系 如果物理内存被占满,此时又有新的页需要被加载进来,此时新页就会吧长时间没有使用的页覆盖掉

3. ASLR

应为虚拟内存的起始地址与大小都是固定的,这意味着,当我们访问时,其数据的地址也是固定的,这会导致我们的数据非常容易被破解, 为了解决这个问题,所以苹果为了解决这个问题,在iOS4.3开始引入了ASLR技术,其实现原理就是在虚拟内存的头部随机加上一块地址,这样每次启动时虚拟地址的其实址就不一样,所以在程序启动的时候需要做偏移修正。

二进制重排原因

从上文的知识中可以知道,ios程序在加载到虚拟内存的时候会被分成很多很多页,如果此时访问的虚拟地址的一个page,对应的物理地址不存在,则会缺页异常,此时会阻塞进程将这一页加载到物理内存然后在访问。

这里可以通过instrumentsSystem Trace来查看你的项目的缺页异常的数量如下: 步骤:先点击启动->首页加载完成后暂停->然后找到你的项目找到主线程 发现启动之前有两百多个缺页异常,此时我们再看项目在编译时期的默认排列顺序,此时我们写一个简单的demo如下图: 就是写了几个简单的方法,然后项目中选择Build-setting搜索link map然后配置 此时会发现对应配置的文件夹中生成了对应的link-map文件, 发现方法、函数等都是按照在文件中的实现顺序来的,而文件的顺序是按照comple source中的顺序来的如图: 这种情况就造成了每个页有可能只有一个方法是有用的,其他方法、函数等都不是在启动阶段调用的,这就造成了在启动时期缺页异常的数量会很多,也就造成了启动时间变长的情况。 这也就是需要进行二进制重排的原因

二进制重排原理

上文分析了二进制重排的原因,就是应为页中空间的浪费没有充分利用每个页的空间造成缺页异常数量增多,二进制重排的原理其实就是将启动阶段用到的方法、函数全部排在最前面,这样就能充分利用每个页的空间,与此同时也降低了缺页异常的数量。如下图所示:

明显减少了一大半的缺页异常的数量

二进制重排实践

通过上面的原理分析可以知道,如果做二进制重排只需要改变编译时期方法、函数等的排列顺序就行。其本质就是就是对启动加载的符号进行重新排列

  1. 配置开启SanitizerCoverage,在build setting中搜索Other C Flags,如下图 如果是OC项目则添加-fsanitize-coverage=func,trace-pc-guard,如果是swift项目则添加-sanitize-coverage=func-sanitize=undefined

2 . 添加hook方法

  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) {
    //guard 是一个哨兵,告诉我们是第几个被调用的
    // 这个地方 是过滤掉了load方法,所以这里需要注释掉
    if (!*guard) return;
      /*
       - PC 当前函数返回上一个调用的地址
       - 0 当前这个函数地址,即当前函数的返回地址
       - 1 当前函数调用者的地址,即上一个函数的返回地址
      */
    void *PC = __builtin_return_address(0);
    char PcDescr[1024];
    printf("guard: %p %x PC %s\n", guard, *guard, PcDescr);
   }

主要的方法在于__sanitizer_cov_trace_pc_guard方法,在这里我们可以取到对应方法的地址,为什么方法执行之前会先调用__sanitizer_cov_trace_pc_guard方法呢,可通过断点调试查看,在一个方法或者函数的起始处大断点,再看汇编代码如下图: 发现在方法执行之前插入了__sanitizer_cov_trace_pc_guard方法,所有的函数执行都会限制性__sanitizer_cov_trace_pc_guard方法,在block前面也打个断点发现 block执行前也会被插入__sanitizer_cov_trace_pc_guard方法,继续查看swift-oc混编是swift方法是否会被hook 也会被hook,所以也验证了clang插桩的方法能覆盖所有方法、函数。

3. 获取符号 上述hook方法中我们知道可以拿到当前方法或者函数的地址,拿到地址之后我们可以通过dladdr方法去除对应方法或者函数的信息具体代码如下图: 发现dli_sname就是我们想要的符号,接下来的操作主要就是把这些符号存储下来然后生成order然后工程再配置对应的Order file就算完成了。

4. 输出order文件 上文中已经可以拿到符号了,最后的工作就是输出order文件。 具体思路:我们可以在__sanitizer_cov_trace_pc_guard将函数地址信息存储下来然后给app添加一个点击屏幕的监听事件,等到首屏加载完毕说明启动完成所有所需要加载的方法也就加载完成,此时我们再在这个方法遍历地址信息,输出符号。 我这里借用的链表存储,所以先要建立一个节点如下图: 然后再通过OSQueueHead创建原子队列,其目的是保证读写安全。 通过OSAtomicEnqueue方法将node入队,通过链表的next指针可以访问下一个符号 此刻地址的储存完成下一步就是读取写入order文件:具体代码如下

      -(void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event
      {
    //定义数组
    NSMutableArray<NSString *> * symbolNames = [NSMutableArray array];

    while (YES) {//一次循环!也会被HOOK一次!!
       SYNode * node = OSAtomicDequeue(&symbolList, offsetof(SYNode, 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);

        BOOL isObjc = [name hasPrefix:@"+["]||[name hasPrefix:@"-["];
        //需要注意如果不是OC方法需要添加下划线
        NSString * symbolName = isObjc ? name : [@"_" stringByAppendingString:name];
        [symbolNames addObject:symbolName];
    }
    //反向数组
    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"];
    //字符串写入文件
    //文件路径
    NSString * filePath = [NSTemporaryDirectory() stringByAppendingPathComponent:@"tudou.order"];
    //文件内容
    NSData * fileContents = [funcStr dataUsingEncoding:NSUTF8StringEncoding];
    [[NSFileManager defaultManager] createFileAtPath:filePath contents:fileContents attributes:nil];
      }

运行完成发现生成了order文件 5. Xcode配置order文件 如图配置文件 6. 查看二进制重排结果 最后同样的查看生成的link map文件: 没有二进制重排之前: 发现是按照文件按照方法的顺序来的。 二进制重排之后: 发现此时就是按照我们的order文件的顺序来的

- EOF -

Copyright© 2013-2020

All Rights Reserved 京ICP备2023019179号-8