iOS深思篇 | 启动时间的度量和优化

8,517 阅读8分钟

一. 简介

App的启动时间是衡量一个App性能的重要指标,或者可以说是App性能的第一印象。在这篇文章中,我们将要介绍启动时间的相关知识和打点统计。

二. 启动优化

2.1 App启动方式

首先了解一下App的启动方式分为两类:

1. 冷启动:从零开始启动App
2. 热启动:App已经存在内存当中,但是后台存活着,再次点击图标启动App

之后测试也依照这两种启动方式进行测试。一般来说启动时间(点击图标 -> 显示Launch Screen -> Launch Screen消失)在小于400ms是最佳的,并且系统限制了启动时间不可以大于20s,否则会因为watchdog(看门狗)机制被杀掉。 在不同的生命周期时,超时时间的限制会有所差别:

生命周期 超时时间
启动 Launch 20 s
恢复 Resume 10 s
悬挂 Suspend 10 s
退出 Quit 6 s
后台 Background 10 min

2.2 App启动流程

启动流程一般划分为pre-main(main函数之前)和main函数之后;

2.2.1 pre-main

以上是Apple展示的pre-main阶段

该阶段各个时期的任务以及优化方法:

阶段 工作 优化
Load dylibs Dyld从主执行文件的header获取到需要加载的所依赖动态库列表,然后它需要找到每个 dylib,而应用所依赖的 dylib 文件可能会再依赖其他 dylib,所以所需要加载的是动态库列表一个递归依赖的集合 1.尽量不使用内嵌(embedded)的dylib,加载内嵌dylib性能开销较大;2.合并已有的dylib和使用静态库(static archives),减少dylib的使用个数;3.懒加载dylib,但是要注意dlopen()可能造成一些问题,且实际上懒加载做的工作会更多
Rebase和Bind 1. Rebase在Image内部调整指针的指向。在过去,会把动态库加载到指定地址,所有指针和数据对于代码都是对的,而现在地址空间布局是随机化,所以需要在原来的地址根据随机的偏移量做一下修正。2. Bind是把指针正确地指向Image外部的内容。这些指向外部的指针被符号(symbol)名称绑定,dyld需要去符号表里查找,找到symbol对应的实现 1.减少ObjC类(class)、方法(selector)、分类(category)的数量;2.减少C++虚函数的的数量(创建虚函数表有开销);3.使用Swift structs(内部做了优化,符号数量更少)
Objc setup 1.注册Objc类 (class registration);2.把category的定义插入方法列表 (category registration);3.保证每一个selector唯一 (selector uniquing) 减少 Objective-C Class、Selector、Category 的数量,可以合并或者删减一些OC类
Initializers 1.Objc的+load()函数;2.C++的构造函数属性函数;3.非基本类型的C++静态全局变量的创建(通常是类或结构体) 1.少在类的+load方法里做事情,尽量把这些事情推迟到+initiailize;2.减少构造器函数个数,在构造器函数里少做些事情;3.减少C++静态全局变量的个数

对于pre-main阶段,Xcode提供了各个阶段时间消耗的方法, Product -> Scheme -> Edit Scheme -> Environment Variables 中将环境变量 DYLD_PRINT_STATISTICS设为1;

Total pre-main time: 955.81 milliseconds (100.0%)
         dylib loading time:  97.42 milliseconds (10.1%)
        rebase/binding time:  55.08 milliseconds (5.7%)
            ObjC setup time:  68.65 milliseconds (7.1%)
           initializer time: 734.45 milliseconds (76.8%)
           slowest intializers :
             libSystem.B.dylib :   7.65 milliseconds (0.8%)
    libMainThreadChecker.dylib :  36.33 milliseconds (3.8%)
                              ...

这里额外补充一下其他的dyld环境变量参数:

