iOS APP启动时间分析

我们都知道 APP 启动时长对保证用户粘性有很大影响,一款加载时长过长的应该可能会直接被用户放弃,那么 APP启动时究竟做了系统究竟都做了哪些工作呢?下面就让我们一起来探究下

1. 启动类型

作为一个开发者,相信大家都已经了解了热启动和冷启动的差别,故此处仅做简介不再详细介绍。

  1. 热启动

    当用户按下home键的时候,iOS 的 App 并不会马上被 kill 掉,还会继续保有一些资源。理想情况下,用户点击 App 的图标再次回来的时候,App几乎不需要做什么,就可以还原到退出前的状态,继续为用户服务。这种持续存活的情况下启动 App,我们称为热启动。

  2. 冷启动

    冷启动就是 App 从不持有任何资源(重新启动/被 kill 掉)一切从头开始启动的过程.

热启动和冷启动

相比较之下,我们应该更关注冷启动的时间,苹果曾在 WWDC 2016大会上曾提到过:APP 启动持续时间因设备而异,400毫秒内是一个较好的启动时长目标,不要让你的启动时间超过20s。

2. 启动流程

首先先简单回顾下启动的整个流程,其实整体上可以分为两大块:pre-main 阶段和 main 阶段,如下图所示:

launch

  1. pre-main 阶段

【1.1】加载应用的可执行文件(自身App的所有.o文件的集合)

【1.2】加载动态链接器dyld(dynamic loader,是一个专门用来加载动态链接库的库)

【1.3】dyld递归加载应用所有依赖的动态链接库dylib

  1. main 阶段

【2.1】调用main()

【2.2】调用UIApplicationMain()

【2.3】调用applicationWillFinishLaunching

而其中 pre-main 阶段提到的 images 是泛指如下所示文件类型:


fileType

Executanle:应用的主要二进制文件

Dylib:动态链接库(又名 DSO 或 DLL)

Bundle:资源文件,不能被链接的 Dylib,只能在运行时使用 dlopen() 加载

Image:上述三种类型的统称

下面我们来分别介绍一下这主要的两个阶段

2.1 pre-main阶段

pre-main 阶段最主要的工作在于加载可执行文件和动态链接,而其中的各个步骤如下图所示:


dyld

2.1.1 Load dylibs

在这一阶段首先dylds会解析应用依赖的动态库,找到其所需的mach-o文件,打开并且读取这些文件并验证其有效性,然后注册代码签名到内核,最后对dylib的每一个segment调用mmap()。


loadDylibs

一般情况下,iOS 应用会加载100-400个dylibs,其中大部分是系统库,这部分 dylib 的加载系统已经做了优化。

另外上图右侧的图中可以看出,其中 Mach-O 图像被分成段, 按照惯例,所有段名称都使用大写字母。每个段总是页面大小的倍数,而页面大小由硬件决定,对于arm64, 页面大小为16K,其他一切都是4k。其中 TEXT 位于文件的开头,它包含Mach头, 它包含机器指令以及只读常量,如c字符串;DATA段包含所有全局变量,是可重写的;而 LINKEDIT 包含 有关变量函数的信息,例如它们的名称和地址。

2.1.2 Rebase

