启动优化

启动性能检测Main函数

启动的过程一般是指从用户点击app图标开始到AppDelegate 的didFinishLaunching方法执行完成为止,其中启动也分为冷启动热启动

  • 冷启动:当启动应用时,后台没有该应用的进程,这时系统会重新创建一个新的进程分配给该应用,这个启动方式就是冷启动
  • 热启动:当启动应用时,后台已有该应用的进程(例:按home键回到桌面,但是该应用的进程是依然会保留在后台,可进入任务列表查看),所以在已有进程的情况下,这种启动会从已有的进程中来启动应用,这个方式叫热启动。

下面说的启动优化是指冷启动情况下的,这种情况下应用的启动时间一般分为Main函数执行之前之后

  • pre-main阶段即main函数之前,操作系统加载App可执行文件到内存,执行一系列的加载&链接等工作,简单来说,就是dyld加载过程
  • main函数之后即从main函数开始,到AppdelegatedidFinishLaunching方法执行完成为止,主要是构建第一个界面,并完成渲染

系统提供了环境变量,让开发者可以看到pre-main过程中的耗时。

  • 创建空工程WeChatDemo查看pre-main阶段的耗时
  • 查看方式:在Xcode中选择项目 Product --> Scheme --> Edit Scheme --> Run --> Arguments -->Environment Variables --> 点击+添加环境变量 DYLD_PRINT_STATISTICS设为YES
image.png
  • 运行项目,查看耗时信息
image.png
  1. dylib loading time:动态库的载入耗时
    动态库的载入肯定会存在耗时,并且动态库会存在依赖关系。系统动态库存在于共享缓存,但自定义动态库没有这个待遇,所以苹果官方建议不要超过6个自定义动态库,超过可进行多个动态库合并,以此来优化动态库加载的耗时。
    动态库的合并,需要源码才能进行。所以我们只能合并自己开发的动态库,日常使用的三方SDK可能无法合并。
  2. rebase/binding time:重定位符号和符号绑定的耗时(运行时期绑定,编译时期链接)
    rebase:系统采用ASLR技术,保证地址空间随机化。所以在运行时,需要通过rebase进行重定位符号,使用ASLR+偏移地址
    binding:使用外部符号,编译时无法找到函数地址。所以在运行时,dyld加载共享缓存,加载链接动态库之后,进行binding操作,重新绑定外部符号
  3. ObjC setup time:注册OC类的耗时
    注册OC类的过程,读取二进制的data段找到OC的相关信息,然后注册OC类。应用启动时,系统会生成分类的两张表,OC类和分类的注册,会插入到这两张表中,所以会造成一定的时间消耗;
    这部分时间很难优化,除非减少项目中类和分类的定义
    减少类和所属分类load方法的使用,让类以懒加载的方式加载。
  4. initializer time:执行load以及C++构造函数的耗时
    尽可能使用initialize方法代替load方法,或者把一些耗时的操作放入子线程
  5. slowest intializers:列举出几个比较耗时的动态库

这一阶段主要是防止资源浪费(比如OC定义的非常多,自定义动态库非常多),优化建议

  • 减少OC类,因为OC类越多越耗时。比如在一些老旧项目中,可以写一套脚本来监测已经不再使用的OC类进行删除
  • 将不必须在+load方法中做的事情延迟到+initialize中,尽量不要用C++虚函数
  • 如果是swift,尽量使用struct

虚拟地址的概念

早期的程序比较小,在运行时会将整个程序全部加载到内存中。但随着软件的发展,程序越来越大,导致内存越来越紧张。这就是早期系统中,为什么经常出现内存不足的提示。

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

  • 内存不够用
  • 内存数据的安全问题:跨进程访问导致数据不安全
虚拟内存

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

  • 每个进程都有一个独立的虚拟内存,其地址都是从0开始,大小是4G固定的,每个虚拟内存又会划分为一个一个的页(页的大小在iOS中是16K,Mac中是4K),每次加载都是以页为单位加载的,进程间是无法互相访问的,保证了进程间数据的安全性
  • 一个进程中,只有部分功能是活跃的,所以只需要将进程中活跃的部分放入物理内存,避免物理内存的浪费
  • 当CPU需要访问数据时,首先是访问虚拟内存,然后通过虚拟内存去寻址,即可以理解为在表中找对应的物理地址,然后对相应的物理地址进行访问。地址翻译的过程,由CPU上的内存管理单元(MMU)完成。
  • 如果在访问时,虚拟地址的内容未加载到物理内存,会发生缺页异常(pagefault),将当前进程阻塞掉,此时需要先将数据载入到物理内存,然后再寻址进行读取,这样就避免了内存浪费。

