日志9. iOS性能优化 - 启动优化

717 阅读13分钟

APP的启动可以分为2种

  1. 冷启动(Cold Launch):从零开始启动APP;新安装,或者 重启手机

  2. 热启动(Warm Launch):APP已经在内存中,在后台存活着,再次点击图标启动APP

APP启动时间的优化,主要是针对冷启动进行优化 

    分析耗时

  • 通过添加环境变量可以打印出APP的启动时间分析(Edit scheme -> Run -> Arguments) DYLD_PRINT_STATISTICS设置为1;
  • 如果需要更详细的信息,那就将DYLD_PRINT_STATISTICS_DETAILS设置为1。

APP的冷启动可以概括为3大阶段

  1. dyld(dynamic link editor):
  • Apple的动态链接器,可以用来装载Mach-O文件(可执行文件、动态库等)
  1. runtime;

  2. main。

1. dyld 阶段:

  1. 装载APP的可执行(Mach-o)文件,同时会递归加载所有依赖的动态库;
  2. 当dyld把可执行文件、动态库都装载完毕后,会通知Runtime进行下一步的处理。

2. runtime 阶段:

  1. 调用map_images进行可执行文件内容的解析和处理:

    _dyld_objc_notify_register(&map_images, load_images, unmap_image); 复制代码

  2. load_images中调用call_load_methods,调用所有Class和Category的+load方法;

    // Call +load methods (without runtimeLock - re-entrant) call_load_methods(); 复制代码

  3. 进行各种objc结构的初始化(注册Objc类 、初始化类对象等等)

  4. 调用C++静态初始化器和__attribute__((constructor))修饰的函数

  • 到此为止,可执行文件和动态库中所有的符号(Class,Protocol,Selector,IMP,…)都已经按格式成功加载到内存中,被runtime 所管理。

3. main函数启动阶段

  1. 所有初始化工作结束后,dyld就会调用main函数;
  • 接下来就是UIApplicationMain函数,AppDelegate的application:didFinishLaunchingWithOptions:方法

冷启动优化:

1. dyld阶段

  1. 减少动态库、合并一些动态库(定期清理不必要的动态库);

  2. 减少Objc类、分类的数量、减少Selector数量(定期清理不必要的类、分类);

  3. 减少C++虚函数数量;(虚函数的存在,会生成一张虚表)

  4. Swift尽量使用struct。

2. runtime阶段

  1. 用+initialize方法和dispatch_once取代所有的__attribute__((constructor))、C++静态构造器、ObjC的+load

    • (void)initialize { static dispatch_once_t onceToken; dispatch_once(&onceToken, ^{

      }); }

3. main

  1. 在不影响用户体验的前提下,尽可能将一些操作延迟,不要全部都放在applicationDidFinishLaunching方法中;

  2. 按需加载。

本文从优化原理出发,介绍了我是如何通过Clang插桩找到启动所需符号,然后修改编译参数完成二进制文件的重新排布提升应用的启动速度的.

一、基本概念(知识储备)

①. 虚拟内存 & 物理内存

早期的数据访问是直接通过物理地址访问的,以这种方式访问会存在以下两个问题:

  1. 内存不够用
  2. 内存数据的安全问题

1 内存不够用的解决方案:虚拟内存

针对问题1,我们在进程和物理内存之间增加一个中间层,这个中间层就是所谓的虚拟内存,主要用于解决当多个进程同时存在时,对物理内存的管理.提高了CPU的利用率,使多个进程可以同时、按需加载.   所以虚拟内存其本质就是一张虚拟地址和物理地址对应关系的映射表

  • 每个进程都有一个独立的虚拟内存,其地址都是从0开始,大小是4G固定的,每个虚拟内存又会划分为一个一个的(页的大小在iOS中是16KB,其他的是4KB),每次加载都是以页为单位加载的,进程间是无法互相访问的,保证了进程间数据的安全性.

  • 一个进程中,只有部分功能是活跃的,所以只需要将进程中活跃的部分放入物理内存,避免物理内存的浪费

  • 当CPU需要访问数据时,首先是访问虚拟内存,然后通过虚拟内存去寻址,即可以理解为在表中找对应的物理地址,然后对相应的物理地址进行访问

  • 如果在访问时,虚拟地址的内容未加载到物理内存,会发生缺页异常(pagefault),将当前进程阻塞掉,此时需要先将数据载入到物理内存,然后再寻址,进行读取.这样就避免了内存浪费

