程序员

iOS 启动时间优化

2020-04-17  本文已影响0人  奉灬孝

在 WWDC 2016 和 2017 都有提到启动这块的原理和性能优化思路,可见启动时间,对于开发者和用户们来说是多么的重要,本文就谈谈如何精确的度量 App 的启动时间,启动时间由 main 之前的启动时间和 main 之后的启动时间两部分组成。

main之前启动项.png

图是 Apple 在 WWDC 上展示的 PPT,是对 main 之前启动所做事的一个简单总结。main 之后的启动时间如何考量呢?进入到 main 函数以后,我们的代码都是从 - (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions 函数开始执行的。一般说来,pre-main阶段的定义为APP开始启动到系统调用main函数这一段时间;main阶段则代表从main函数入口到主UI框架的viewDidAppear函数调用的这一段时间。

1. main 阶段的时间

main 阶段的时间就是从 main 函数到第一个界面渲染完成这段时间。在开始之前,我们先来磨练一个我们自己的工具。

生活中,我们计量一段时间一般是用计时器。这里我们要想知道哪些操作,或者说哪些代码是耗时的,我们也需要一个打点计时器。用过 profile 的朋友都知道这个工具很强大,可以使用它来分析出哪些代码是耗时的。但是它不够灵活,我们来看一下我们的这个计时器应该怎么设计。

代码耗时.png

如上图所示,在时间轴上,我们从 start 开始打点计时,然后我们在第一个小红旗那里打了一个点,记录这段代码的耗时,然后又在第二个小红旗那里打了一个点,记录这中间代码的耗时。然后在结束的地方打一个点,然后把所有打点的结果展示出来。同时,我们为每段计时加上标注,用来区分这段时间是执行了什么操作花费的时间。这样一来,我们就能快速精准的知道究竟是谁拖慢了启动。

didFinishLaunchingWithOptions

一般来说,我们放到 didFinishLaunchingWithOptions 执行的代码,有很多初始化操作,如日志,统计,SDK配置等。尽量做到只放必需的,其他的可以延迟到 MainViewController 展示完成 viewDidAppear 以后。

一、 日志、统计等必须在 APP 一启动就最先配置的事件
二、 项目配置、环境配置、用户信息的初始化 、推送、IM等事件
三、 其他 SDK 和配置事件

第一类,必须第一时间启动,仍然把它留在 didFinishLaunchingWithOptions 里启动。

第二类,这些功能在用户进入 APP 主体的之前是必须要加载完的,我把他放到广告页面的 viewDidAppear 启动。

第三类,由于启动时间不是必须的,所以我们可以放在第一个界面的 viewDidAppear 方法里,这里完全不会影响到启动时间。

既然思路有了,我们就开始动手吧!在这里我们需要用到一个工具 —— 打点计时器 BLStopwatch

- (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions {
    
    [[BLStopwatch sharedStopwatch] start];
    ...
    初始化第三方 SDK
    配置 APP 运行需要的环境
    自己的一些工具类的初始化
    ...
    [[BLStopwatch sharedStopwatch] splitWithDescription:@"didFinishLaunchingWithOptions"];
    NSLog(@"\n启动耗时:%@",[[BLStopwatch sharedStopwatch].splits.firstObject objectForKey:@"#1 didFinishLaunchingWithOptions"]);

    return YES;
}

优化前的 didFinishLaunchingWithOptions 启动耗时:

didFinishLaunchingWithOptions启动耗时.png

如何管理项目需要启动的一些事件呢?为此,我们我专门建了一个类来负责启动事件,为什么呢?如果不这么做,那么此次优化以后,以后再引入第三方的时候,别的同事可能很直觉的就把第三方的初始化放到了 didFinishLaunchingWithOptions 方法里,这样久而久之, didFinishLaunchingWithOptions 又变得不堪重负,到时候又要专门花时间来做重复的优化。

/**
 * 注意: 这个类负责所有的 didFinishLaunchingWithOptions 延迟事件的加载.
 * 以后引入第三方需要在 didFinishLaunchingWithOptions 里初始化或者我们自己的类需要在 didFinishLaunchingWithOptions 初始化的时候,
 * 要考虑尽量少的启动时间带来好的用户体验, 所以应该根据需要减少 didFinishLaunchingWithOptions 里耗时的操作.
 * 第一类: 比如日志 / 统计等需要第一时间启动的, 仍然放在 didFinishLaunchingWithOptions 中.
 * 第二类: 比如用户数据需要在广告显示完成以后使用, 所以需要伴随广告页启动, 只需要将启动代码放到 startupEventsOnADTimeWithAppDelegate 方法里.
 * 第三类: 比如直播和分享等业务, 肯定是用户能看到真正的主界面以后才需要启动, 所以推迟到主界面加载完成以后启动, 只需要将代码放到 startupEventsOnDidAppearAppContent 方法里.
 */
#import <Foundation/Foundation.h>

NS_ASSUME_NONNULL_BEGIN

@interface FASDelayStartupTool : NSObject

/**
 * 启动伴随 didFinishLaunchingWithOptions 启动的事件.
 * 启动类型为:日志 / 统计等需要第一时间启动的.
 */
+ (void)startupEventsOnAppDidFinishLaunchingWithOptions;

/**
 * 启动可以在展示广告的时候初始化的事件.
 * 启动类型为: 用户数据需要在广告显示完成以后使用, 所以需要伴随广告页启动.
 */
+ (void)startupEventsOnADTime;

/**
 * 启动在第一个界面显示完(用户已经进入主界面)以后可以加载的事件.
 * 启动类型为: 比如直播和分享等业务, 肯定是用户能看到真正的主界面以后才需要启动, 所以推迟到主界面加载完成以后启动.
 */
+ (void)startupEventsOnDidAppearAppContent;

@property (nonatomic, strong) NSDictionary *launchOptions;

@end

NS_ASSUME_NONNULL_END

然后把 日志/统计 等需要第一时间启动的事件封装到 startupEventsOnAppDidFinishLaunchingWithOptions 方法中,用户数据需要在广告显示完成以后的事件可以放到 startupEventsOnADTime 方法中,启动在第一个界面显示完(用户已经进入主界面)以后可以加载的事件(如直播和分享等业务)可以放到 startupEventsOnDidAppearAppContent 方法中。

然后我们的 didFinishLaunchingWithOptions 方法中,只需要我们的工具类调用需要第一时间启动事件方法 startupEventsOnAppDidFinishLaunchingWithOptions 即可。

[FASDelayStartupTool startupEventsOnAppDidFinishLaunchingWithOptions];

然后在我们的 MainViewController 展示完成 viewDidAppear 以后,工具类进行启动在第一个界面显示完成事件 startupEventsOnDidAppearAppContent 即可。

[FASDelayStartupTool startupEventsOnDidAppearAppContent];

优化后的 didFinishLaunchingWithOptions 启动耗时:

优化后didFinishLaunchingWithOptions启动耗时.png

2. pre-main 阶段的时间

iOS启动时间可以通过在Edit -> Run -> Environment Variables 加入 DYLD_PRINT_STATISTICS环境变量来查看


环境变量.png

添加环境变量后,控制台输出信息如下图所示:


输出信息.png
输出内容展示了系统调用main()函前主要进行的工作内容和时间花费,Session上也对每一阶段加载过程具体内容进行了详细的叙述。

启动优化

dylib loading time: 对动态库加载的时间优化.每个App都进行动态库加载,其中系统级别的动态库占据了绝大数,而针对系统级别的动态库都是经过系统高度优化的,不用担心时间的花费.开发者应该关注于自己集成到App的那些动态库,这也是最能消耗加载时间的地方.对此Apple建议减少在App里开发者的动态库集成或者有可能地将其多个动态库最终集成一个动态库后进行导入, 尽量保证将App现有的非系统级的动态库个数保证在6个以内.微信之前就有超过6个的动态库,现在已经优化到只剩下 6个动态库了。
Objc setup time: 减少Objc运行初始化的时间花费.主要是类的注册,分类的注册,唯一选择器的存在,以及涉及子父类内存布局的Non Fragile ivars偏移的更新,都会影响Objective-C运行时初始化的时间消耗.
initializer time: 运行初始化程序。如果使用了Objective-C的 +load 方法,请将其替换为 +initialize 方法。
Rebase/binding time: 修正调整镜像内的指针(重新调整)和设置指向镜像外符号的指针(绑定)。为了加快重新定位/绑定时间,我们需要更少的指针修复。

动态语言:只写声明,不写实现, 编译不报错,执行报错
静态语言:只写声明,不写实现, 编译会报错 函数名称就是指针! 方法直接跳转

每个应用程序都有很大的可寻址内存,当应用程序分配内存时,即使所有的物理内存都被占用,操作系统也会提供内存。要适应此分配请求,操作系统会使用 paging(页面调度)或 swapping(交换) 操作将一些物理内存中的内容复制到硬盘。之前包含数据的物理内存就可供应用程序使用,而原来的那些数据已经写入硬盘。

如果又需要先前复制到硬盘的那块内存,操作系统会将另一块物理内存复制到硬盘,并将原先的旧内存再调度回内存。即使内存存在硬盘之间的调度,操作系统仍然能够为每个应用程序映射地址空间到物理内存。操作系统的这一功能称为 虚拟内存(virtual memory)

由于从物理内存和硬盘中复制内容很消耗时间,因此使用虚拟内存会影响性能。过多的页面调度会降低系统的性能,这称为抖动(thrashing)。如果一起使用的两个或多个对象在内存中的存储位置离得很远,抖动发生的可能性就会增加,因此对象实例的内存分配位置很重要。

考虑以下场景:当某个对象需要内存时可以从硬盘将它调度到物理内存。随后该对象需要访问另外一个对象,因此对象不位于物理内存,现在就需要调度更多内存。更坏的情况是,为第二个对象调度的内存会迫使第一个对象的内存被再次调出。由于对象会影响对方的交互,就会导致抖动。

分区用于确保分配给要同时使用的对象的内存位于相邻位置。当需要某个对象时,另外的对象也基本上会用到。因为这些对象位于同一个分区,需要的所有对象都同时调入内存的可能性就更大,当不需要对象时,又可以将它们同时调出内存。

之前的应用直接全部塞进物理内存中,因为软件发展比硬件快,物理内存不能够加载全部的应用,所以出现了虚拟内存
进程的虚拟内存 通过进程映射表(mmu)翻译内存地址 转成 物理内存地址
内存不以字节管理,而是以页来管理,假设有5页数据,并不是全部加载进入内存,用到多少加载多少,用到那一块加载那一块,分页加载,运到的时候,加载到物理内存中,之后点击到之前没用到的内存的时候,就会造成内存缺页异常,就会把缺页的数据加载到物理内存中,覆盖掉内存中不活跃的数据,内存不够用时候,会出现卡顿现象,这是系统如此设计的。一次缺页内存感受不到,应用启动的时候,有1000个内存缺页的时候,内存耗时就能感受到了,减少缺页的次数,就能够完成启动优化。
抛出问题:假设第一页 第三页 第五页 都只有一个方法需要加载,那如何才能尽量减少内存缺页异常呢?
Instruments
找到启动时间,运行应用,不能只检测main阶段,要检测从点击应用,到出现第一个界面即停止,点击Instruments,点击录制后,出现第一个页面,马上停止。过滤只显示Main Thread相关,选择Summary: Virtual Memory。
Main Thread -> Virtual Memory -> Fire Backed Page In 观察次数:Count 时间:Duration
第一次启动的时候,发现内存缺页Fire Backed Page In次数(Count)非常多,杀掉进程后,第二次启动便会发现内存缺页次数大幅度减少,其原因你们可能认为是热启动,但热启动背后的原理是什么呢?
冷启动:物理内存中没有应用的运行内存
热启动:物理内存中已经有应用的运行内存
所以说热启动的同学,你们杀掉应用后,再打开N个程序,然后再打开这个应用看一下,会发现:内存缺页的次数依然非常多,这是因为虚拟内存分页加载应用数据到物理内存,当物理内存快满的时候,系统会覆盖掉内存中不活跃的数据,所以第二次打开的时候,我们第一次启动留存在物理内存当中的数据已经非常少了,所以第二次启动的时候,内存缺页依然非常多。
那么现在到了我们之前抛出的问题:如何减少内存缺页呢?

二进制重排:

链接器 (LD)去做的 按照符号表的顺序去排列
objc4-750 源码 libobjc.order文件就用到了二进制重排,我们就是基于order_file完成二进制重排
AppDelegate加一个load方法
ViewController加一个load方法
二进制的顺序是按照 文件链接 顺序 Build Phase -> Compile Source ,如下图所示:

文件链接顺序.png
符号顺序 Build Setting -> link Map (link Map.txt文件)里面有符号顺序 符号也是根据文件 来 排列的,如下图所示:(我么可以通过/Users/xxx/Library/Developer/Xcode/DerivedData找到该文件缓存目录)
ps:Build Settings中修改Write Link Map File为YES编译后会生成一个Link Map符号表txt文件。
符号顺序.png
符号表.png

Xcode 使用的链接器件是 ld,ld 有一个不常用的参数-order_file,通过man ld可以看到详细文档:

Alters the order in which functions and data are laid out. For each section in the output file, any >symbol in that section that are specified in the order file file is moved to the start of its section and >laid out in the same order as in the order file file.

可以看到,order_file 中的符号会按照顺序排列在对应 section 的开始,完美的满足了我们的需求。

我们可以利用终端在该目录下新建(touch) 一个Order文件(ansyxpf.order),排列文件顺序,如下图所示:


ansyxpf.order.png

Xcode 的 GUI 也提供了 order_file 选项:build -> order file 把生成的order文件加进去


order file.png

然后command+shift+K clean清空一下,再编译一下,再去link Map.txt文件中查看符号顺序,你会发现:二进制重排后的符号顺序如下图所示:


二进制重排后的符号顺序.png

大功告成,我们顺利的完成了二进制重排!

如果 order_file 中的符号实际不存在会怎么样呢?
ld 会忽略这些符号,如果提供了 link 选项-order_file_statistics,会以 warning 的形式把这些没找到的符号打印在日志里。

那么如何获得自己主工程和三方库启动相关的符号表呢?

  1. Hook : 函数方法的本质都是发送消息,其底层都是通过 objc_msgSend 来实现的, objc_msgSend 是使用汇编语言编写的,是因为其一是使用纯 C 是无法编写一个携带未知参数并跳转至任意函数指针的方法,所以其参数是可变的,需要通过汇编来获取,所以不如直接用汇编来的方便。
  2. 静态扫描 :扫描 Mach-O 特定段和节里面所存储的符号以及函数数据
  3. Clang插桩 :即批量 Hook,可以实现100%符号覆盖,即完全获取swiftOCCblock 函数

Clang插桩

LLVM内置了一个简单的代码覆盖率检测(SanitizerCoverage)。它在函数级、基本块级和边缘级插入对用户定义函数的调用。我们这里的批量Hook,就需要借助于SanitizerCoverage

关于 clang 的插桩覆盖的官方文档如下 : clang 自带代码覆盖工具 文档中有详细概述,以及简短Demo演示。

1. 首先 , 添加编译设置

OC 项目:直接搜索 Other C Flags 来到 Apple Clang - Custom Compiler Flags 中 , 添加

-fsanitize-coverage=trace-pc-guard

Swift 项目: 需要额外在 “Other Swift Flags” 中加入

-sanitize-coverage=func
-sanitize=undefined
2. 重写方法

新建 FXOrderFile 文件,重写 __sanitizer_cov_trace_pc_guard_init__sanitizer_cov_trace_pc_guard 方法

/*
 - start:起始位置
 - stop:并不是最后一个符号的地址,而是整个符号表的最后一个地址,最后一个符号的地址=stop-4(因为是从高地址往低地址读取的,且stop是一个无符号int类型,占4个字节)。stop存储的值是符号的
 */
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;
}
/*
 可以全面hook方法、函数、以及block调用,用于捕捉符号,是在多线程进行的,这个方法中只存储pc,以链表的形式
 
 - guard 是一个哨兵,告诉我们是第几个被调用的
 */
void __sanitizer_cov_trace_pc_guard(uint32_t *guard) {
//  if (!*guard) return;
  //当前函数返回到上一个调用的地址!!
    // __builtin_return_address 当前函数返回到哪里去 0:当前函数地址 1:当前调用者的地址
    void *PC = __builtin_return_address(0);
    //创建结构体!
   SYNode * node = malloc(sizeof(SYNode));
    *node = (SYNode){PC,NULL};
    
    //加入结构!
    OSAtomicEnqueue(&symbolList, node, offsetof(SYNode, next));
}
3. 获取所有符号并写入文件

while 循环从队列中取出符号,处理非 OC 方法的前缀,存到数组中
数组取反,因为入队存储的顺序是反序的
数组去重,并移除本身方法的符号
将数组中的符号转成字符串并写入到 ansyxpf.order 文件中

extern void getOrderFile(void(^completion)(NSString *orderFilePath)){
    
    collectFinished = YES;
    __sync_synchronize();
    NSString *functionExclude = [NSString stringWithFormat:@"_%s", __FUNCTION__];
    dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(0.01 * NSEC_PER_SEC)), dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
        //创建符号数组
        NSMutableArray<NSString *> *symbolNames = [NSMutableArray array];
        
        //while循环取符号
        while (YES) {
            //出队
            CJLNode *node = OSAtomicDequeue(&queue, offsetof(CJLNode, next));
            if (node == NULL) break;
            
            //取出PC,存入info
            Dl_info info;
            dladdr(node->pc, &info);
//            printf("%s \n", info.dli_sname);
            
            if (info.dli_sname) {
                //判断是不是OC方法,如果不是,需要加下划线存储,反之,则直接存储
                NSString *name = @(info.dli_sname);
                BOOL isObjc = [name hasPrefix:@"+["] || [name hasPrefix:@"-["];
                NSString *symbolName = isObjc ? name : [@"_" stringByAppendingString:name];
                [symbolNames addObject:symbolName];
            }
           
        }
        
        if (symbolNames.count == 0) {
            if (completion) {
                completion(nil);
            }
            return;
        }
        
        //取反(队列的存储是反序的)
        NSEnumerator *emt = [symbolNames reverseObjectEnumerator];
        
        //去重
        NSMutableArray<NSString *> *funcs = [NSMutableArray arrayWithCapacity:symbolNames.count];
        NSString *name;
        while (name = [emt nextObject]) {
            if (![funcs containsObject:name]) {
                [funcs addObject:name];
            }
        }
        
        //去掉自己
        [funcs removeObject:functionExclude];
        
        //将数组变成字符串
        NSString *funcStr = [funcs componentsJoinedByString:@"\n"];
        NSLog(@"Order:\n%@", funcStr);
        
        //字符串写入文件
        NSString *filePath = [NSTemporaryDirectory() stringByAppendingPathComponent:@"ansyxpf.order"];
        NSData *fileContents = [funcStr dataUsingEncoding:NSUTF8StringEncoding];
        BOOL success = [[NSFileManager defaultManager] createFileAtPath:filePath contents:fileContents attributes:nil];
        if (completion) {
            completion(success ? filePath : nil);
        }
    });
}
4. 在didFinishLaunchingWithOptions方法最后调用

