iOS开发优化的起步之启动优化

前言

作为开发人员,启动是App给用户的第一印象,对用户体验至关重要。任何开发的APP的业务迭代迅速,如果放任不管,启动速度会一点点劣化。为此iOS客户端团队做了大量优化工作,除了传统的修改业务代码方式,我们还做了些开拓性的探索,首先我们需要考虑的是,应用启动分为2种情况:

冷启动:指 app 被后台杀死后,在这个状态打开 app,这种启动方式叫做冷启动,根据测试结果并非是杀掉进程后直接启动,需要先开启5-10个应用后,然后在启动应用,才会完全冷启动

热启动:指 app 没有被后台杀死,仍然在后台运行,通常我们再次去打开这个 app,这种启动方式叫热启动。

而作为开发人员我们主要要做的启动优化,主要分为2种情况:

  • 2.mian函数之后优化

  • 1.main函数之前优化

1.main函数之后:

main函数之后,其实大部分来源于我们的业务代码,这里提供一个检测代码中花费时间的Demo,可以自行检测添加代码,进行添加

- (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions {
    // Override point for customization after application launch.
    [[BLStopwatch sharedStopwatch] start];
    int a = 0;
    for (int i = 0; i < 10000000; i++) {
        a++;
    }
    [[BLStopwatch sharedStopwatch] splitWithDescription:@"didFinishLaunchingWithOptions"];
    
    

    return YES;
}
- (void)viewDidLoad {
    [super viewDidLoad];
    //刷新时间:
       [[BLStopwatch sharedStopwatch] refreshMedianTime];
       
       int a = 0;
       for (int i = 0; i < 10000000; i++) {
           a++;
       };
       [[BLStopwatch sharedStopwatch] splitWithDescription:@"viewDidLoad"];
    // Do any additional setup after loading the view.
}
-(void)viewDidAppear:(BOOL)animated{
    [super viewDidAppear:animated];
    //刷新时间:
       [[BLStopwatch sharedStopwatch] refreshMedianTime];
       
       int a = 0;
       for (int i = 0; i < 10000000; i++) {
           a++;
       };
       [[BLStopwatch sharedStopwatch] splitWithDescription:@"viewDidAppear"];
    [[BLStopwatch sharedStopwatch] stopAndPresentResultsThenReset];
    
    
}

检测结果:

Main阶段优化:

  1. 能懒加载的尽量使用懒加载

  2. 尽可能发挥CPU的价值,尽量使用多线程进行加载;

  3. 启动界面,尽可能的避免使用storyboard,或Xib(这写都是需要转换为代码的,需要耗费时间)

  4. NSUserDefaults实际上是在Library文件夹下会生产一个plist文件,如果文件太大的话一次能读取到内存中可能很耗时,这个影响需要评估,如果耗时很大的话需要拆分(需考虑老版本覆盖安装兼容问题) 

  5. 每次用NSLog方式打印会隐式的创建一个Calendar,因此需要删减启动时各业务方打的log,或者仅仅针对内测版输出log

 

2.main函数之前

想要查看一下main函数之前我们应用都做了些什么,我们只需要在在 eidt scheme  -->Run  --> Arguments -->Environment Variables 添加环境变量 DYLD_PRINT_STATISTICS;

添加完成环境变量后,我们可以运行应用,可以监控main之前的运行时间:

这个是应用启动后时间,由于模拟器之前有安装过应用,所以这次属于热启动,我们将进程杀死后,再进行检测

我们对其中的名词进行解释一下:

  • dylib load time          : 动态库加载时间,尽量使用系统库,外部库尽量不要超过6个(苹果建议)

  • rebase/binding          :就是 macho +ASLR 进行内部地址偏移+ 外部符号绑定(例如NSLog 为系统Founditon框架中的方法就需要外部符号绑定)

  • Objc setuptime          :OC 类注册时间 ,优化方案:减少oc类,2万个类会增加800mm这里优化性较小

  • initializer time            : 加载包含load方法的类时间(非懒加载类)   尽量减少load 可修改为initalizer中+ 单利方式进行加

  • slowest intializers     :最慢的

  • libsystem.B.dylb       : 系统库

  • libglInterpose.dylb   :  调试相关,release会去掉的

  • marsbridgentwork    : 系统库

  • zhitongti                     : 主进程

由此我们可以看出优化方案:

  1. 这里在main函数之前我们能做的东西就比较少了,首先我们尽量减少动态库(能合并的,可以尽量合并,不要超过6个),

  2. 尽量减少load 可修改为initalizer中+ 单利方式进行加载

  3. swift比oc效率高,可以尝试用swift替换OC,老工程就不建议了

  4. 减少oc类,2万个类会增加800mm,这里的优化时间比较小,但是可以考虑将弃用类(每个工程总有一些迭代,但是已经弃用未删除的代码)找工具删除

  5. 这里还有一个很牛逼的优化二进制重排,在链接阶段(编译期间)有这么个优化

2.1二进制重排

二进制重排,首先需要了解几个概念:虚拟内存,物理内存;

  • 物理内存:真实的硬件设备(内存条)

  • 虚拟内存:利用磁盘空间虚拟出的一块逻辑内存,用作虚拟内存的磁盘空间被称为交换空间(Swap Space)。(为了满足物理内存的不足而提出的策略)

在很久以前,还没有虚拟内存概念的时候,程序寻址用的都是物理地址,取决于CPU的地址线条数,32位平台的话 2^32也就是4G 。且每次开启一个进程都给4G的物理内存。很显然你内存小一点,这很快就分配完了,于是没有得到分配资源的进程就只能等待,而且还有一个问题,就是我当前的应用如果使用物理内存,直接给当前地址+偏移量,有可能会访问到别的进程,这样也导致进程之间的不安全;

而后引入了虚拟内存极大解决了这方面的问题,一个进程运行时都会得到4G的虚拟内存。这个虚拟内存你可以认为,每个进程都认为自己拥有4G的空间,这只是每个进程认为的,但是实际上,在虚拟内存对应的物理内存上,可能只对应的一点点的物理内存,实际用了多少内存,就会对应多少物理内存,引入虚拟内存主要就是解决物理内存分配,以及进程之间的安全问题;

2.1.1虚拟内存工作原理:

进程得到的这4G虚拟内存是一个连续的地址空间(这也只是进程认为),而实际上,它通常是被分隔成多个物理内存碎片,还有一部分存储在外部磁盘存储器上,在需要时进行数据交换。

进程开始要访问一个地址,它可能会经历下面的过程:

  1. 每次我要访问地址空间上的某一个地址,都需要把地址翻译为实际物理内存地址
  2. 所有进程共享这整一块物理内存,每个进程只把自己目前需要的虚拟地址空间映射到物理内存上
  3. 进程需要知道哪些地址空间上的数据在物理内存上,哪些不在(可能这部分存储在磁盘上),还有在物理内存上的哪里,这就需要通过页表来记录
  4. 页表的每一个表项分两部分,第一部分记录此页是否在物理内存上,第二部分记录物理内存页的地址(如果在的话)
  5. 当进程访问某个虚拟地址的时候,就会先去看页表,如果发现对应的数据不在物理内存上,就会发生缺页异常
  6. 缺页异常的处理过程,操作系统立即阻塞该进程,并将硬盘里对应的页换入内存,然后使该进程就绪,如果内存已经满了,没有空地方了,那就找一个页覆盖,至于具体覆盖的哪个页,就需要看操作系统的页面置换算法是怎么设计的了。

关于虚拟内存与物理内存关系可以用下图进行描述

页表的工作原理如下图所示:

  1. 我们的cpu想访问虚拟地址所在的虚拟页(VP3),根据页表,找出页表中第三条的值.判断有效位。 如果有效位为1,DRMA缓存命中,根据物理页号,找到物理页当中的内容,返回。
  2. 若有效位为0,参数缺页(Page fault)异常,调用内核缺页异常处理程序。内核通过页面置换算法选择一个页面作为被覆盖的页面,将该页的内容刷新到磁盘空间当中。然后把VP3映射的磁盘文件缓存到该物理页上面。然后页表中第三条,有效位变成1,第二部分存储上了可以对应物理内存页的地址的内容。
  3. 缺页异常处理完毕后,返回中断前的指令,重新执行,此时缓存命中,执行1。
  4. 将找到的内容映射到告诉缓存当中,CPU从告诉缓存中获取该值,结束。

由此我们可看到当有缺页异常(Page fault )时,就会稍微耗时,这里我们就可以看到优化点,我们可以可以看一下符号重排一下就可以了;

xcode缺页异常(Page fault )检测

在我们xcode中就有检查工具,在xcode中的instruments中有工具System Trace 中就可以进行调试

启动这个工具之后我们到自己启动也后,停止,然后进行分析如下操作:

这里我们可以看到File backed Page In 就是一共有多少页,这里可以看到2583页;

我们来进行一次热启动,直接重启一下,看看情况:

我们可以看到热启动后,一共就只有116页,这我们就可以看出,实际上热启动和冷启动差距还是很大的,这样我们就可以看出系统在启动时需要的分页会很多,特别是冷启动,启动中有大量的缺页中断,这就会导致耗时,我们先看一下怎样监测页码;

3苹果支持二进制排序

首先二进制重排Xcode给我们提供了一种方法,xcode中使用的链接器为LD,ld中有个文件叫做order_file,如果有一个order文件,将符号顺序写入在order文件,Xcode就会将按照order中的顺序进行排列;

我们可以看下苹果官方给我们提供的源码来查看一下:

其中order文件中内容,这个文件内容就是函数,方法的符号;

我们看下官方给的源码中xcode如何配置这个order文件的

我们可以看到不管是在debug($(SRCROOT)/libobjc.order)还是在release($(SDKROOT)/AppleInternal/OrderFiles/libobjc.order)环境苹果都指定了一个order文件

苹果指定了这个文件后,他们编译出来的二进制文件,就会按照这个里面的符号进行排序

3.1详解二进制排序

     这个Demo是详解二进制的

那么我们自己新建个工程来尝试一下二进制重排,新建工程,其中添加一些方法,首先我们要知道ld链接文件的顺序还和下面这里配置有关

这里的顺序的,所以我们需要注意这里的列顺序,例如我当前在ViewController都写个load方法,方法内输出控制器名称

我们可以看到先执行了ViewController 的Load方法,如果修改了链接顺序,就会有改变,这个大家可以自行尝试;

接下来我们看下如何获取到我们项目的符号。Xcode中有设置可以获取到

1.进入build Settings 找到Linking  write Link Map File 配置为yes

2.编译一下工程,我们可以得到编译文件

3.show in forder并找到上级目录Intermediates.noindex

4.进入文件夹,找到对应的txt文件

其中BinaryRearrangement-LinkMap-normal-x86_64.txt这个文件就是我们的包含符号顺序的文件,打开文件进行查看

可以看出这里的连接顺序,和我们刚才的文件顺序是一致的,这里我们需要知道的是,链接文件实际链接的是将.m文件转换后的.o文件,继续查看下面sections:

再往下就是重点了symoblo

symoblo有几个关键词

  • Address 为macho中的实际代码地址:
  • Size:代码占用的内存
  • File:文件的序号
  • Name:函数的方法名称,其中的C语言方法前面会加上下划线

这里就是我们的符号顺序,这里我们可以看出,函数的排序和我们实际的顺序是不一样的,我们对代码进行分析,实际顺序这几个函数应该是:

  1. +[ViewController load]
  2. +[AppDelegate load]
  3. _main
  4.  +[AppDelegate load]

然而我们看到实际文件中,和我们分析的顺序是不一样的,这就是我们要优化的点,也是我们进行二进制重排的原因,那么我们现在尝试重排二进制,

1.首先编辑文件alan.order内容为:

2.放到根目录,并进行xcode配置

3.再次编译,查看符号文件

4.我们可以看到自己写入在order文件中的顺序已经正常加载,未写入的,也会加载进来,并且如果你写了一个没有的方法,苹果也不会加载(这里做了容错)

二进制重排难点

这样我们就看到了二进制重排其实也很简单,其实二进制重排难点主要在于,我们怎么样找到这些顺序的符号,并生成order文件,这里我们可以看下抖音研发实践团队这篇文章;根据抖音团队这篇文章我们来继续查看一下使用Clang插装方式进行,首先看先Clang官网文章

 

根据Clang官网进行配置这里-fsanitize-coverage=trace-pc-guard

这里有几个坑点:

fsanitize-coverage=trace-pc-guard直接这么配置,while循环会一直走添加方法需要修改为:-fsanitize-coverage=func,trace-pc-guard

添加完成后我们bulid 工程,会出现报错

这个错误很容易解决,根据Clang官网加上示例代码就可以了

extern "C" 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.
}

