iOS进阶-启动优化

1,547 阅读12分钟

在聊启动优化前,我们先得明确一个概念:启动分为冷启动(app第一次启动)和热启动,而优化的一般是冷启动。冷启动部分一般会以main函数作为结点分为两部分:main函数之前main函数之后

main函数之前 -- pre-main

想要优化main函数之前耗时,那得知道main函数之前都干了什么。我们通过xcode工程添加环境变量DYLD_PRINT_STATISTICS去打印出来;

在重新运行项目后,你可以在控制台看到耗时打印;

Total pre-main time: 1.1 seconds (100.0%)
         dylib loading time:  48.16 milliseconds (4.1%)
        rebase/binding time:  37.59 milliseconds (3.2%)
            ObjC setup time: 430.91 milliseconds (36.7%)
           initializer time: 656.02 milliseconds (55.9%)
           slowest intializers ://下面几个是最慢耗时库的list
             libSystem.B.dylib :   9.07 milliseconds (0.7%)
          libglInterpose.dylib : 206.17 milliseconds (17.5%)
                  demoApp : 768.08 milliseconds (65.4%)
  • Total pre-main time:pre-main总的时间。

  • dylib loading time:动态库加载时间。能使用系统提供的动态库尽量使用过系统的;如果是自己开发的动态库,尽量不要超过6个,如果超过6个,考虑去合并多余的动态库

  • rebase/binding time:rebase(修正偏移指针=ASLR + 偏移值)、binding(符号绑定),简单说就是将函数、常量等的最终地址确定下来,便于查找调用的时间;

  • ObjC setup time:OC类的注册耗时

  • initializer time:load函数耗时

  • libSystem.B.dylib:系统库的耗时

  • libglInterpose.dylib:调试库(也是系统库)的耗时

  • demoApp:主程序的耗时

以上哪些部分可以优化

  • dylib loading time:动态库加载时间。能使用系统提供的动态库尽量使用过系统的;如果是自己开发的动态库,尽量不要超过6个,如果超过6个,考虑去合并多余的动态库
  • rebase/binding time和ObjC setup time:这两个其实能优化的很少,即减少类的数量,而且效果不好(减少20000个类才只能减少800ms),当然也可以优化比如删除不再使用的类、减少重复代码...注意如果是Swift,会快很多,这个是语言级别的优化了,所以你也可以将一些oc类用swift实现,具体能减少多少自己去测试吧
  • initializer time:既然是load()方法的耗时,可以考虑将load()方法里的耗时操作放到initialize()方法中,做到加载,当然不能破坏原来的功能(比如方法交换什么的放到initialize()方法中就会出问题)

main函数之后

main函数之后的耗时一般是main函数进来到第一个界面显示的时间(viewDidAppear:), 所以可以用打点计时的方式去记录,这里推荐一个三方的计时器: time = main ---》第一个界面 打点计时器:BLStopwatch

BLStopwatch使用举例

我们可以在我们需要计算耗时的地方加上BLStopwatch 1、didFinishLaunchingWithOptions耗时

2.viewDidLoad耗时
3.viewDidAppear耗时
4.弹框显示打点结果
可以看到每段耗时都被记录了,所以我们可以针对耗时部分去做优化

如何优化

根据自己工程的业务,main函数之后可以在下面几个阶段去做优化

  • 1.懒加载:把暂时不需要的加载的放到后面去
  • 2.发挥CPU的价值,尽量用多线程进行初始化
  • 3.启动界面避免使用storyboard、xib
  • 4.release版本移除打印

二进制重排优化

虚拟内存和物理内存

如下图可知,每个进程访问内存时并不是直接访问的,每个进程会有一分虚拟内存,需要通过中间的页表(映射表),映射后们才能取到真实的物理地址,所以进程中的虚拟内存地址是连续的,但是映射后真实的物理地址并不一定是连续的。

内存分页管理

每个进程都有若干虚拟的页表(上图的映射表),当我们在使用进程时,系统会对相对的部分的页表进行映射访问,但是当前物理内存没有对应的部分时,系统就会阻塞当前进程(这个过程我们称为缺页中断),并去创建的页表对应的内存。即上图进程1,P2页在物理内存中没有时,就会在物理内存中创建;如果物理内存有空的内存区就在空的内存区创建,没有空的内存区,系统就会找一页去覆盖某个不活跃的页。

ASLR

虚拟内存确实能解决效率的问题,但是也会有安全问题,因为虚拟内存时确定的,所以很容易被黑客找到;因此出现了在每次加载虚拟内存前加上一个随机的偏移值,使虚拟内存地址变的不确定:ASLR技术(地址空间随机化,具体自己百度)

二进制重排原理

我们在启动的时候,需要加载大量的代码,而这些代码的顺序是根据文件的顺序生成的。比如下图,app启动需要用到P1,P3,P5部分的页表,但是中间隔了P2、P4这个时候我们可以考虑将提到前面,这样缺页中断(page fault)就会少,耗时也就少了;这种将启动代码放在二进制文件前面的做法就是二进制重排

System Trace