需要注意的是,这里的调用位置是由你决定的,一般来说,是第一个渲染的界面

- (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions {
    
    
    getOrderFile(^(NSString *orderFilePath) {
        NSLog(@"OrderFilePath:%@", orderFilePath);
    });
    
    return YES;
}

5. 拷贝文件,放入指定位置,并配置路径

一般将该文件放入主项目路径下,并在 Build Settings -> Order File 中配置 ./ansyxpf.order

order file.png
成果展示

以下是我的应用二进制重排前后的Instruments分析图!

二进制重排后.png 二进制重排前.png

附:

虚拟内存的安全问题

如果用虚拟内存,如果知道了应用的大小,映射表的数据都是从0开始,偏移地址地址不会变,知道方法地址,通过偏移地址就能知道方法的实际地址,所以出现了ASLR(随机的偏移值),方法的实际地址就不知道了 (ASLR iOS4.3版本出现了)
ASLR:随机偏移量
内部方法都有偏移地址
内部方法的地址:内部方法的偏移地址(0xff000) + ASLR随机偏移量 (0x111) = 0xff111

iOS 系统的内存页大小为16KB
MAC 系统的内存页大小为4KB

PIC(位置无关代码)

首先,需要理解加载域与运行域的概念。加载域是代码存放的地址,运行域是代码运行时的地址。为什么会产生这2个概念?这2个概念的实质意义又是什么呢?

在一些场合,一些代码并不在储存这部分代码的地址上执行地址,比如说,放在norflash中的代码可能最终是放在RAM中运行,那么中norflash中的地址就是加载域,而在RAM中的地址就是运行域。

在汇编代码中我们常常会看到一些跳转指令,比如说b、bl等,这些指令后面是一个相对地址而不是绝对地址,比如说b main,这个指令应该怎么理解呢?main这里究竟是一个什么东西呢?这时候就需要涉及到链接地址的概念了,链接地址实际上就是链接器对代码中的变量名、函数名等东西进行一个地址的编排,赋予这些抽象的东西一个地址,然后在程序中访问这些变量名、函数名就是在访问一些地址。一般所说的链接地址都是指链接这些代码的起始地址,代码必须放在这个地址开始的地方才可以正常运行,否则的话当代码去访问、执行某个变量名、函数名对应地址上的代码时就会找不到,接着程序无疑就是跑飞。但是上面说的那个b main的情形有点特殊,b、bl等跳转指令并不是一个绝对跳转指令,而是一个相对跳转指令,什么意思呢?就是说,这个main标签最后得到的只并不是main被链接器编排后的绝对地址,而是main的绝对地址减去当前的这个指令的绝对地址所得到的值,也就是说b、bl访问到的是一个相对地址,不是绝对地址,因此,包括这个语句和main在内的代码段无论是否放在它的运行域这段代码都能正常运行。这就是所谓的位置无关代码。

由上面的论述可以得知,如果你的这段代码需要实现位置无关,那么你就不能使用绝对寻址指令,否则的话就是位置有关了。

上一篇下一篇

猜你喜欢

热点阅读