iOS开发技术博客

iOS App启动优化方案

2022-09-18  本文已影响0人  Xcoder_

冷启动

APP的启动流程图如下:

image.png

pre-main 阶段

pre-main 阶段指的是从用户唤起 App 到 main() 函数执行之前的过程。
对于pre-main阶段,Xcode9之后,Apple提供了一种测量方法,在 Xcode 中 Edit scheme -> Run -> Auguments 将环境变量DYLD_PRINT_STATISTICS设为1 。

如下图所示,包含 main 函数执行之前各项的加载时间,我们可以多次运行取一下平均值,苹果推荐这个时间应在 400ms 以内

Total pre-main time: 354.21 milliseconds (100.0%)
         dylib loading time:  25.52 milliseconds (7.2%)
        rebase/binding time:  12.70 milliseconds (3.5%)
            ObjC setup time: 152.74 milliseconds (43.1%)
           initializer time: 163.24 milliseconds (46.0%)
           slowest intializers :
             libSystem.B.dylib :   7.98 milliseconds (2.2%)
   libBacktraceRecording.dylib :  13.53 milliseconds (3.8%)
    libMainThreadChecker.dylib :  41.11 milliseconds (11.6%)
                      TestDemo :  88.76 milliseconds (25.0%)

pre-main 阶段所干的事大概可以总结为:

ASLR(地址空间配置随机加载)

ASLR(Address space layout randomization):地址空间配置随机加载,是一种防范内存损坏漏洞被利用的计算机安全技术。

地址空间配置随机加载利用随机方式配置数据地址空间,使某些敏感数据配置到一个恶意程序无法事先获知的地址,令攻击者难以进行攻击。

总结为如下图:

image.png

pre-main 阶段的优化方案:

main() 阶段

对于 main() 阶段,主要测量的就是从 main()函数开始执行到 didFinishLaunchingWithOptions方法执行结束的耗时。

image.png

对于main() 阶段时间,比较好测量,我们可以在 main 函数开始执行和 applicationDidBecomeActive: 方法执行末尾时分别记录一个时间点,然后计算两者时间差即可,大致如下:

image.png

其中,关于 StartupTimeMonitor 的定义如下:

#import <Foundation/Foundation.h>

@interface StartupTimeMonitor : NSObject

+ (instancetype)sharedMonitor;

- (void)appWillStartLoading;
- (void)appDidFinishLoading;

@end


#import "StartupTimeMonitor.h"

@interface StartupTimeMonitor () {
 CFAbsoluteTime _startTime;
 CFAbsoluteTime _stopTime;
}

@end

@implementation StartupTimeMonitor

+ (instancetype)sharedMonitor {
 static StartupTimeMonitor *sharedMonitor = nil;
 static dispatch_once_t onceToken;
 dispatch_once(&onceToken, ^{
 sharedMonitor = [[StartupTimeMonitor alloc] init];
 });
 return sharedMonitor;
}

- (void)appWillStartLoading {
 _startTime = CFAbsoluteTimeGetCurrent();
}

- (void)appDidFinishLoading {
 _stopTime = CFAbsoluteTimeGetCurrent();

 NSUInteger milliseconds = (NSUInteger)((_stopTime - _startTime) * 1000);
 NSLog(@"Loading done in %lu ms", milliseconds);
}

@end

main() 阶段的优化方案:

纯代码的方式,而不是 xib/Storyboard,来加载首页视图
延迟暂时不需要的三方库加载;
延迟执行部分业务逻辑和 UI 配置,首屏渲染完成前只处理首屏相关的业务;
延迟加载 / 懒加载部分视图;
避免首屏加载时大量的本地/网络数据读取;

二进制重排优化启动

什么是二进制重排

重新排列函数符号的位置,降低Mach-O文件载入物理内存时触发的PageFault次数,这个叫二进制重排

物理内存&虚拟内存

虚拟内存的概述图如下:

image.png

虚拟内存的技术出现之后,每个进程并不是直接全部扔进物理内存,而是给每个应用分配一个虚拟的内存,虚拟内存通过虚拟页表来把相应数据放进物理内存里面。

虚拟内存的技术出现之后,也有了内存分页的概念,虚拟页表把一个进程分成若干页,比如:Page1、Page2、Page3…,当启动进程1的时候,只需要把Page1装载进物理内存,以此类推,如上图。

缺页中断(PageFault)

假设在启动时期我们需要调用两个函数 method1 与 method4 ,函数编译在 mach-o 中的位置是根据 ld ( Xcode 的链接器) 的编译顺序并非调用顺序来的 ,因此很可能这两个函数分布在不同的内存页上 。