变量 描述
DYLD_PRINT_STATISTICS_DETAILS 打印启动时间等详细参数
DYLD_PRINT_SEGMENTS 日志段映射
DYLD_PRINT_INITIALIZERS 日志图像初始化要求
DYLD_PRINT_BINDINGS 日志符号绑定
DYLD_PRINT_APIS 日志dyld API调用(例如,dlopen)
DYLD_PRINT_ENV 打印启动环境变量
DYLD_PRINT_OPTS 打印启动时命令行参数
DYLD_PRINT_LIBRARIES_POST_LAUNCH 日志库加载,但仅在main运行之后
DYLD_PRINT_LIBRARIES 日志库加载
DYLD_IMAGE_SUFFIX 首先搜索带有这个后缀的库

这个方法确实很方便,但是我们如果想要自己度量per-main阶段的时间消耗,又如何统计呢? 由于我们主要针对冷启动进行优化,就先介绍一下冷启动的流程:

冷启动的流程

可以将其归纳为三个阶段:

1. dyld:加载镜像,动态库
2. RunTime方法
3. main函数初始化

从图中可以看出开发者在main之前可以处理的是Run Image Initializers阶段(对应Apple展示图中的initializers阶段),load加载、__attribute__((constructor))和C++静态对象初始化;

load耗时监测

想知道load方法执行的时间,就不可避免的需要获取+load类和分类的方法。目前我了解到的也是有两种,一种是通过runtime api,去读取对应镜像下所有类及其元类,并逐个遍历元类的实例方法,如果方法名称为load,则执行hook操作,代表库是AppStartTime;一种是和 runtime一样,直接通过getsectiondata函数,读取编译时期写入mach-o文件DATA段的__objc_nlclslist__objc_nlcatlist节,这两节分别用来保存no lazy class列表和no lazy category列表,所谓的no lazy结构,就是定义了+load方法的类或分类,代表库是A4LoadMeasure

先说一下两种方案对比结果:

load 方案对比

load误差 统计范围
AppStartTime 100ms左右
A4LoadMeasure 50ms左右 类和分类

从测试结果来看,当然我们会选择后者,还统计了分类load加载。而且从性能上看,前者会for循环调用object_getClass()方法,该方法会触发类的realize 操作,给类开辟可读写的信息存储空间、调整成员变量布局、插入分类方法属性等操作,简单来说就是让类变成可用(realized)状态,这样当有大量的类进行该操作时,会额外增加per-main时间,造成不必要的开销。

//获取no lazy class 列表和 no lazy category 列表
static NSArray <LMLoadInfo *> *getNoLazyArray(const struct mach_header *mhdr) {
    NSMutableArray *noLazyArray = [NSMutableArray new];
    unsigned long bytes = 0;
    Class *clses = (Class *)getDataSection(mhdr, "__objc_nlclslist", &bytes);
    for (unsigned int i = 0; i < bytes / sizeof(Class); i++) {
        LMLoadInfo *info = [[LMLoadInfo alloc] initWithClass:clses[i]];
        if (!shouldRejectClass(info.clsname)) [noLazyArray addObject:info];
    }
    
    bytes = 0;
    Category *cats = getDataSection(mhdr, "__objc_nlcatlist", &bytes);
    for (unsigned int i = 0; i < bytes / sizeof(Category); i++) {
        LMLoadInfo *info = [[LMLoadInfo alloc] initWithCategory:cats[i]];
        if (!shouldRejectClass(info.clsname)) [noLazyArray addObject:info];
    }
    
    return noLazyArray;
}

//hook 类和分类的 +load 方法
static void hookAllLoadMethods(LMLoadInfoWrapper *infoWrapper) {
    unsigned int count = 0;
    Class metaCls = object_getClass(infoWrapper.cls);
    Method *methodList = class_copyMethodList(metaCls, &count);
    for (unsigned int i = 0, j = 0; i < count; i++) {
        Method method = methodList[i];
        SEL sel = method_getName(method);
        const char *name = sel_getName(sel);
        if (!strcmp(name, "load")) {
            LMLoadInfo *info = nil;
            if (j > infoWrapper.infos.count - 1) {
                info = [[LMLoadInfo alloc] initWithClass:infoWrapper.cls];
                [infoWrapper insertLoadInfo:info];
                LMAllLoadNumber++;
            } else {
                info = infoWrapper.infos[j];
            }
            ++j;
            swizzleLoadMethod(infoWrapper.cls, method, info);
        }
    }
    free(methodList);
}

