iOS APP启动优化

459 阅读10分钟

题外

APP编译过程

20200119104715.jpeg APP加载知识 其他

前言

App的启动是给用户的第一印象,启动越慢,用户流失的概率就越高,所以App的启动时间是体现其性能优劣的一个重要指标,很多大厂应用甚至会做到"毫秒必究".

冷启动和热启动

标题说明
冷启动App 启动时,应用进程不在系统中(初次打开或程序被杀死),需要系统分配新的进程来启动应用。
热启动App 退回后台后,对应的进程还在系统中,启动则将应用返回前台展示。

启动时间的组成

启动时间的划分可以把main()函数作为关键点分割成两块

  • t1阶段,main()之前的处理所需时间,称为pre-main
  • t2阶段,main()及main()之后处理所需时间

1723a78c376791a7.jpg

t1阶段:pre-main

db061a267f6dc2b14ff7f9120020a5c261253.png

t2阶段

t2阶段耗时的主要是业务代码 推荐 BLStopwatch,这个工具可以打点统计业务耗时

本篇文章主要针对冷启动方式进行优化

pre-main 时间测量

1. XCode环境设置打印

Xcode 中提供了测量 pre-main 的时间 Edit scheme -> Run -> Auguments 添加环境变量 DYLD_PRINT_STATISTICS,value设为YES。

42695-1723af4ff4348acd.png.jpeg

2. 使用 Instruments 的 App Launch 工具

  • App Life Cycle,可以看到App每个阶段整体的耗时

  • Time profile,可以看到App各个线程/符号(函数)的耗时

启动优化的整体思路

阶段工作优化
Load dylibsDyld从主执行文件的header获取到需要加载的所依赖动态库列表,然后它需要找到每个 dylib,而应用所依赖的 dylib 文件可能会再依赖其他 dylib,所以所需要加载的是动态库列表一个递归依赖的集合1.尽量不使用内嵌(embedded)的dylib,加载内嵌dylib性能开销较大;2.合并已有的dylib和使用静态库(static archives),减少dylib的使用个数;3.懒加载dylib,但是要注意dlopen()可能造成一些问题,且实际上懒加载做的工作会更多
Rebase和Bind1. Rebase在Image内部调整指针的指向。在过去,会把动态库加载到指定地址,所有指针和数据对于代码都是对的,而现在地址空间布局是随机化,所以需要在原来的地址根据随机的偏移量做一下修正。2. Bind是把指针正确地指向Image外部的内容。这些指向外部的指针被符号(symbol)名称绑定,dyld需要去符号表里查找,找到symbol对应的实现1.减少ObjC类(class)、方法(selector)、分类(category)的数量;2.减少C++虚函数的的数量(创建虚函数表有开销);3.使用Swift structs(内部做了优化,符号数量更少)
Objc setup1.对所有声明的OC类,将其注册到一个全局表中 (class registration);2.把category的定义插入方法列表 (category registration);3.保证每一个selector唯一 (selector uniquing)减少 Objective-C Class、Selector、Category 的数量,可以合并或者删减一些OC类
Initializers1.Objc的+load()函数;2.C++的构造函数属性函数;3.非基本类型的C++静态全局变量的创建(通常是类或结构体)1.少在类的+load方法里做事情,尽量把这些事情推迟到+initiailize;2.减少构造器函数个数,在构造器函数里少做些事情;3.减少C++静态全局变量的个数

本文主要从二进制重排,动态库转静态库,load迁移几个方面介绍

二进制重排

物理内存和虚拟内存

内存管理

内存是分页管理的,映射表不是以字节为单位,是以页为单位。

  • Linux以4K为一页
  • macOS以4K为一页
  • iOS以16K一页 终端输入pageSize

截屏2021-04-13 下午2.20.05.png

内存浪费

早期的计算机不断启动应用,到达一定数量以后会报错,应用无法正常运行,必须先关闭前面的部分应用才能继续开启。 这是因为早期计算机没有虚拟地址,一旦加载都会全部加载到内存中 。一旦物理内存不够了,那么应用就无法继续开启。 应用在内存中的排序都是顺序排列的,这样进程只需要把自己的地址尾部往后偏移一点就能访问到别的进程中的内存地址,相当不安全。

1723b443939cb86a.jpg

虚拟内存

用户使用时并不会使用到全部内存,如果App一启动就全部加载到内存中会浪费很多内存空间。 虚拟内存技术的出现就是为了解决这个内存浪费问题。 App启动后会认为自己已经获取到整个App运行所需的内存空间,但实际上并没有在物理内存上为他申请那么大的空间,只是生成了一张虚拟内存和物理内存关联的表。