image

那么启动时 , page1 与 page2 则都需要从无到有加载到物理内存中 , 从而触发两次 page fault 。

而二进制重排的做法就是将 method1 与 method4 放到一个内存页中 ,那么启动时则只需要加载 page1 即可 ,也就是只触发一次 page fault ,达到优化目的 。

实际项目中的做法是将启动时需要调用的函数放到一起 ( 比如 前10页中 ) 以尽可能减少 page fault ,达到优化目的 , 而这个做法就叫做 : 二进制重排 。

缺页中断时间消耗的检测

前面我们已经提到了缺页中断,接下来我们通过Profile来检测一下缺页中断的发生。
Xcode顶部菜单Product->Profile->Instruments->System Trace

image

可以看到我们的项目冷启动时,缺页次数大概是1200多次,耗时130毫秒,如果项目再大一些,缺页发生的更多那么也是一个不小的影响启动时间的一个因素。

为什么二进制重排能优化启动时间

我们先来举个例子,一个应用启动需要调用方法1、方法3、方法4、方法6、方法7,其中方法1在Page1页上,方法6在Page2页上,方法3、方法7在Page3页上,方法4在Page4页上,如下图所示:

image.png

二进制排列后,如下图所示:

image.png

经过二进制重排之后,我们把启动需要调用的方法全部集中在了Page1里面,这样在启动时只需要装载Page1即可,相比之前减少了Page2、Page3、Page4的装载,这就减少了PageFault的次数,节省的时间大约为:0.5ms * 3 = 1.5ms。

这也就解释了为什么二进制重排能够优化启动时长。

Link Map File

链接映射文件:Link Map File,是编译期间产生的产物,里面记录的是每个类所生成的可执行文件的路径、CPU架构、符号等信息,可以简单的理解为这个文件告诉了我们一个应用的可执行文件的排列顺序。

BuildSetting - Write Link Map File设置为YES

image.png

修改完毕后 clean 一下 , 运行工程 , Products --> Show Build folder in Finder, 找到 macho 的上上层目录.

image.png

link map文件的文件名为: xxxDemo-LinkMap-normal-x86_64.txt

image.png

编译项目之后根据上图的地址找到我们需要的Link Map 文件,如图所示:

image.png

文件资源的编译顺序如下图所示:

image.png

从上图可以看出一个项目可执行文件的排列顺序为:

Order File

在项目根目录通过touch link.order生成link.order文件,这里面就是方法符号的排序

image.png

然后通过Target -> Build Setting -> Linking -> Order File 设置 order file 的路径

image.png

编写order_file

我们尝试修改函数顺序,link.order文件里写入以下的内容,

-[ViewController viewDidLoad]
+[ViewController boringColor]
-[ViewController learningGCD]
image.png

command + Kcommand + B 再查看一下Link Map File,顺序已经换过来了

image.png

可以看到 , 我们所写的这三个方法已经被放到最前面了 , 至此 , 生成的 macho 中距离首地址偏移量最小的代码就是我们所写的这三个方法 , 假设这三个方法原本在不同的三页 , 那么我们就已经优化掉了两个 page fault。

到这里为止,二进制重排的整个核心我们就分析得差不多了,但是这个二进制重排有个最大的问题,那就是:我们如何才能准确获取项目启动时刻调用的方法顺序,换句话说我怎么知道我这个项目启动需要调用到哪些方法。

Clang插桩

LLVM内置了一个简单的代码覆盖率检测工具(SanitizerCoverage)。它在函数级、基本块级和边缘级插入对用户定义函数的调用,并提供了这些回调的默认实现。在认为启动结束的位置添加代码,就能够拿到启动到指定位置调用到的所有函数符号。

看一看 ~ clang文档

image.png

Swift混编的项目,在Buiding Setting中Other Swift Flags里面添加

image.png

在项目启动首页后的地方调用一下下面的代码,生成OrderFile文件

#import "dlfcn.h"
#import <libkern/OSAtomic.h>

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 list = OS_ATOMIC_QUEUE_INIT;
//定义节点结构体
typedef struct {
    void *pc;   //存下获取到的PC
    void *next; //指向下一个节点
} Node;

void __sanitizer_cov_trace_pc_guard(uint32_t *guard) {
     void *PC = __builtin_return_address(0);
     Node *node = malloc(sizeof(Node));
     *node = (Node){PC, NULL};
     // offsetof() 计算出列尾,OSAtomicEnqueue() 把 node 加入 list 尾巴
     OSAtomicEnqueue(&list, node, offsetof(Node, next));
}