如下图所示,虚拟内存与物理内存间的关系

2 内存数据的安全问题:ASLR技术

在上面解释的虚拟内存中,我们提到了虚拟内存的起始地址与大小都是固定的,这意味着,当我们访问时,其数据的地址也是固定的,这会导致我们的数据非常容易被破解,为了解决这个问题,苹果在iOS4.3开始引入了ASLR技术.

ASLR的概念:(Address Space Layout Randomization ) 地址空间配置随机加载,是一种针对缓冲区溢出的安全保护技术,通过对堆、栈、共享库映射等线性区布局的随机化,通过增加攻击者预测目的地址的难度,防止攻击者直接定位攻击代码位置,达到阻止溢出攻击的目的的一种技术.

其目的是通过利用随机方式配置数据地址空间,使某些敏感数据(例如APP登录注册、支付相关代码)配置到一个恶意程序无法事先获知的地址,令攻击者难以进行攻击.

由于ASLR的存在,导致可执行文件和动态链接库在虚拟内存中的加载地址每次启动都不固定,所以需要在编译时来修复镜像中的资源指针,来指向正确的地址。即正确的内存地址 = ASLR地址 + 偏移值

②. 可执行文件

不同的操作系统,其可执行文件的格式也不同.系统内核将可执行文件读取到内存,然后根据可执行文件的头签名(magic魔数)判断二进制文件的格式

其中PE、ELF、Mach-O这三种可执行文件格式都是COFF(Command file format)格式的变种,COFF的主要贡献是目标文件里面引入了“段”的机制,不同的目标文件可以拥有不同数量和不同类型的“段”

③. 通用二进制文件

因为不同CPU平台支持的指令不同,比如arm64x86,苹果中的通用二进制格式就是将多种架构的Mach-O文件打包在一起,然后系统根据自己的CPU平台,选择合适的Mach-O,所以通用二进制格式也被称为胖二进制格式,如下图所示 

综上所述:

  1. 通用二进制文件是苹果公司提出的一种新的二进制文件的存储结构,可以同时存储多种架构的二进制指令,使CPU在读取该二进制文件时可以自动检测并选用合适的架构,以最理想的方式进行读取
  2. 由于通用二进制文件会同时存储多种架构,所以比单一架构的二进制文件大很多,会占用大量的磁盘空间,但由于系统会自动选择最合适的,不相关的架构代码不会占用内存空间,且执行效率高
  3. 还可以通过指令来进行Mach-O的合并与拆分
    1. 查看当前Mach-O的架构:lipo -info MachO文件

    2. 合并:lipo -create MachO1 MachO2 -output 输出文件路径

    3. 拆分:lipo MachO文件 –thin 架构 –output 输出文件路径

④. Mach-O文件

Mach-O文件是Mach Object文件格式的缩写,它是用于可执行文件、动态库、目标代码的文件格式.作为a.out格式的替代,Mach-O格式提供了更强的扩展性,以及更快的符号表信息访问速度

熟悉Mach-O文件格式,有助于更好的理解苹果底层的运行机制,更好的掌握dyld加载Mach-O的步骤

.1 Mach-O文件

如果想要查看具体的Mach-O文件信息,可以使用MachOView软件查看:

2 Mach-O文件格式

对于OS X 和iOS来说,Mach-O是其可执行文件的格式,主要包括以下几种文件类型

  • Executable:可执行文件

  • Dylib:动态链接库

  • Bundle:无法被链接的动态库,只能在运行时使用dlopen加载

  • Image:指的是Executable、Dylib和Bundle的一种

  • Framework:包含Dylib、资源文件和头文件的集合

以上是Mach-O文件的格式,一个完成的Mach-O文件主要分为三大部分:

  • Header Mach-O头部:主要是Mach-O的cpu架构,文件类型以及加载命令等信息,包含了整个Mach-O文件的关键信息,使得CPU能快速知道Mac-O的基本信息,

  • Load Commands 加载命令:描述了文件中数据的具体组织结构,不同的数据类型使用不同的加载命令表示,要是用于加载指令,其大小和数目在Header中已经被提供

  • Data 数据:数据中的每个段(segment)的数据都保存在这里,段的概念与ELF文件中段的概念类似.每个段都有一个或多个部分,它们放置了具体的数据与代码,主要包含代码,数据,例如符号表,动态符号表等等。
    这个区域存储了具体的只读、可读写代码,例如方法、符号表、字符表、代码数据、连接器所需的数据(重定向、符号绑定等)。主要是存储具体的数据

