1.冷发动

1.1 什么是冷发动?

冷发动是指内存中不包括该应用程序相关的数据,必需求从磁盘载入到内存中的发动进程。

注意:从头翻开 APP, 不一定便是冷发动。

  1. 当内存不足,APP被体系主动杀死后,再发动便是冷发动。
  2. 假如在从头翻开 APP 之前,APP 的相关数据还存储在内存中,这时再翻开 APP,便是热发动
  3. 冷发动与热发动是由体系决议的,咱们无法决议。
  4. 当然设备重启今后,第一次翻开 APP 的进程,一定是冷发动

1.2 如何计算冷发动耗时?

一般来讲,计算 APP 发动时长,以main 函数为节点,分两个大阶段:

  • main 函数之后的代码,是咱们自己写的,咱们能够自行计算进入 main 函数到第一个界面显示的耗时
    • main 函数里打印一下当时的时间,
    • 在第一个要显示的控制器的viewDidLoad 办法中打印一下当时时间
    • 两个时间的差值,即为main函数后的加载时长
  • main 函数之前,为pre-main阶段,由所以体系在做工作,这段时间 耗时,咱们没办法直接计算,需求查 看体系给咱们的反应

1.2.1 pre-main阶段都做了什么?

接下来看一下项目中的pre-main阶段的耗时。

  • 检查体系给的反应需求 添加一个环境变量
  • 添加路径:在 Xcode -> Edit Scheme -> Run -> Arguments -> Environment Variables 中,
  • 添加一个环境变量 DYLD_PRINT_STATISTICS:1。

下图是我项目的加载耗时:

使用二进制重排 & Clang插桩技术对iOS冷启动做优化太爽了

耗时进程分为以下4部分:

  1. dylib loading time : 是指动态库加载的耗时,体系的动态库做过优化,耗时较少。 苹果官方推荐最多不要超过6个外部动态库,多余6个,需求考虑兼并动态库,兼并动态库对于发动时期的优化,十分有效。 像微信的动态库前期有八九个,现在也优化成6个了。
  2. rebase/binding
  • rebase:是指地址的 偏移批改耗时。
    • 在编译生成二进制文件的时分,每个函数都有一个地址,这个地址是相对于二进制文件的偏移地址
    • 在发动时,也便是在二进制文件在加载到虚拟内存的时分,为了安全起见,苹果有个安全机制(ASLR),会在整个二进制文件的最前面随机加一个偏移值
    • 比方 A 函数,相对于二进制文件的偏移值是 0x003。 发动时,整个二进制文件被分配了一个随机值0x100。 那么A 函数在内存中的实际地址是 0x003 + 0x100 = 0x103。
    • 偏移批改指的便是计算办法在虚拟内存中的地址的进程!
  • binding: 动态库的办法绑定,是指将办法姓名与办法的完成进行绑定进程的耗时。
    • 比方 NSLog 办法,在加载的时分需求先找到Foundation库,再找到库里的NSLog的办法的完成,将办法姓名和办法完成绑定在一起。
  1. Objc setup time: 注册一切 OC类 耗时, 类越多耗时越多,有人计算过2万个自界说的OC的类,大约耗时800毫秒。删去不用的类,能够削减耗时。
  2. initializer time:load办法 和 C++结构函数的耗时.削减重写load办法,尽量将工作延迟到 main 办法今后,能够削减耗时。
  3. slowest intializers : 指出了最耗时的几个库是下面的6个库(最后一个是我的项目)。

1.2.2 pre-main阶段耗时优化办法总结:

  • 削减外部动态库的数量
  • 不用的类和办法,删去
  • 类尽量运用懒加载,也便是尽量不要重写load办法。
  • 发动时加载的数据运用多线程
  • 运用纯代码。不用xib storyboard(要额外进行代码解析转化和页面的渲染)

以上办法,都是和自己的项目代码息息相关的优化计划。不同项目具体是实施动作不相同。

还有一个优化办法,不管是什么项目,实施动作都相同 ,对什么项目都有效,那便是二进制重排!

2. 二进制重排