日常开发中性能分析是用最多的工具无疑是Time Profiler,但Time Profiler是基于采样的,并且只能统计线程实际在运行的时间,而发生Page Fault的时候线程是被blocked,所以我们需要用一个不常用但功能却很强大的工具:System Trace。(Xocde - Instruments - System Trace)

连上自己的设备点击红点运行程序,这个工具就会进行分析,启动分析一般是记录到第一个页面显示。我用自己的demo分析内容如下,搜索main thread,选择Virtual Memory。图中File Backed Page In次数就是缺页中断Page Fault次数,并且双击还能按时序看到引起Page Fault的堆栈,可以看到demo工程有3078次缺页中断,耗时821.13ms。
经过测试:

  • 如果是程序第一次启动,这个缺页中断数会比热启动大
  • 将app退到后台,双击home键,上滑杀死app,重新启动,这个缺页中断数值会小很多,可以认为这种不算冷启动。说明系统会做一些优化。
  • 双击home键,上滑杀死app,之后打开其他几个app,过个10min后重新启动app,这个缺页中断数又会变得很大

Linkmap

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

开启后,run一次demo,可以找到XXX.linkMap-normal-arm64.txt文件product - show in finder - intermediates.noindex - XXX build - bebug - XXX.build - XXX.linkMap-normal-arm64.txt

下面是XXX.linkMap-normal-arm64.txt文件的大致内容

# Path: /Users/Library/Developer/Xcode/DerivedData/Demo-aomzumcaofovkzbrotetwdamhtrc/Build/Products/Debug-iphoneos/Demo.app/Demo
# Arch: arm64
# Object files:
[  0] linker synthesized
[  1] /Users/Library/Developer/Xcode/DerivedData/Demo-aomzumcaofovkzbrotetwdamhtrc/Build/Intermediates.noindex/Demo.build/Debug-iphoneos/Demo.build/Objects-normal/arm64/AppDelegate.o
[  2] /Users/Library/Developer/Xcode/DerivedData/Demo-aomzumcaofovkzbrotetwdamhtrc/Build/Intermediates.noindex/Demo.build/Debug-iphoneos/Demo.build/Objects-normal/arm64/ViewController.o
[  3] /Users/Library/Developer/Xcode/DerivedData/Demo-aomzumcaofovkzbrotetwdamhtrc/Build/Intermediates.noindex/Demo.build/Debug-iphoneos/Demo.build/Objects-normal/arm64/main.o
...
# Sections:
# Address	Size    	Segment	Section
0x100005EC0	0x00000664	__TEXT	__text
0x100006524	0x00000090	__TEXT	__stubs
0x1000065B4	0x000000A8	__TEXT	__stub_helper
...
0x100008000	0x00000008	__DATA	__got
0x100008008	0x00000060	__DATA	__la_symbol_ptr
0x100008068	0x00000060	__DATA	__cfstring
...
# Symbols:
# Address	Size    	File  Name
0x100005EC0	0x000000A8	[  3] _main
0x100005F68	0x0000002C	[  1] +[AppDelegate load]
0x100005F94	0x0000002C	[  2] +[ViewController load]
0x100005FC0	0x00000064	[  2] -[ViewController viewDidLoad]
0x100006024	0x0000006C	[  1] -[AppDelegate application:didFinishLaunchingWithOptions:]
0x100006090	0x00000114	[  1] -[AppDelegate application:configurationForConnectingSceneSession:options:]
0x1000061A4	0x00000064	[  1] -[AppDelegate application:didDiscardSceneSessions:]
0x100006208	0x00000014	[  2] -[ViewController test2]
0x10000621C	0x00000014	[  2] -[ViewController test1]
0x100006230	0x00000070	[  2] -[ViewController viewDidAppear:]
0x1000062A0	0x00000088	[  4] -[SceneDelegate scene:willConnectToSession:options:]
0x100006328	0x00000040	[  4] -[SceneDelegate sceneDidDisconnect:]
...

linkmap主要包括三大部分:

  • Object Files 生成二进制用到的link单元的路径和文件编号,其顺序就是工程文件在build phase - compile sources中的顺序
  • Sections 记录Mach-O每个Segment/section的地址范围.__TEXT是代码段,只读;__DATA是数据段,可读可写。
  • Symbols 按顺序记录每个符号的地址范围。1.以地址为区分的 2.file指的是占用多少空间,在方法中写的代码越多,size越大

.order文件

.order文件可以指定符号(符号)的放到虚拟内存页表的前几页。按照这个思路,我们可以将app启动时需要调用的符号(方法)整理到.order文件文件中,这样缺页中断数就会减少,启动也就是更快了。

.order文件配置

  • 在主工程路径下新建一个.order文件,使用vim编辑它
 ~ cd /Users/Demo
 ~ Demo  touch fun.order
 ~ Demo  vim fun.order
  • vim编辑内容
_main
+[AppDelegate load]
+[ViewController load]
-[ViewController viewDidLoad]
-[dljljl dage]//工程中的无效的方法会被丢弃
-[oooo v587]//工程中的无效的方法会被丢弃
  • 指定.order文件路径- Build Settings - order file。
    这样二进制文件重排就OK了,当然这只是个demo,二进制重排启动优化效果不明显,但是如果是大的工程,那绝对是有很大的进步。

