我们都知道 APP 启动时长对保证用户粘性有很大影响,一款加载时长过长的应该可能会直接被用户放弃,那么 APP启动时究竟做了系统究竟都做了哪些工作呢?下面就让我们一起来探究下
1. 启动类型
作为一个开发者,相信大家都已经了解了热启动和冷启动的差别,故此处仅做简介不再详细介绍。
热启动
当用户按下home键的时候,iOS 的 App 并不会马上被 kill 掉,还会继续保有一些资源。理想情况下,用户点击 App 的图标再次回来的时候,App几乎不需要做什么,就可以还原到退出前的状态,继续为用户服务。这种持续存活的情况下启动 App,我们称为热启动。
冷启动
冷启动就是 App 从不持有任何资源(重新启动/被 kill 掉)一切从头开始启动的过程.
相比较之下,我们应该更关注冷启动的时间,苹果曾在 WWDC 2016大会上曾提到过:APP 启动持续时间因设备而异,400毫秒内是一个较好的启动时长目标,不要让你的启动时间超过20s。
2. 启动流程
首先先简单回顾下启动的整个流程,其实整体上可以分为两大块:pre-main 阶段和 main 阶段,如下图所示:
- pre-main 阶段
【1.1】加载应用的可执行文件(自身App的所有.o文件的集合)
【1.2】加载动态链接器dyld(dynamic loader,是一个专门用来加载动态链接库的库)
【1.3】dyld递归加载应用所有依赖的动态链接库dylib
- main 阶段
【2.1】调用main()
【2.2】调用UIApplicationMain()
【2.3】调用applicationWillFinishLaunching
而其中 pre-main 阶段提到的 images 是泛指如下所示文件类型:
Executanle:应用的主要二进制文件
Dylib:动态链接库(又名 DSO 或 DLL)
Bundle:资源文件,不能被链接的 Dylib,只能在运行时使用 dlopen() 加载
Image:上述三种类型的统称
下面我们来分别介绍一下这主要的两个阶段
2.1 pre-main阶段
pre-main 阶段最主要的工作在于加载可执行文件和动态链接,而其中的各个步骤如下图所示:
2.1.1 Load dylibs
在这一阶段首先dylds会解析应用依赖的动态库,找到其所需的mach-o文件,打开并且读取这些文件并验证其有效性,然后注册代码签名到内核,最后对dylib的每一个segment调用mmap()。
一般情况下,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 遍历所有的内部数据指针,然后为它们添加一个地址偏移值。
2.1.3 Bind
Bind 操作针对那些指向动态库之外的指针,这些指针通过名称绑定。运行时,dylb
通过符号名找到实现该符号的位置,主要是遍历查找符号表,当找到时把值存到该数据指针中。这几乎不会发生页面错误。
2.1.4 Objc
ObjC
是动态语言,可以在运行时通过类名把类实例化,所以在运行时,ObjC
需要维护一张包含所有类与其映射的表格。每个加载类时,在这个全局表格中注册类名。在运行时还会把定义的 Category
插入到方法列表中。
另外Selector
对于 ObjC
是唯一的。
2.1.5 Initializers
调用所有类的 +(void)load
方法,对所有动态库初始化。需要从下到上初始化,因为上层的一些动态库可能依赖于下层的动态库,所以先初始化下层的动态库保证所有的动态库都可以正确初始化。
当所有的动态库初始化完成后,最终调用主 dylib
程序,也就是 main()
。
2.1.6 如何优化?
寻找优化点需要先了解每个步骤的一个时长,这样才能够更有针对点。所以我们先来看下如何获取启动消耗时长。
2.1.6.1 开发环境下时长测量
在开发环境下,我们可以通过配置 Schemes 中的环境变量 DYLD_PRINT_STATISTICS (简略)或 DYLD_PRINT_STATISTICS_DETAILS (详细)为1,可以看到 pre-main 阶段各个步骤消耗时长。
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 相当于加载完成,过程比较清晰,不在赘述。
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 <sys/sysctl.h>
#import <mach/mach.h>
+ (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 启动过程(二):从 exec() 到 main()