地址翻译

当App需要使用某一块虚拟内存的地址时,会通过这张表查询该虚拟地址是否已经在物理内存中申请了空间。

  • 如果已经申请了则通过表的记录访问物理内存地址,
  • 如果没有申请则申请一块物理内存空间并记录在表中(Page Fault)。

这个通过进程映射表映射到不同的物理内存空间的操作叫地址翻译,这个过程需要CPU和操作系统配合。

o_200428113337WX20200428-193316.png

Page Fault

当数据未在物理内存会进行下列操作 o_200428114049WX20200428-194034.png

  • 系统阻塞该进程

  • 将磁盘中对应Page的数据加载到内存

  • 把虚拟内存指向物理内存

上述行为就就是Page Fault

灵活内存管理

虽然解决了浪费问题,但是万一物理内存空间全都被申请了呢?还是有可能产生内存不足的情况的,为保证当前App的正常使用,数据加载遵循以下原则:

  • 如果有空闲内存空间就放空的内存空间中
  • 如果没有就覆盖其他进程的数据
  • 具体覆盖由操作系统处理

解决安全问题

空间问题已经解决了,但是安全问题是怎么解决的呢?

在dylib的加载过程中系统为了安全考虑引入了ASLR(Address Space Layout Randomization)技术和代码签名。

ASLR技术:镜像Image、可执行文件、dylib、bundle在加载的时候会在其指向的地址(preferred_address)前面添加一个随机数偏差(slide),防止应用内部地址被定位。

为什么进行二进制重排

虚拟内存技术会产生缺页中断(Page Fault),这个过程是个耗时操作。 每页耗时也有很大差距,1微秒到0.8毫秒不等。

使用过程中对这点耗时感觉不明显,但是启动时加载大量数据,如果产生大量缺页中断(Page Fault),时间叠加后用户会有明显感知。

如果我们把所有启动时候的代码都放在一页或者两页,这样就很大程度减少了启动时的缺页中断(Page Fault)从而优化启动速度,这就是二进制重排。

二进制重排概述

二进制重排就是为了减少启动时的缺页异常Page Fault从而减少启动时间

原理

编译器在生成二进制代码的时候,默认按照链接的Object file(.0)顺序写文件,按照Object File内部的函数顺序写函数。

静态库文件.a就是一组.o文件的ar包,可以用nm命令查看.a包含的所有.o

如下图:假设我们只有两个Page,Page1和Page2,其中绿色的method1和method3在应用启动时候需要调用,为了执行对应的代码,系统必须进行两个Page Fault。 但如果我们把method1和method3排布到一起,那么只需要一个Page Fault即可,这就是二进制文件重排的核心原理。

o_200429034221WX20200429-114139.png 但如果我们把method1和method3排布到一起,那么只需要一个Page Fault即可。

o_200429034227WX20200429-114209.png

如何查看APP Page Fault

  1. 首先打开需要分析的项目,然后command+i打开instruments调试工具, 在打开System Trace

o_200508120547WX20200508-200340.png 2. 在点击运行,注意需要在APP启动看到首页后点击停止

o_200508121241WX20200508-201145.png 3. 运行结束后,即可看到整个分析图,在搜索框中输入main thread, 然后再到下面选择Main Thread --> Virtual Memory(虚拟内存)

o_200508115600WX20200508-195505.png

二进制重排方式

要真正的实现二进制重排,我们需要拿到启动的所有方法、函数等符号,并保存其顺序,然后写入order文件,实现二进制重排

有以下几个获取符号的方式比较常见:

  • fishHook
  • Clang插桩

fishHook

fishHook github.com/facebook/fi… 是Facebook开源的一个可以hook系统函数的一个工具, 我们可以hook到系统的objc_msgSend的方式,收集函数符号。但这种实现方式initialize、block以及直接调用方法hook不到

Clang插桩

OC方法、函数、block都能hook到.实际上是在编译期就在每一个函数内部二进制数据添加hook代码,来实现全局方法的hook效果

说白了我们要跟踪到 每个方法的执行,从而获取到启动时 方法执行的顺序,然后再按照这个顺序去编写order file

来看看 clang是怎么做的

代码覆盖率检测工具(SanitizerCoverage)