由于dylib的加载过程中,系统为了安全考虑,引入了 ASLR(Address Space Layout Randomization 技术和代码签名。而 ASLR 使所有动态库被加载到随机地址上,所以需要 rebase 遍历所有的内部数据指针,然后为它们添加一个地址偏移值。

rebase

2.1.3 Bind

Bind 操作针对那些指向动态库之外的指针,这些指针通过名称绑定。运行时,dylb 通过符号名找到实现该符号的位置,主要是遍历查找符号表,当找到时把值存到该数据指针中。这几乎不会发生页面错误。

bind

2.1.4 Objc

ObjC 是动态语言,可以在运行时通过类名把类实例化,所以在运行时,ObjC 需要维护一张包含所有类与其映射的表格。每个加载类时,在这个全局表格中注册类名。在运行时还会把定义的 Category 插入到方法列表中。

另外Selector 对于 ObjC 是唯一的。

Objc

2.1.5 Initializers

调用所有类的 +(void)load 方法,对所有动态库初始化。需要从下到上初始化,因为上层的一些动态库可能依赖于下层的动态库,所以先初始化下层的动态库保证所有的动态库都可以正确初始化。

当所有的动态库初始化完成后,最终调用主 dylib 程序,也就是 main()

init

2.1.6 如何优化?

寻找优化点需要先了解每个步骤的一个时长,这样才能够更有针对点。所以我们先来看下如何获取启动消耗时长。

2.1.6.1 开发环境下时长测量

在开发环境下,我们可以通过配置 Schemes 中的环境变量 DYLD_PRINT_STATISTICS (简略)或 DYLD_PRINT_STATISTICS_DETAILS (详细)为1,可以看到 pre-main 阶段各个步骤消耗时长。


editSchemes

耗时打印
2.1.6.2 线上环境下时长测量

线上环境没有xcode控制台,但是启动流程是相同的,所以在对应锚点位置进行打点去计算整体耗时也是可行的。

APP 整个初始化过程都是从 initializeMainExecutable 方法开始的。dyld 会优先初始化动态库,然后初始化 App 的可执行文件。那么找到最早加载的动态库,然后在其 load 函数中做 Hook 即可拿到开始时间,动态库的 load 顺序是与 Load Commands 顺序和依赖关系息息相关的,只要把我们的耗时统计库命名为 A 开头的库(未亲测),并在内部进行hook 打点即可。再次总结下整体的思路:

  • 找到最早 load 的动态库

  • 在 load 函数中获取 App 中的所有可执行文件

  • hook 对应的可执行文件的 load 函数

  • 统计每个 load 函数的时间、全部 load 函数的整体时间

  • 上报统计分析

2.1.6.3 可优化点

综上所述可以看出,依赖的 dylib 越少越好。

在 pre-main 阶段,我们可以做的优化有:

1、尽量不使用内嵌(embedded)的dylib,加载内嵌dylib性能开销较大

2、合并已有的dylib和使用静态库(static archives),减少dylib的使用个数

3、懒加载dylib,但是要注意dlopen()可能造成一些问题,且实际上懒加载做的工作会更多

4、整理代码,去除重复的实现,避免出现功能重复的类&分类&方法

2.2 main阶段

main 阶段的调用步骤从调用 main() 到首页 viewWillDidLoad 加载完毕,这时我们的 APP 相当于加载完成,过程比较清晰,不在赘述。


main

2.2.1 如何优化?

main 到 didFinishLaunching 结束或者第一个 ViewController 的 viewDidAppear 都是作为 main 之后启动时间的一个度量指标。直接使用全局变量统计打点计算即可,但遇到时间较长需要排查问题时,只有这样粗略的统计两个点的时间并不方便排查,目前比较好的方式就是为把启动任务规范化、粒子化,针对每个任务时长进行打点统计,方便后期问题的定位和优化。

第一步,在 didFinishLaunchingWithOptions 方法里,我们会创建应用的 window,指定首页视图控制器;也会由于业务需要初始化所有第三方库;检查是否需要显示引导页、是否需要登录、是否有新版本等。。。由于历史原因,这里的代码容易变得比较庞大,启动耗时难以控制。

第二步,首页控制器视图中的 viewWillDidLoad 中的一些操作,例如设置系统UI风格,网络请求加载数据,也会让页面加载空白时长太长。

所以综合以上两个步骤所做的工作,可以进行以下优化:

1、梳理第三方库,找到可以延迟加载的库,做延迟加载处理,比如放到首页控制器的viewDidAppear方法里。

2、梳理业务逻辑,把可以延迟执行的逻辑,做延迟执行处理。比如检查新版本、注册推送通知等逻辑。

3、避免复杂/多余的计算,另外首页控制器尽量采用纯代码方式来构建以节约耗时。

4、避免在首页控制器的viewDidLoad和viewWillAppear做太多耗时操作,因为这2个方法执行完成,首页控制器才能显示,所以部分可以延迟创建的视图应做延迟创建/懒加载处理。

线上启动时间收集方案

pre-main阶段耗时

由于pre-main阶段主要包含如下过程:Load dylibs -> Rebase ->Bind ->ObjC ->Initializers,由系统帮助执行,在开发过程中对开发者基本不可见。

方案1-度量 C++ Static Initializers

【参考链接】

自行测试方案准确度不高,因为很多并行执行所以一味使用时间差相加并不够准确。

方案2-获取exec函数执行时间为初始时间点

【参考链接】

因为系统允许我们通过sysctl函数获得进程的有关信息,其中就包括进程创建的时间戳。那么就可获取App的进程创建时间(即exec函数执行时间)作为冷启动的起始时间(未亲测)

#import 
#import 

+ (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;
    }
}