学习二进制重排,首先要知道数据是如何加载到内存中的

咱们已经知道数据加载到内存的进程,当虚拟内存页还没有对应的物理内存页时,会呈现缺页反常(PageFault)。

当冷发动时,物理内存中是没有数据的,这时会呈现很多的缺页反常,在iOS出产环境的app,在发生Page Fault进行从头加载时,iOS体系还会对其做一次签名验证,因此 iOS 出产环境的 Page Fault 比Debug环境下所产生的耗时更多

这儿有没有优化空间呢?接下来便是优化计划:二进制重排!

在了解二进制重排之前,再了解下在项目编译生成二进制文件的时分,类及其内部办法完成的摆放次序是什么样的呢?

2.2.1 二进制文件中办法完成排序是什么样的?

  1. 在 viewController 中,先随意写几个办法。

使用二进制重排 & Clang插桩技术对iOS冷启动做优化太爽了

  1. 再看下源文件的编译次序

使用二进制重排 & Clang插桩技术对iOS冷启动做优化太爽了

接下来检查 Link Map文件检查符号次序, 检查办法:

  1. 翻开link map

****

使用二进制重排 & Clang插桩技术对iOS冷启动做优化太爽了

  1. 编译生成link map 文件
  2. 找到link map 文件
  • 项目目录中,生成的 app 右键,show in Finder

使用二进制重排 & Clang插桩技术对iOS冷启动做优化太爽了

  • 找到 app 的上上级目录

使用二进制重排 & Clang插桩技术对iOS冷启动做优化太爽了

  • 进入Intermediates.noindex ->TraceDemo.build -> Debug-iphonesimulator -> TraceDemo.build -> TraceDemo-LinkMap-normal-x86_64.txt

使用二进制重排 & Clang插桩技术对iOS冷启动做优化太爽了

  1. 翻开link map 文件,找到自己的类及办法的姓名

使用二进制重排 & Clang插桩技术对iOS冷启动做优化太爽了

5.咱们能够直观的看出 link map中符号的次序,类是以源文件的编译次序,从上到下按序摆放。办法名是以类中办法的书写次序,由上到下排序。

2.2.2 为什么需求二进制重排?

从源码的履行次序上看,应该是 load -> test2 -> viewDidLoad -> test1.

可是二进制文件中符号的次序是办法从上到下的书写次序没有依照调用次序去摆放。

在冷发动分页加载二进制文件时,发现很多页中都有发动时需求用到的办法,那么即使页里边也存在发动时不需求的办法,可是由于内存是分页管理的,要加载就要整页加载。这样就导致了很多不需求在 pre-main 阶段履行的办法,也会被加载到内存中,添加了发动的耗时。

例如,发动需求加载100个页,每个页能够包括20个办法。可是每个页里只有2个办法是发动时后用到的。这样实际上发动时必需求的办法是2 * 100 = 200个,假如将这200个办法紧挨着放在一起,那么只需求2页。比100个页,削减了98页。这样耗时就会大大下降。

使用二进制重排 & Clang插桩技术对iOS冷启动做优化太爽了

2.2.3如何进行二进制重排?

1. 二进制重排的办法

在项目编译生成二进制文件的时分,找到发动时需求的办法,并且将它们放在一起 从头排序,这便是二进制重排。

两个要害点: 找到发动时需求办法 & 办法 的重排序


2.办法的重排序:

重排序其实很简单。xcode已经为咱们提供了这个机制,它运用的链接器叫做 ld, ld有一个参数叫做Order File, 咱们能够经过配置order文件,来使编译时生成的二进制的文件的Link Map种的符号次序,依照咱们指定的次序摆放生成。并且 libobjc 实际上也做了二进制重排

使用二进制重排 & Clang插桩技术对iOS冷启动做优化太爽了


【第一步】在项目根目录下建一个xxx.order的文件,里边写上依照自己想摆放的次序,写上办法或许函数的姓名。(假如写了一个不存在的符号,也不会报错,会被主动过滤掉~)

使用二进制重排 & Clang插桩技术对iOS冷启动做优化太爽了