LLVM 具有内置的简单代码覆盖率检测工具(SanitizerCoverage

  • 它可以在函数,块、边缘级别插入用户定义函数并提供回调
  • 它可以实现了简单的可视化覆盖率报告
通过看守者跟踪 (Tracing PCs with guards)

1406-1725449d0fc09c36.jpg

具体实现
  1. 在项目Build Settings-> Other C Flags中添加-fsanitize-coverage=trace-pc-guard配置

o_200605082116WX20200605-161950.png 2. 把下面两个方法写在一个类中

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) {
  if (!*guard) return;
  void *PC = __builtin_return_address(0);
  char PcDescr[1024];
  printf("guard: %p %x PC %s\n", guard, *guard, PcDescr);
}

执行代码看一看

53013-17254dcd5bba03c8.png.jpeg

看不懂,咱们打个断点看看

startstop 里面存的是一堆序号

截屏2021-04-12 下午5.25.26.png 这会不会是函数的序号呢?给他安排个函数试试看,果然添加个函数后08变为09

__sanitizer_cov_trace_pc_guard

上述 guard_init 方法里面可以获取到所有方法的数量,那么肯定也有办法获取方法具体的相关信息。重点就是接下来要分析的__sanitizer_cov_trace_pc_guard。 添加点击方法,调用一下刚刚添加的方法

截屏2021-04-12 下午6.44.39.png 点击调用test方法,断点看一下

截屏2021-04-12 下午6.45.43.png 可以看到在调用方法的时候插入了__sanitizer_cov_trace_pc_guard方法。

可能还有人看不懂,咱们使用hopper 来看下生成的 mach-o 二进制文件

截屏2021-04-12 下午8.07.18.png 上图可以看出,是在每个函数内部调用了hook函数

也就是说 , 我们现在可以在 __sanitizer_cov_trace_pc_guard 这个函数中 , 通过 __builtin_return_address 数拿到原函数调用 __sanitizer_cov_trace_pc_guard 这句汇编代码的指令的地址

根据内存地址获取函数名称

拿到了函数内部一行代码的地址 , 如何获取函数名称呢 ? 在 dlfcn.h 中有一个方法如下 :

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 *);

紧接着我们来实验一下 , 先导入头文件#import <dlfcn.h> , 然后修改代码如下 :

void __sanitizer_cov_trace_pc_guard(uint32_t *guard) {
    if (!*guard) return;  // Duplicate the guard check.
    
    void *PC = __builtin_return_address(0);
    Dl_info info;
    dladdr(PC, &info);
    
    printf("fname=%s \nfbase=%p \nsname=%s\nsaddr=%p \n",info.dli_fname,info.dli_fbase,info.dli_sname,info.dli_saddr);
    
    char PcDescr[1024];
    printf("guard: %p %x PC %s\n", guard, *guard, PcDescr);
}

38752-171873d06b5bfd62.png.jpeg

到这里我们可以获取到函数的信息了,下面简单说一下swift的配置

Block 、 C函数
  • 添加一个 block
  • 添加一个 c函数

同样可以hook,这里不做实践了

Swift 混编处理

Target -> Build Setting -> Custom Complier Flags -> Other Swift Flags 添加

  • -sanitize-coverage=func
  • -sanitize=undefined

二进制重排完整实践

  1. 配置clang插桩

这里提出点错误: 配置为-fsanitize-coverage=func,trace-pc-guard

-fsanitize-coverage=trace-pc-guard和-fsanitize-coverage=func,trace-pc-guard区别在于前者会统计多次,后者统计一次

o_200605082116WX20200605-161950.png

  1. 获取符号列表 懒人版直接定义一个类收集启动符号列表时需要的符号方法,生成本地文件binary.order

  2. 配置binary.file

截屏2021-04-13 上午11.40.08.png

LinkMap

Linkmap是iOS编译过程的中间产物,记录了二进制文件的布局,需要在Xcode的Build Settings里开启Write Link Map File:

截屏2021-04-12 下午4.40.54.png

之后点击run就会生成一个Linkmap文件, 这个文件里面就有链接的符号顺序表:

截屏2021-04-12 下午4.43.56.png

这个文件分为四个部分:

  1. Path
  • Path是生成.o目标文件的路径
  • Arch是架构类型
  • Object files列举了可执行文件里所有的obj以及tbd。每一行首的数字代表对文件的编号。 2.Section(Mach-O信息) Sections 记录Mach-O每个Segment/section的地址范围

3.Symbols(符号信息)

  • Address 表示文件中方法的地址。
  • Size 表示方法所占内存的大小。
  • File 表示所在的文件编号,与Object files部分的中括号的数字对应
  • Name 表示方法名。
  1. Dead Stripped Symbols 表示链接器认为无用的符号,链接的时候不会计入

o_200514132246WX20200514-212233.png