如何找到所有启动时的方法

我们已经知道了如何使用order file进行二进制重排,但是如何找到启动时,所有调用的的方法呢?

fishhook - objc_msgSend方案

抖音团队给出一个方案:使用了绝大部分Objective C的方法在编译后会走objc_msgSend,所以通过fishhook hook这一个C函数即可获得Objective C符号。但是这个会有一些瓶颈:

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

这种重排方案能够覆盖到80%~90%的符号,不能达到100%。

使用Clang SanitizerCoverage 的方案

简单来说 SanitizerCoverage 是 Clang 内置的一个代码覆盖工具。它把一系列以 _sanitizer_cov_trace_pc 为前缀的函数调用插入到用户定义的函数里,借此实现了全局 AOP 的大杀器。其覆盖之广,包含 Swift/Objective-C/C/C++ 等语言,Method/Function/Block 全支持。

  • 如何开启SanitizerCoverage 在 build settings 里的 “Other C Flags” 中添加 -fsanitize-coverage=func,trace-pc-guard。如果含有 Swift 代码的话,还需要在 “Other Swift Flags” 中加入 -sanitize-coverage=func 和 -sanitize=undefined。所有链接到 App 中的二进制都需要开启 SanitizerCoverage,这样才能完全覆盖到所有调用。
    示例代码,我这里以第一个页面的viewDidAppear为结束
#import <dlfcn.h>
#import <libkern/OSAtomic.h>

- (void)viewDidAppear:(BOOL)animated {
    [super viewDidAppear:animated];
    [self record];
}

- (void)record {
    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);
        //判断是否是oc方法
        BOOL  isObjc = [name hasPrefix:@"+["] || [name hasPrefix:@"-["];
        //是oc方法就直接使用,不是还要加上一个_
        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];
        }
    }
    //干掉自己![self record];
    [funcs removeObject:[NSString stringWithFormat:@"%s",__FUNCTION__]];//
    //将数组变成字符串
    NSString * funcStr = [funcs  componentsJoinedByString:@"\n"];
    //打印,到这一步你就可以直接复制控制台上的打印到.order文件中了
    NSLog(@"&&&&&&&&&&&&&&&&&&\n%@\n&&&&&&&&&&&&&&&&&&",funcStr);
    //下面就是更加自动化:在沙盒目录直接生成一个fun.order文件
    NSString * filePath = [NSTemporaryDirectory() stringByAppendingPathComponent:@"fun.order"];
    NSData * fileContents = [funcStr dataUsingEncoding:NSUTF8StringEncoding];
    [[NSFileManager defaultManager] createFileAtPath:filePath contents:fileContents attributes:nil];
}

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) {
//    if (!*guard) return;  // load 方法的guard是0 所以这里注销掉
    /*  精确定位 哪里开始 到哪里结束!  在这里面做判断写条件!*/
    
    void *PC = __builtin_return_address(0);
    //使用链表形式储存
    SYNode *node = malloc(sizeof(SYNode));
    *node = (SYNode){PC,NULL};
    //原子队列 进入
    OSAtomicEnqueue(&symbolList, node, offsetof(SYNode, next));
    
//    printf("fname:%s \nfbase:%p \nsname:%s \nsaddr:%p\n",
//           info.dli_fname,
//           info.dli_fbase,
//           info.dli_sname,
//           info.dli_saddr);
}

@end

下面是我自己项目的控制台部分打印,到这里就可以直接复制打印到.order文件中了,当然也可以更加自动化:在沙盒目录直接生成一个fun.order文件,这部分功能我已经在实例代码中展示过。

+[_AFURLSessionTaskSwizzling load]
+[_AFURLSessionTaskSwizzling swizzleResumeAndSuspendMethodForClass:]
_af_addMethod
+[IQKeyboardManager load]
+[UIViewController(FDFullscreenPopGesturePrivate) load]
___55+[UIViewController(FDFullscreenPopGesturePrivate) load]_block_invoke
+[UINavigationController(FDFullscreenPopGesture) load]
___54+[UINavigationController(FDFullscreenPopGesture) load]_block_invoke
+[UITableView(MJRefresh) load]
+[UIScrollView(MJExtension) initialize]
___39+[UIScrollView(MJExtension) initialize]_block_invoke
。。。
。。。
。。。

写在最后

运用上述的方法我自己的工程启速度大概升了17%。其实文章的重点是想说二进制重排的,因为这部分目前网上说的不清不楚。apple早就用了这个技术,在objc源码中就包含libobjc.order文件。想做好启动优化是需要大量的底层基础的比如Clang、Mach-o、ASLR...,一遍下来,你会受益匪浅。如果有更好的优化方法,烦请在评论中给出。如果文章对您有帮助,烦请点个赞,小弟在此谢过了!

参考1. 抖音研发实践:基于二进制文件重排的解决方案 APP启动速度提升超15% 2.iOSApp 二进制文件重排