// This callback is inserted by the compiler on every edge in the
// control flow (some optimizations apply).
// Typically, the compiler will emit the code like this:
//    if(*guard)
//      __sanitizer_cov_trace_pc_guard(guard);
// But for large functions it will emit a simple call:
//    __sanitizer_cov_trace_pc_guard(guard);
extern "C" void __sanitizer_cov_trace_pc_guard(uint32_t *guard) {
  if (!*guard) return;  // Duplicate the guard check.
  // If you set *guard to 0 this code will not be called again for this edge.
  // Now you can get the PC and do whatever you want:
  //   store it somewhere or symbolize it and print right away.
  // The values of `*guard` are as you set them in
  // __sanitizer_cov_trace_pc_guard_init and so you can make them consecutive
  // and use them to dereference an array or a bit vector.
  void *PC = __builtin_return_address(0);
  char PcDescr[1024];
  // This function is a part of the sanitizer run-time.
  // To use it, link with AddressSanitizer or other sanitizer.
  __sanitizer_symbolize_pc(PC, "%p %F %L", PcDescr, sizeof(PcDescr));
  printf("guard: %p %x PC %s\n", guard, *guard, PcDescr);
}

还会报错我们将 extern ’C‘ 再次进行编译将打印的注释掉就可以编译正常了

这样就实现了静态插装:

在所有的函数,block 方法内部直接添加一个一行代码调用这个函数__sanitizer_cov_trace_pc_guard,而且是第一个方法就会调用这个方法,那么我们只需要对这个函数进行处理并将调用方法进行组装生成order文件,再加上上一步的操作,就可以实现完全的二进制重排了。

获取并生成order文件Demo

这里再说一个坑点,就是如果有OC工程中和swift混编的,插装需要再添加一个配置文件才可以将swift文件进行hook住,           -sanitize-coverage=func -sanitize=undefined 工程中有swift才能找到这个配置

这里输出一下我的符号顺序, 

我们可以看到有一些是我们可以看到的方法,红色框内部就是swift编译后的符号,也是可以正常被HOOK到。

接下来我们看一下使用二进制重排和为使用二进制重排的符号顺序:

未使用二进制重排

使用二进制重排后的符号顺序:

通过这种方式我们可以达到一定的优化效果;

原创文章 88 获赞 21 访问量 2万+

猜你喜欢

转载自blog.csdn.net/ZhaiAlan/article/details/104923246