使用虚拟内存的优势:

  • 程序以懒加载的方式加载到内存中,按需加载避免内存浪费
  • 程序物理内存完全阻隔开,无法跨进程访问数据更安全

进程通信由系统提供API使用kernel发送信号。但不能直接跨进程访问,保证数据的安全

虚拟内存与物理内存的关系

虚拟内存:内存分页

页表中记录了内存页的状态、虚拟内存和物理内存的对应关系。其中状态分为:未分配(Unallocated)、未缓存(Uncached)和已缓存(Cached)

  • 未分配的内存页,是没有被进程申请使用的,也就是空闲的虚拟内存,不占用虚拟内存磁盘的任何空间。
  • 未缓存的内存页,仅在虚拟内存中,没有被物理内存缓存。
  • 已缓存的内存页,同时存在于虚拟内存和物理内存中。
缺页异常
  • 当程序访问未被缓存的内存页时,就会触发缺页异常(缺页中断)
  • 缺页中断会将当前进程阻塞掉,此时需要先将数据载入到物理内存,然后再寻址进行读取;
  • 部分情况下,被访问的页面已经加载到物理内存中,但页表中并不存在该对应关系,这时只需要在页表中建立虚拟内存物理内存的关系即可;
  • 其他情况下,操作系统需要将磁盘上未被缓存虚拟页加载到物理内存中。
页面置换

物理内存的空间是有限的,当内存中没有空间时,操作系统会从选择合适的物理内存页驱逐回磁盘,为新的内存页让出位置,选择待驱逐页的过程在操作系统中叫做页面置换。也就是覆盖掉不那么活跃的物理内存

例如,同一台设备上,依次打开微信、微博、淘宝、京东、抖音,此时再回到微信,又会看到微信的启动界面。因为系统在内存紧张的时候,会按照活跃度将最不活跃的内存进行覆盖

ASLR技术

程序的代码在不修改的情况下,每次加载到虚拟内存中的地址都是一样的,这种方式并不安全。为了解决地址固定的问题,出现了ASLR技术

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

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

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

iOS/MacOS操作系统实现了ASLR

  • Mac OS X:Apple在Mac OS X Leopard10.5(2007年十月发行)中某些库导入了随机地址偏移,但其实现并没有提供ASLR所定义的完整保护能力。而Mac OS X Lion10.7则对所有的应用程序均提供了ASLR支持。Apple宣称为应用程序改善了这项技术的支持,能让3264位的应用程序避开更多此类攻击。从OS X Mountain Lion10.8开始,核心及核心扩充(kext)与zones在系统启动时也会随机配置;
  • iOS(iPhone、iPod touch、iPad):Apple在iOS4.3内导入了ASLR

PageFault调试&启动优化

缺页中断消耗

当系统访问虚拟内存时,发现数据还未加载到物理内存中,会触发缺页中断(Page Fault),造成进程阻塞。此时系统会先将数据加载到物理内存中,进程才能继续运行。虽然每一页数据加载到内存的速度很快毫秒级别,但在应用冷启动时可能会出现大量的缺页中断,对启动速度带来一定的时间消耗。

PageFault调试
  • 运行测试项目查看应用启动过程中Page Fault所带来的消耗,Xcode菜单中 -> 选择Product --> Profile,打开Instruments
image.png
  • 运行测试项目当第一个界面出来后即可停止,搜索main thread -> 选择Summary: Virtual Memory虚拟内存
image.png

缺页中断564次,耗时196.95ms。一次缺页中断耗时 大概 0.35ms,冷启动时间 231.17ms

启动优化
  • 创建测试项目,查看代码执行顺序
  • 项目Build Settings --> Write Link Map File设置为YES
image.png
  • 编译项目来到工程的Build目录下查找LinkMap文件
image.png
  • LinkMap文件保存了项目在编译链接时的符号顺序方法/函数为单位排列