Tip: 这里说明一个问题,A4LoadMeasureLMAllLoadNumber定位最后一次打印有计算误差,稍微取巧了一下,改为在主线程获取,具体可查看demo;

attribute((constructor))和C++对象静态初始化

__attribute__是GNU C特色的一个编译器属性,可以通过iOS attribute了解一下;它与load,main,initialize的调用顺序如下:

load -> attribute((constructor)) -> main -> initialize

好了,接下来,我们再次对比下这两个三方库:

initialize 方案对比

static initialize误差
AppStartTime 30ms左右
A4LoadMeasure 40ms左右

统计下来,两者数据差不多,最主要的是打印了方法指针;A4LoadMeasure统计的方案我不敢苟同,只是在__attribute__((constructor))方法作用域前后打点就是C++ Static Initializers端所用时间?这波儿操作看不懂了;获取__mod_init_func(初始化的全局函数地址)段更值得认同;

简单介绍下初始化函数大致执行顺序如下:

initializeMainExecutable -> ImageLoader::runInitializers -> ImageLoader::doInitialization -> ImageLoaderMachO::doModInitFunctions

最后一个函数是主要处理的逻辑,下面👇附上代码:

//该函数主要负责处理__DATA下的__mod_init_func
void ImageLoaderMachO::doModInitFunctions(const LinkContext& context)
{
    if ( fHasInitializers ) {
        const uint32_t cmd_count = ((macho_header*)fMachOData)->ncmds;
        const struct load_command* const cmds = (struct load_command*)&fMachOData[sizeof(macho_header)];
        const struct load_command* cmd = cmds;
        for (uint32_t i = 0; i < cmd_count; ++i) {
            if ( cmd->cmd == LC_SEGMENT_COMMAND ) {
                const struct macho_segment_command* seg = (struct macho_segment_command*)cmd;
                const struct macho_section* const sectionsStart = (struct macho_section*)((char*)seg + sizeof(struct macho_segment_command));
                const struct macho_section* const sectionsEnd = &sectionsStart[seg->nsects];
                for (const struct macho_section* sect=sectionsStart; sect < sectionsEnd; ++sect) {
                    const uint8_t type = sect->flags & SECTION_TYPE;
                    if ( type == S_MOD_INIT_FUNC_POINTERS ) {
                        Initializer* inits = (Initializer*)(sect->addr + fSlide);
                        const size_t count = sect->size / sizeof(uintptr_t);
                        
                        for (size_t j=0; j < count; ++j) {
                            Initializer func = inits[j];
                            // <rdar://problem/8543820&9228031> verify initializers are in image
                            if ( ! this->containsAddress((void*)func) ) {
                                dyld::throwf("initializer function %p not in mapped image for %s\n", func, this->getPath());
                            }
                        
                            func(context.argc, context.argv, context.envp, context.apple, &context.programVars);
                        }
                    }
                }
            }
            cmd = (const struct load_command*)(((char*)cmd)+cmd->cmdsize);
        }
    }
}

这里有一个问题说明下,原文也提到了,if ( ! this->containsAddress((void*)func) )这个判断函数地址是否在当前image的地址空间中,由于我们是在动态库中做函数地址替换,替换后的函数地址都是动态库中的了,没有在其他 image中,所以当其他image执行到这个判断时,就抛出了异常。在demo工程中这个现象还不明显,当工程架构复杂一些,这个问题就比较明显了;

三. 工程说明

目前工程已支持pod引入:

pod 'A0PreMainTime'

#******子组件单独引入***********
#pre-main阶段耗时检测
pod 'A0PreMainTime/PreMainTime'
#业务时间度量
pod 'A0PreMainTime/TimeMonitor'

具体请查看A0PreMainTime

学习:

计算 +load 方法的耗时

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

App启动时间优化

iOS启动时间优化

手淘iOS性能优化探索

美团外卖iOS App冷启动治理

扩展:

dyld详解

提交到AppStore问题:不支持的体系结构x86