- (void)startCreateOrderFile {
    NSLog(@"开始...");
    NSMutableArray *arr = [NSMutableArray array];
    while(1){
        //有进就有出,这个方法和 OSAtomicEnqueue() 类比使用
        Node *node = OSAtomicDequeue(&list, offsetof(Node, next));
        //退出机制
        if (node == NULL) {
            break;
        }
        //获取函数信息
        Dl_info info;
        dladdr(node->pc, &info);
        NSString *sname = [NSString stringWithCString:info.dli_sname encoding:NSUTF8StringEncoding];
        printf("%s \n", info.dli_sname);
        //处理c函数及block前缀
        BOOL isObjc = [sname hasPrefix:@"+["] || [sname hasPrefix:@"-["];
        //c函数及block需要在开头添加下划线
        sname = isObjc ? sname: [@"_" stringByAppendingString:sname];
        
        //去重
        if (![arr containsObject:sname]) {
            //因为入栈的时候是从上至下,取出的时候方向是从下至上,那么就需要倒序,直接插在数组头部即可
            [arr insertObject:sname atIndex:0];
        }
    }
    
    //去掉 touchesBegan 方法 启动的时候不会用到这个
    [arr removeObject:[NSString stringWithFormat:@"%s",__FUNCTION__]];
    //数组合成字符串
    NSString * funcStr = [arr  componentsJoinedByString:@"\n"];
    //写入文件
    NSString * filePath = [NSTemporaryDirectory() stringByAppendingPathComponent:@"link.order"];
    NSData * fileContents = [funcStr dataUsingEncoding:NSUTF8StringEncoding];
    NSLog(@"结束...");
    NSLog(@">> 生成的文件路径为:%@", filePath);
    [[NSFileManager defaultManager] createFileAtPath:filePath contents:fileContents attributes:nil];
}
image.png

运行代码后记录 link.order 的路径,本demo生成的文件路径为:/Users/xxxx/Library/Developer/CoreSimulator/Devices/D5B9DEA2-86A4-4A9F-8E71-EF6C18449D80/data/Containers/Data/Application/F938AF62-2A5A-4C62-969D-65DA8987D620/tmp/link.order

Finder 前往路径取出 order file

image.png

放在项目根目录,修改函数调用顺序


image.png

如何统计pod库的函数调用

由于我们是通过编译选项去做的插桩,它只会生效于有该选项的工程,而pod库则是单独的工程,我们只需要在Podfile文件后面加上下面这段

post_install do |installer|
  installer.pods_project.targets.each do |target|
    target.build_configurations.each do |config|
        if config.name == 'Debug'
          # 将依赖的pod项目的Other C Flags加上’-fsanitize-coverage=func,trace-pc-guard‘选项
          config.build_settings['OTHER_CFLAGS'] ||= ['$(inherited)', '-fsanitize-coverage=func,trace-pc-guard']
          config.build_settings['OTHER_SWIFT_FLAGS'] ||= ['$(inherited)', '-fsanitize-coverage=func,trace-pc-guard']
        end
      #end
    end
  end
end

APP启动的监控手段

为了可以监控到日常开发过程中启动耗时变化,监控了启动过程中的方法调用耗时,通过每天构建对比当天版本和昨天版本的差异分析耗时原因,流程如下:

image.png

统计pre-main耗时:

pre-main耗时 = 进入main 函数的时间 - 进程创建时间,以下是获取进程创建时间实现

+ (BOOL)processInfoForPID:(int)pid procInfo:(struct kinfo_proc *)procInfo {
    int cmd[4] = {CTL_KERN, KERN_PROC, KERN_PROC_PID, pid};
    size_t size = sizeof(*procInfo);
    return sysctl(cmd, sizeof(cmd)/sizeof(*cmd), procInfo, &size, NULL, 0) == 0;
}

+ (NSTimeInterval)processStartTime {
    struct kinfo_proc kProcInfo;
    if ([self processInfoForPID:[[NSProcessInfo processInfo] processIdentifier] procInfo:&kProcInfo]) {
        return kProcInfo.kp_proc.p_un.__p_starttime.tv_sec * 1000.0 + kProcInfo.kp_proc.p_un.__p_starttime.tv_usec / 1000.0;
    } else {
        NSAssert(NO, @"无法取得进程的信息");
        return 0;
    }
}

参考:
BLStopwatch
ios-deploy
iOS 启动优化 + 监控实践

上一篇 下一篇

猜你喜欢

热点阅读