iOS APP启动时间分析

作者: 奚山遇白 | 来源:发表于2020-03-26 13:33 被阅读0次

    我们都知道 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 <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 启动性能优化

    优化 App 的启动时间实践 iOS

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

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

    iOS App 启动性能优化

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

    一种 hook C++ static initializers 的方法

    相关文章

      网友评论

        本文标题:iOS APP启动时间分析

        本文链接:https://www.haomeiwen.com/subject/baxcuhtx.html