【第二步】在 Build Settings搜索order file的文件。将项目根目录创立的文件,设置上去。

使用二进制重排 & Clang插桩技术对iOS冷启动做优化太爽了

【第三步】从头编译,检查 Link Map 文件的次序,果然,依照咱们指定的次序摆放啦!

使用二进制重排 & Clang插桩技术对iOS冷启动做优化太爽了

3. 静态插桩 – 找到冷发动时的一切办法

接下来,需求做的便是写入 order 文件里的符号了,咱们不可能手写上一切的发动时需求的履行的符号,这儿的一切符号包括,调用的办法、函数、C++结构办法、swift办法、block。

这儿运用LLVM 内置的简单代码覆盖率检测工具(SanitizerCoverage)。它在边际、 函数、基本块 等级上刺进对用户界说函数的调用。

  • edge(默许):检测边际(一切的指令跳转都会被刺进对用户界说函数的调用, 如循环、分支判断、办法函数等)。
  • bb检测基本块。
  • func:仅将检测每个 功能的输入块(这个便是咱们要重排序的符号)。

依照文档,

  • 【第1步】搜索并设置Other C Flags/ Other C++ Flags为 ****-fsanitize-coverage=func,trace-pc-guard这儿要用func, 不能用默许的edge, 不然会形成死循环)。
  • 假如有swift ,需求设置Other Swift Flags设置为**** -sanitize-coverage=func -sanitize=undefined

使用二进制重排 & Clang插桩技术对iOS冷启动做优化太爽了

使用二进制重排 & Clang插桩技术对iOS冷启动做优化太爽了

  • 【第2步】编译器将刺进对模块结构函数的调用,所以咱们要完成这个办法:
__sanitizer_cov_trace_pc_guard_init(uint32_t *start, uint32_t *stop);

经过打印start, stop 地址的内容,从 start 地址开始,到 stop 地址的前4位,存储的是 uint32 的1-19的数字。

使用二进制重排 & Clang插桩技术对iOS冷启动做优化太爽了

咱们能够从这个函数中知道, 当时项目中自界说的功能输入块的数量。

  • 【第3步】编译器会在生成二进制文件的时分,在每个func调用之初,刺进以下代码
__sanitizer_cov_trace_pc_guard(&guard_variable)

也便是说,每个办法在履行的时分,都会调用上面这个办法。 接下来:

      1. 咱们要完成这个办法,并在这个办法里,获取到本办法结束后要回来的地址
// 获取到本办法结束后,要回来的地址去,这个地址包括在被hook的办法内部,但不是被hook 的办法的首地址
void *PC = __builtin_return_address(0);

使用二进制重排 & Clang插桩技术对iOS冷启动做优化太爽了

      1. 并将地址保存一个体系的原子行列(( 底层实际上是个栈结构 , 使用行列结构 + 原子性来保证次序 ))中,运用原子行列,是为了避免多线程资源抢夺。原子行列的存值办法如下:
// 将结构体存入到原子行列中。
// offsetof(type,member) 回来结构体中成员的偏移值,由于指针PC是8字节,所以这儿回来8字节。
// 详见下图
OSAtomicEnqueue(&symbolList, node, offsetof(SYNode, next));

使用二进制重排 & Clang插桩技术对iOS冷启动做优化太爽了

每个 SYNode 首地址都距离上一个偏移 PC 所占的字节数。这样做的妙处便是,每个SYNode 的next 的地址,恰巧便是下一个结构体的地址。这样方便获取行列里边的一切数据。

  • 【第4步】咱们在点击屏幕的事件中
    • 把存储到原子行列中的地址遍历出来,
    • 根据地址获取当时地址地点的办法的名称并存入数组中,
typedef struct dl_info {
        const char      *dli_fname;     /* 地点文件 */
        void            *dli_fbase;     /* 文件地址 */
        const char      *dli_sname;     /* 符号名称 */
        void            *dli_saddr;     /* 函数起始地址 */
} Dl_info;
//这个函数能经过函数内部地址找到函数符号
int dladdr(const void *, Dl_info *);
    • 由于原子行列是栈结构,先进后出,所以咱们需求将数组倒序摆放
    • 由于办法可能会被屡次调用,咱们需求去重
    • 再将最后咱们当时点击屏幕的办法删去掉
    • 将办法姓名的数组,转成字符串,写到沙盒文件中