# Symbols:
# Address   Size        File  Name
0x100001E60 0x00000040  [  5] -[SceneDelegate sceneWillResignActive:]
0x100001EA0 0x00000040  [  5] -[SceneDelegate sceneWillEnterForeground:]
0x100001EE0 0x00000040  [  5] -[SceneDelegate sceneDidEnterBackground:]
0x100001F20 0x00000020  [  5] -[SceneDelegate window]
0x100001F40 0x0000008E  [  4] _main
0x100001FD0 0x00000030  [  2] +[ViewController load]
0x100002000 0x00000030  [  3] +[AppDelegate load]
0x100002030 0x00000039  [  2] -[ViewController viewDidLoad]
0x100002070 0x00000080  [  3] -[AppDelegate application:didFinishLaunchingWithOptions:]
0x1000020F0 0x00000120  [  3] -[AppDelegate application:configurationForConnectingSceneSession:options:]
0x100002210 0x00000070  [  3] -[AppDelegate application:didDiscardSceneSessions:]
0x100002280 0x000000B0  [  5] -[SceneDelegate scene:willConnectToSession:options:]
0x100002330 0x00000040  [  5] -[SceneDelegate sceneDidDisconnect:]
0x100002370 0x00000040  [  5] -[SceneDelegate sceneDidBecomeActive:]
0x1000023B0 0x00000040  [  5] -[SceneDelegate setWindow:]
0x1000023F0 0x00000033  [  5] -[SceneDelegate .cxx_destruct]
0x100002424 0x00000006  [  6] _NSLog
......
  • 文件编译顺序:Xcode中Build Phases -> Compile Sources的文件排列顺序
image.png
  • 文件中方法/函数的符号顺序就是代码的书写顺序
#import "ViewController.h"
@interface ViewController ()

@end

@implementation ViewController

+(void)load {
    NSLog(@"123");
}

- (void)viewDidLoad {
    [super viewDidLoad];
    // Do any additional setup after loading the view.
}
@end

ViewController.m为例load方法在viewDidLoad方法之前,和LinkMap文件中的顺序一致。

按照默认配置,在应用启动时会加载到大量与启动时无关的代码,导致Page Fault的次数增长影响启动时间。如果可以将启动时需要的方法/函数排列在最前面,就能大大降低缺页中断的可能性,从而提升应用的启动速度,这就是二进制重排的核心原理。
以下图为例,方法 1方法 3是启动的时候调用的,为了执行对应的代码,就需要两次Page Fault。假如我们把方法 13排列到一起,那么只需要一次Page Fault,从而提升启动速度。

image.png

二进制重排体验

二进制重排的方案最开始是由抖音的这篇文章抖音研发实践:基于二进制文件重排的解决方案 APP启动速度提升超15%火起来的。

链接器ld有个参数-order_file支持按照符号的方式排列二进制。所以需要在工程中创建.order文件,按固定格式将启动时需要的方法/函数顺序排列,然后在Xcode中使用.order文件即可。通过LinkMap文件中的顺序,查看最终的排序是否符合预期。

  • 在工程根目录创建.order文件
截屏2021-10-18 下午11.58.50.png
  • hk.order文件写入启动时需要的方法/函数
_main
+[ViewController load] 
+[AppDelegate load] 
  • 在Xcode使用.order文件,在Build Setting -> Order File中配置
image.png
  • 编译项目打开LinkMap文件
# Symbols:
# Address   Size        File  Name
0x100001E60 0x0000008E  [  4] _main
0x100001EF0 0x00000030  [  2] +[ViewController load]
0x100001F20 0x00000030  [  3] +[AppDelegate load]
0x100001F50 0x00000039  [  2] -[ViewController viewDidLoad]
0x100001F90 0x00000080  [  3] -[AppDelegate application:didFinishLaunchingWithOptions:]
0x100002010 0x00000120  [  3] -[AppDelegate application:configurationForConnectingSceneSession:options:]
......

最前面三个方法/函数,按照.order文件中的顺序排列

由此可见如果我们将项目中启动时需要调用的所有方法/函数都找到,把它们全部写入到.order文件中,就能大大降低缺页中断的可能性。

二进制重排方案小结:

  • 定位到APP启动时调用的项目中的方法;
  • 生成order文件;
  • 配置二进制重排。

这里的难点是,如何找到启动时项目调用了哪些方法?

  • 1、hook objc_msgSend:我们知道,函数的本质是发送消息,在底层都会来到objc_msgSend,但是由于objc_msgSend的参数是可变的需要通过汇编获取,对开发人员要求较高。而且也只能拿到OCswift中@objc后的方法
  • 2、静态扫描:扫描Mach-O特定段和节里面所存储的符号以及函数数据
  • 3、Clang插桩:即批量hook,可以实现100%符号覆盖,即完全获取swiftOCCblock函数

你可能感兴趣的:(启动优化)