main阶段耗时

通过获取main函数执行前的开始节点,到应用启动结束(可按照application:didFinishLaunchingWithOptions:为标准,也可按照首屏viewWillAppear:)为结束节点,取其差值,即可得出main阶段耗时。以下以application:didFinishLaunchingWithOptions:为标准举例:

1. 获取开始节点

CFAbsoluteTime startTime;
int main(int argc, char * argv[])
{
    startTime = CFAbsoluteTimeGetCurrent();
    @autoreleasepool {
        return UIApplicationMain(argc, argv, nil, NSStringFromClass([WYSphinxAppDelegate class]));
    }
}

2. 获取耗时

extern CFAbsoluteTime startTime;

@implementation AppDelegate

- (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions
{
    double launchTime = CFAbsoluteTimeGetCurrent() - startTime;
    NSLog(@"main阶段启动时间为:%f",launchTime);
    return YES;
}

录屏分帧方案

【参考链接】

录屏测试方案通过记录移动设备屏幕的变化,分析用户从点击 App 图标到看到主体框架出现的时长更加直观,但缺点为启动时长的判断必然会受到开屏广告的影响。

知乎采用了选取了开源的录屏工具 xrecord,代码是托管在 Gitlab 上,每一个需求的提测对应到一个 Merge Request,针对 Merge Request 进行测试,确认代码变动不会引入增加启动耗时的风险,才能正常合入。 在 Merge Request 打出包后,通过 ios-deploy 工具,在真机上自动安装知乎 App 并启动 10 次。测试结束后,客户端上报记录的启动时长数据到数据收集服务。整体测试可在 Jenkins Pipeline 里完成。

方案数据比较

hook_cpp_init方案 获取exec函数执行时间 DEBUG环境DYLD_PRINT数据 main阶段 备注
记录1 5.741000175476074(偏差:2.62) 3.347375000(偏差:0.22) 2.1 1.021468 偏差计算取小数点后两位计算
记录2 15.26010036468506(偏差:13.97) 1.472.803955(偏差:0.18) 0.87252 0.423142
记录3 5.32984733581543(偏差:4.15) 1.363412842(偏差:0.19) 0.83899 0.340946
记录4 5.290031433105469(偏差:4.03) 1.467530029(偏差:0.2) 0.94199 0.326492
记录5 4.670023918151855(偏差:3.52) 1.332086914(偏差:0.19) 0.83053 0.316389
记录6 5.077123641967773(偏差:3.8) 1.458367920(偏差:0.18) 0.89947 0.382381
记录7 5.414128303527832(偏差:4.19) 1.424246826(偏差:0.2) 0.90283 0.326239
记录8 3.532886505126953(偏差:2.49) 1.159961914(偏差:0.11) 0.71855 0.339095
记录9 4.925727844238281(偏差:3.69) 1.426151123(偏差:0.19) 0.86423 0.379697
记录10 4.57763671875(偏差:3.51) 1.281541016(偏差:0.22) 0.75535 0.310366
平均偏差值 3.5(去除记录2异常值) 0.188

参考:

WWDC2016 -Optimizing App Startup Time

iOS APP 启动性能优化

优化 App 的启动时间实践 iOS

如何精确度量 iOS App 的启动时间

iOS App 启动过程(二):从 exec() 到 main()

iOS App 启动性能优化

iOS开发之runtime(15):static_init()提升启动速度

一种 hook C++ static initializers 的方法

你可能感兴趣的:(iOS APP启动时间分析)