完整代码如下:

//
//  ViewController.m
//  TraceDemo
//
//  Created by hank on 2020/3/16.
//  Copyright  2020 hank. All rights reserved.
//
#import "ViewController.h"
#import <dlfcn.h>
#import <libkern/OSAtomic.h>
#import "TraceDemo-Swift.h"
@interface ViewController ()
@end
@implementation ViewController
+(void)initialize
{
}
void(^block1)(void) = ^(void) {
};
void test(){
    block1();
}
+(void)load
{
}
- (void)viewDidLoad {
    [super viewDidLoad];
    [SwiftTest swiftTestLoad];
    test();
}
-(void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event
{
    NSMutableArray <NSString *> * symbolNames = [NSMutableArray array];
    while (YES) {
        SYNode * node = OSAtomicDequeue(&symbolList, offsetof(SYNode, next));
        if (node == NULL) {
            break;
        }
        Dl_info 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<NSString *> *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:@"demo.order"];
    NSData * fileContents = [funcStr dataUsingEncoding:NSUTF8StringEncoding];
    [[NSFileManager defaultManager] createFileAtPath:filePath contents:fileContents attributes:nil];
    NSLog(@"%@",funcStr);
}
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.
}
//原子行列
static  OSQueueHead symbolList = OS_ATOMIC_QUEUE_INIT;
//界说符号结构体
typedef struct {
    void *pc;
    void *next;
}SYNode;
void __sanitizer_cov_trace_pc_guard(uint32_t *guard) {
    // 会导致load 办法被return
//    if (!*guard) return; 
    // 获取到本办法结束后,要回来的地址去,这个地址包括在被hook的办法内部,但不是被hook 的办法的首地址
    void *PC = __builtin_return_address(0);
    SYNode *node = malloc(sizeof(SYNode));
    *node = (SYNode){PC,NULL};
    //进入
    OSAtomicEnqueue(&symbolList, node, offsetof(SYNode, next));
}
@end

2.2.4 如何验证二进制重排的作用?

1.检查缺页反常数量Page Fualt:

  1. 检查一下项目的缺页反常数量。注意需求卸载 APP 或许重启手机,来保证这个APP完全没有被加载到内存中,由于假如物理内存中有该APP的数据,
  2. 翻开 Instrument ->System Trace

使用二进制重排 & Clang插桩技术对iOS冷启动做优化太爽了

3.挑选真机、项目、点击发动,当第一个页面显示出来后,点击中止。

  1. xcode 12搜索main thread, 挑选Virtual MemoryFile Backed Page in 便是缺页反常的数量

使用二进制重排 & Clang插桩技术对iOS冷启动做优化太爽了

优化前:项目的缺页遗产数量是427


优化后:

使用二进制重排 & Clang插桩技术对iOS冷启动做优化太爽了

优化前:项目的缺页遗产数量是286


削减了发动时大约40%的缺页反常~


3.主动更新order 文件

随着代码迭代,order文件需求更新,每次手动更新很麻烦,所以需求主动更新。

使用二进制重排 & Clang插桩技术对iOS冷启动做优化太爽了

brew install ios-deploy
APP_ORDER_DIR=appOrderDir
APP_ORDER=./$APP_ORDER_DIR/Documents/app.order
mkdir $APP_ORDER_DIR
ios-deploy --download=/Documents --bundle_id $PRODUCT_BUNDLE_IDENTIFIER --to ./$APP_ORDER_DIR
if [ -e $APP_ORDER ] ;then
cp -f $APP_ORDER ./Resource/app.order
fi
rm -r $APP_ORDER_DIR


【补充xcode13】检查缺页反常的办法

挑选真机、项目、点击发动,当第一个页面显示出来后,点击中止。

使用二进制重排 & Clang插桩技术对iOS冷启动做优化太爽了

青山不改,绿水常流。谢谢我们!