二、App启动 *

进程如果能直接访问物理内存无疑是很不安全的,所以操作系统在物理内存之上又建立了一层虚拟内存.苹果在这个基础上还有 ASLR(Address Space Layout Randomization) 技术的保护.

iOS系统中虚拟内存到物理内存的映射都是以页为最小单位的.当进程访问一个虚拟内存Page而对应的物理内存却不存在时,就会出现Page Fault缺页中断,然后加载这一页.虽然本身这个处理速度是很快的,但是在一个App的启动过程中可能出现上千(甚至更多)次Page Fault,这个时间积累起来会比较明显了.

iOS系统中一页是16KB.

我们常说的启动是指点击App到第一页显示为止,包含
pre-main
maindidFinishLaunchingWithOptions结束的整个时间.

load

  • load的加载时机是在runtime初始化,在main函数之前
  • 一个类的多个分类的load函数也都会被执行,执行顺序是按照编译顺序;分类的load在原类之后执行,先类再分类
  • load的执行是按照继承链从上往下执行的,先父类再子类
  • load方法只有实现了才会执行,没有实现不会去调用继承链上游的,也不会调用分类中的
    **
    runtime是如何加载load方法的**
  1. 收集load方法的信息,先类再分类,先父类再子类,分别存储到2个结构体数组中
  2. 调用load方法,先调用类的,再调用分类的
  3. 由于系统是收集了所有的load的imp,然后去执行,所以就保证了load方法都执行了,并且是按照先类再分类,类是按照继承链从父到子类的顺序执行的
  • initializeload的本质区别就是一个走的是消息机制,
    一个是直接通过imp函数指针调用;所以才导致load和initialize的差异性

二进制重排  -  pre-main阶段的优化方案,即二进制重排

在虚拟内存部分,当进程访问一个虚拟内存page,而对应的物理内存不存在时,会触发缺页中断(Page Fault),因此阻塞进程.此时就需要先加载数据到物理内存,然后再继续访问.这个对性能是有一定影响的.

基于Page Fault,我们思考,App在冷启动过程中,会有大量的类、分类、三方等需要加载和执行,此时产生的Page Fault所带来的耗时是很大的.

Page Fault

进程如果能直接访问物理内存无疑是很不安全的,所以操作系统在物理内存的上又建立了一层虚拟内存。为了提高效率和方便管理,又对虚拟内存和物理内存又进行分页(Page)。当进程访问一个虚拟内存Page而对应的物理内存却不存在时,会触发一次缺页中断(Page Fault),分配物理内存,有需要的话会从磁盘mmap读人数据。

通过App Store渠道分发的App,Page Fault还会进行签名验证,所以一次Page Fault的耗时比想象的要多:

Page Fault

重排

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

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

默认布局

简化问题:假设我们只有两个page:page1/page2,其中绿色的method1和method3启动时候需要调用,为了执行对应的代码,系统必须进行两个Page Fault。

但如果我们把method1和method3排布到一起,那么只需要一个Page Fault即可,这就是二进制文件重排的核心原理。

我们的经验是优化一个Page Fault,启动速度提升0.6~0.8ms。

核心问题

为了完成重排,有以下几个问题要解决:

  • 重排效果怎么样 - 获取启动阶段的page fault次数

  • 重排成功了没 - 拿到当前二进制的函数布局

  • 如何重排 - 让链接器按照指定顺序生成Mach-O

  • 重排的内容 - 获取启动时候用到的函数

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

抖音有一篇文章抖音研发实践:基于二进制文件重排的解决方案 APP启动速度提升超15%,但是文章中也提到了瓶颈:

基于静态扫描+运行时trace的方案仍然存在少量瓶颈:

  • initialize hook不到
  • 部分block hook不到
  • C++通过寄存器的间接函数调用静态扫描不出来

目前的重排方案能够覆盖到80%~90%的符号,未来我们会尝试编译期插桩等方案来进行100%的符号覆盖,让重排达到最优效果。

同时也给出了解决方案编译期插桩.