阅读 716

iOS 启动时间优化

在 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 方法里,这里完全不会影响到启动时间。针对初始化耗时的库,比如埋点库,可以延后初始化,先将所需要的数据存储到内存中,待到埋点库初始化时再进行记录。对一些主图上业务网络可以延后请求,比如闪屏、消息盒子、主图天气、限行控件数据请求、开放图层数据、Wi-Fi信息上报请求等。

既然思路有了,我们就开始动手吧!在这里我们需要用到一个工具 —— 打点计时器 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

进入主视图之后的优化

线程调度和任务编排

对于任务编排有种打法,就是先把所有任务滞后,然后再看哪个是启动开始必须要加载的。效果立竿见影,很快就能看到最好的结果,后面就是反复斟酌,严格把关谁才是必要的启动任务了。

启动阶段的任务,先理出相关依赖关系,在框架中进行配置,有依赖的任务有序执行,无依赖独立任务可以在非密集任务执行期串行分组,组内并发执行。

针对初始化耗时的库,比如埋点库,可以延后初始化,先将所需要的数据存储到内存中,待到埋点库初始化时再进行记录。对一些主图上业务网络可以延后请求,比如闪屏、消息盒子、主图天气、限行控件数据请求、开放图层数据、Wi-Fi信息上报请求等。

2. pre-main 阶段的时间

pre-main 阶段

  1. 操作系统在创建进程后,把控制权交到了程序的入口,这个入口往往是运行库中的某个入口的函数 _dyld_start

  2. 入口函数对运行库和程序运行环境进行初始化,包括堆、I/O、线程、全局变量构造,等等。

  3. 入口函数在完成初始化之后,调用 main 函数,正式开始执行程序的主体部分。

  4. main 函数执行完毕以后,返回到入口函数,入口函数进行清理工作,包括全局变量析构、堆销毁、关闭I/O等,然后进行系统调用结束进程。

那么优化启动时间,就必须优化一下入口函数_dyld_startmain函 的启动时间,所以:

  • _dyld_start 之后要少些动态库,因为链接耗时;
  • 少些 +load、C 的 constructor 函数和 C++ 静态对象,因为这些会在启动阶段执行,多了就会影响启动时间。
  • 没有用的代码就需要定期清理和线上监控。通过元类中flag的方式进行监控然后定期清理。

iOS启动时间可以通过在Edit -> Run -> Environment Variables 加入 DYLD_PRINT_STATISTICS环境变量来查看 环境变量.png 添加环境变量后,控制台输出信息如下图所示: 输出信息.png 输出内容展示了系统调用main()函前主要进行的工作内容和时间花费,Session上也对每一阶段加载过程具体内容进行了详细的叙述。

启动优化

  1. dylib loading time: 对动态库加载的时间优化.每个App都进行动态库加载,其中系统级别的动态库占据了绝大数,而针对系统级别的动态库都是经过系统高度优化的,不用担心时间的花费.开发者应该关注于自己集成到App的那些动态库,这也是最能消耗加载时间的地方.对此Apple建议减少在App里开发者的动态库集成或者有可能地将其多个动态库最终集成一个动态库后进行导入, 尽量保证将App现有的非系统级的动态库个数保证在6个以内.微信之前就有超过6个的动态库,现在已经优化到只剩下 6个动态库了。
  2. Objc setup time: 减少Objc运行初始化的时间花费.主要是类的注册,分类的注册,唯一选择器的存在,以及涉及子父类内存布局的Non Fragile ivars偏移的更新,都会影响Objective-C运行时初始化的时间消耗.
  3. initializer time: 运行初始化程序。如果使用了Objective-C的 +load 方法,请将其替换为 +initialize 方法。少些 +load、C 的 constructor 函数和 C++ 静态对象,因为这些会在启动阶段执行,多了就会影响启动时间。
  4. Rebase/binding time: 修正调整镜像内的指针(重新调整)和设置指向镜像外符号的指针(绑定)。为了加快重新定位/绑定时间,我们需要更少的指针修复。
    • 如果有大量(大的是20000)Objective-C类、选择器和类别的应用程序可以增加800ms的启动时间。
    • 如果应用程序使用C++代码,那么使用更少的虚拟函数。
    • 使用Swift结构体通常也更快。可以使用swift语言,因为swift语言是静态的,静态语言效率更高一些。
  • 动态语言: 只写声明,不写实现, 编译不报错,执行报错 
  • 静态语言: 只写声明,不写实现, 编译会报错  函数名称就是指针! 方法直接跳转

之前的应用直接全部塞进物理内存中,因为软件发展比硬件快,物理内存不能够加载全部的应用,所以出现了虚拟内存 进程的虚拟内存 通过进程映射表(mmu)翻译内存地址  转成 物理内存地址

内存不以字节管理,而是以页来管理,假设有5页数据,并不是全部加载进入内存,用到多少加载多少,用到那一块加载那一块,分页加载,运到的时候,加载到物理内存中,之后点击到之前没用到的内存的时候,就会造成内存缺页异常,就会把缺页的数据加载到物理内存中,覆盖掉内存中不活跃的数据,内存不够用时候,会出现卡顿现象,这是系统如此设计的。一次缺页内存感受不到,应用启动的时候,有1000个内存缺页的时候,内存耗时就能感受到了,减少缺页的次数,就能够完成启动优化。

抛出问题: 假设第一页 第三页 第五页 都只有一个方法需要加载,那如何才能尽量减少内存缺页异常呢?

  1. 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 方法

  • __sanitizer_cov_trace_pc_guard_init 方法
/*
 - 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;
}
复制代码
  • 参数1 start 是一个指针,指向无符号int类型,4个字节,相当于一个数组的起 始位置,即符号的起始位置(是从高位往低位读)

参数1

- `参数2 stop`,由于数据的地址是往下读的(即从高往低读,所以此时获取的地址并不是stop真正的地址,而是标记的最后的地址,读取stop时,由于stop占4个字节,stop真实地址 = stop打印的地址-0x4)
复制代码

参数2

- stop内存地址中存储的值表示什么?在增加一个方法/块/c++/属性的方法(多3个),发现其值也会增加对应的数,例如增加一个test1方法
复制代码

stop含义

  • __sanitizer_cov_trace_pc_guard 方法
/*
 可以全面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分析图!

  • File Backed Page In次数就是触发Page Fault(内存缺页)的次数
  • Page Cache Hit就是页缓存命中的次数

二进制重排后.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在内的代码段无论是否放在它的运行域这段代码都能正常运行。这就是所谓的位置无关代码。

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

参考链接:

App 启动提速实践和一些想法

文章分类
iOS
文章标签