iOS App冷启动治理:来自美团外卖的实践

1、背景

冷启动时长是App性能的重要指标,做为用户体验的第一道“门”,直接决定着用户对App的第一印象。美团外卖iOS客户端从2013年11月开始,历经几十个版本的迭代开发,产品形态不断完善,业务功能日趋复杂;同时外卖App也已经由原来的独立业务App演进成为一个平台App,陆续接入了闪购、跑腿等其余新业务。所以,更多更复杂的工做须要在App冷启动的时候被完成,这给App的冷启动性能带来了挑战。对此,咱们团队基于业务形态的变化和外卖App的特色,对冷启动进行了持续且有针对性的优化工做,目的就是为了呈现更加流畅的用户体验。html

2、冷启动定义

通常而言,你们把iOS冷启动的过程定义为:从用户点击App图标开始到appDelegate didFinishLaunching方法执行完成为止。这个过程主要分为两个阶段:ios

  • T1:main()函数以前,即操做系统加载App可执行文件到内存,而后执行一系列的加载&连接等工做,最后执行至App的main()函数。
  • T2:main()函数以后,即从main()开始,到appDelegate的didFinishLaunchingWithOptions方法执行完毕。

然而,当didFinishLaunchingWithOptions执行完成时,用户尚未看到App的主界面,也不能开始使用App。例如在外卖App中,App还须要作一些初始化工做,而后经历定位、首页请求、首页渲染等过程后,用户才能真正看到数据内容并开始使用,咱们认为这个时候冷启动才算完成。咱们把这个过程定义为T3。git

综上,外卖App把冷启动过程定义为:__从用户点击App图标开始到用户能看到App主界面内容为止这个过程,即T1+T2+T3。__在App冷启动过程中,这三个阶段中的每一个阶段都存在不少能够被优化的点。github

3、问题现状

性能存量问题

美团外卖iOS客户端通过几十个版本的迭代开发后,在冷启动过程当中已经积累了若干性能问题,解决这些性能瓶颈是冷启动优化工做的首要目标,这些问题主要包括:swift

注:启动项的定义,在App启动过程当中须要被完成的某项工做,咱们称之为一个启动项。例如某个SDK的初始化、某个功能的预加载等。缓存

性能增量问题

通常状况下,在App早期阶段,冷启动不会有明显的性能问题。冷启动性能问题也不是在某个版本忽然出现的,而是随着版本迭代,App功能愈来愈复杂,启动任务愈来愈多,冷启动时间也一点点延长。最后当咱们注意到,并想要优化它的时候,这个问题已经变得很棘手了。外卖App的性能问题增量主要来自启动项的增长,随着版本迭代,启动项任务简单粗暴地堆积在启动流程中。若是每一个版本冷启动时间增长0.1s,那么几个版本下来,冷启动时长就会明显增长不少。bash

4、治理思路

冷启动性能问题的治理目标主要有三个:网络

  1. 解决存量问题:优化当前性能瓶颈点,优化启动流程,缩短冷启动时间。
  2. 管控增量问题:冷启动流程规范化,经过代码范式和文档指导后续冷启动过程代码的维护,控制时间增量。
  3. 完善监控:完善冷启动性能指标监控,收集更详细的数据,及时发现性能问题。

5、规范启动流程

截止至2017年末,美团外卖用户数已达2.5亿,而美团外卖App也已完成了从支撑单一业务的App到支持多业务的平台型App的演进(美团外卖iOS多端复用的推进、支撑与思考),公司的一些新兴业务也陆续集成到外卖App当中。下面是外卖App的架构图,外卖的架构主要分为三层,底层是基础组件层,中层是外卖平台层,平台层向下管理基础组件,向上为业务组件提供统一的适配接口,上层是基础组件层,包括外卖业务拆分的子业务组件(外卖App和美团App中的外卖频道能够复用子业务组件)和接入的其余非外卖业务。session

App的平台化为业务方提供了高效、标准的统一平台,但与此同时,平台化和业务的快速迭代也给冷启动带来了问题:多线程

  1. 现有的启动项堆积严重,拖慢启动速度。
  2. 新的启动项缺少添加范式,杂乱无章,修改风险大,难以阅读和维护。

面对这个问题,咱们首先梳理了目前启动流程中全部的启动项,而后针对App平台化设计了新的启动项管理方式:分阶段启动和启动项自注册

分阶段启动

早期因为业务比较简单,全部启动项都是不加以区分,简单地堆积到didFinishLaunchingWithOptions方法中,但随着业务的增长,愈来愈多的启动项代码堆积在一块儿,性能较差,代码臃肿而混乱。

经过对SDK的梳理和分析,咱们发现启动项也须要根据所完成的任务被分类,有些启动项是须要刚启动就执行的操做,如Crash监控、统计上报等,不然会致使信息收集的缺失;有些启动项须要在较早的时间节点完成,例如一些提供用户信息的SDK、定位功能的初始化、网络初始化等;有些启动项则能够被延迟执行,如一些自定义配置,一些业务服务的调用、支付SDK、地图SDK等。咱们所作的分阶段启动,首先就是把启动流程合理地划分为若干个启动阶段,而后依据每一个启动项所作的事情的优先级把它们分配到相应的启动阶段,优先级高的放在靠前的阶段,优先级低的放在靠后的阶段。

下面是咱们对美团外卖App启动阶段进行的从新定义,对全部启动项进行的梳理和从新分类,把它们对应到合理的启动阶段。这样作一方面能够推迟执行那些没必要过早执行的启动项,缩短启动时间;另外一方面,把启动项进行归类,方便后续的阅读和维护。而后把这些规则落地为启动项的维护文档,指导后续启动项的新增和维护。

经过上面的工做,咱们梳理出了十几个能够推迟执行的启动项,占全部启动项的30%左右,有效地优化了启动项所占的这部分冷启动时间。

启动项自注册

肯定了启动项分阶段启动的方案后,咱们面对的问题就是如何执行这些启动项。比较容易想到的方案是:在启动时建立一个启动管理器,而后读取全部启动项,而后当时间节点到来时由启动器触发启动项执行。这种方式存在两个问题:

  1. 全部启动项都要预先写到一个文件中(在.m文件import,或用.plist文件组织),这种中心化的写法会致使臃肿的代码,难以阅读维护。
  2. 启动项代码没法复用:启动项没法收敛到子业务库内部,在外卖App和美团App中要重复实现,和外卖App平台化的方向不符。

而咱们但愿的方式是,启动项维护方式可插拔,启动项之间、业务模块之间不耦合,且一次实现可在两端复用。下图是咱们采用的启动项管理方式,咱们称之为启动项的自注册:一个启动项定义在子业务模块内部,被封装成一个方法,而且自声明启动阶段(例如一个启动项A,在独立App中能够声明为在willFinishLaunch阶段被执行,在美团App中则声明在resignActive阶段被执行)。这种方式下,启动项即实现了两端复用,不相关的启动项互相隔离,添加/删除启动项都更加方便。

那么如何给一个启动项声明启动阶段?又如何在正确的时机触发启动项的执行呢?在代码上,一个启动项最终都会对应到一个函数的执行,因此在运行时只要能获取到函数的指针,就能够触发启动项。美团平台开发的组件启动治理基建Kylin正是这样作的:Kylin的核心思想就是在编译时把数据(如函数指针)写入到可执行文件的__DATA段中,运行时再从__DATA段取出数据进行相应的操做(调用函数)。

为何要用借用__DATA段呢?缘由就是为了可以覆盖全部的启动阶段,例如main()以前的阶段。

Kylin实现原理简述:Clang 提供了不少的编译器函数,它们能够完成不一样的功能。其中一种就是 section() 函数,section()函数提供了二进制段的读写能力,它能够将一些编译期就能够肯定的常量写入数据段。 在具体的实现中,主要分为编译期和运行时两个部分。在编译期,编译器会将标记了 attribute((section())) 的数据写到指定的数据段中,例如写一个{key(key表明不一样的启动阶段), *pointer}对到数据段。到运行时,在合适的时间节点,在根据key读取出函数指针,完成函数的调用。

上述方式,能够封装成一个宏,来达到代码的简化,以调用宏 KLN_STRINGS_EXPORT("Key", "Value")为例,最终会被展开为:

__attribute__((used, section("__DATA" "," "__kylin__"))) static const KLN_DATA __kylin__0 = (KLN_DATA){(KLN_DATA_HEADER){"Key", KLN_STRING, KLN_IS_ARRAY}, "Value"};
复制代码

使用示例,编译器把启动项函数注册到启动阶段A:

KLN_FUNCTIONS_EXPORT(STAGE_KEY_A)() { // 在a.m文件中,经过注册宏,把启动项A声明为在STAGE_KEY_A阶段执行
    // 启动项代码A
}
复制代码
KLN_FUNCTIONS_EXPORT(STAGE_KEY_A)() { // 在b.m文件中,把启动项B声明为在STAGE_KEY_A阶段执行
    // 启动项代码B
}
复制代码

在启动流程中,在启动阶段STAGE_KEY_A触发全部注册到STAGE_KEY_A时间节点的启动项,经过对这种方式,几乎没有任何额外的辅助代码,咱们用一种很简洁的方式完成了启动项的自注册。

- (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions {
    // 其余逻辑
    [[KLNKylin sharedInstance] executeArrayForKey:STAGE_KEY_A];  // 在此触发全部注册到STAGE_KEY_A时间节点的启动项
    // 其余逻辑
    return YES;
}
复制代码

完成对现有的启动项的梳理和优化后,咱们也输出了后续启动项的添加&维护规范,规范后续启动项的分类原则,优先级和启动阶段。目的是管控性能问题增量,保证优化成果。

6、优化main()以前

在调用main()函数以前,基本全部的工做都是由操做系统完成的,开发者可以插手的地方很少,因此若是想要优化这段时间,就必须先了解一下,操做系统在main()以前作了什么。main()以前操做系统所作的工做就是把可执行文件(Mach-O格式)加载到内存空间,而后加载动态连接库dyld,再执行一系列动态连接操做和初始化操做的过程(加载、绑定、及初始化方法)。这方面的资料网上比较多,但重复性较高,此处附上一篇WWDC的Topic:Optimizing App Startup Time

加载过程—从exec()到main()

真正的加载过程从exec()函数开始,exec()是一个系统调用。操做系统首先为进程分配一段内存空间,而后执行以下操做:

  1. 把App对应的可执行文件加载到内存。
  2. 把Dyld加载到内存。
  3. Dyld进行动态连接。

下面咱们简要分析一下Dyld在各阶段所作的事情:

阶段 工做
加载动态库 Dyld从主执行文件的header获取到须要加载的所依赖动态库列表,而后它须要找到每一个 dylib,而应用所依赖的 dylib 文件可能会再依赖其余 dylib,因此所须要加载的是动态库列表一个递归依赖的集合
Rebase和Bind - Rebase在Image内部调整指针的指向。在过去,会把动态库加载到指定地址,全部指针和数据对于代码都是对的,而如今地址空间布局是随机化,因此须要在原来的地址根据随机的偏移量作一下修正
- Bind是把指针正确地指向Image外部的内容。这些指向外部的指针被符号(symbol)名称绑定,dyld须要去符号表里查找,找到symbol对应的实现
Objc setup - 注册Objc类 (class registration)
- 把category的定义插入方法列表 (category registration)
- 保证每个selector惟一 (selector uniquing)
Initializers - Objc的+load()函数
- C++的构造函数属性函数
- 非基本类型的C++静态全局变量的建立(一般是类或结构体)

最后 dyld 会调用 main() 函数,main() 会调用 UIApplicationMain(),before main()的过程也就此完成。

了解完main()以前的加载过程后,咱们能够分析出一些影响T1时间的因素:

  1. 动态库加载越多,启动越慢。
  2. ObjC类,方法越多,启动越慢。
  3. ObjC的+load越多,启动越慢。
  4. C的constructor函数越多,启动越慢。
  5. C++静态对象越多,启动越慢。

针对以上几点,咱们作了以下一些优化工做:

代码瘦身

随着业务的迭代,不断有新的代码加入,同时也会废弃掉无用的代码和资源文件,可是工程中常常有无用的代码和文件被遗弃在角落里,没有及时被清理掉。这些无用的部分一方面增大了App的包体积,另外一方便也拖慢了App的冷启动速度,因此及时清理掉这些无用的代码和资源十分有必要。

经过对Mach-O文件的了解,能够知道__TEXT:__objc_methname:中包含了代码中的全部方法,而__DATA__objc_selrefs中则包含了全部被使用的方法的引用,经过取两个集合的差集就能够获得全部未被使用的代码。核心方法以下,具体能够参考:objc_cover:

def referenced_selectors(path):
    re_sel = re.compile("__TEXT:__objc_methname:(.+)") //获取全部方法
    refs = set()
    lines = os.popen("/usr/bin/otool -v -s __DATA __objc_selrefs %s" % path).readlines() # ios & mac //真正被使用的方法
    for line in lines:
        results = re_sel.findall(line)
        if results:
            refs.add(results[0])
    return refs
}
复制代码

经过这种方法,咱们排查了十几个无用类和250+无用的方法。

+load优化

目前iOS App中或多或少的都会写一些+load方法,用于在App启动执行一些操做,+load方法在Initializers阶段被执行,但过多+load方法则会拖慢启动速度,对于大中型的App更是如此。经过对App中+load的方法分析,发现不少代码虽然须要在App启动时较早的时机进行初始化,但并不须要在+load这样很是靠前的位置,彻底是能够延迟到App冷启动后的某个时间节点,例如一些路由操做。其实+load也能够被当作一种启动项来处理,因此在替换+load方法的具体实现上,咱们仍然采用了上面的Kylin方式。

使用示例:

// 用WMAPP_BUSINESS_INIT_AFTER_HOMELOADING声明替换+load声明便可,不需其余改动
WMAPP_BUSINESS_INIT_AFTER_HOMELOADING() { 
    // 原+load方法中的代码
}
复制代码
// 在某个合适的时机触发注册到该阶段的全部方法,如冷启动结束后
[[KLNKylin sharedInstance] executeArrayForKey:@kWMAPP_BUSINESS_INITIALIZATION_AFTER_HOMELOADING_KEY] 
}
复制代码

7、优化耗时操做

在main()以后主要工做是各类启动项的执行(上面已经叙述),主界面的构建,例如TabBarVC,HomeVC等等。资源的加载,如图片I/O、图片解码、archive文档等。这些操做中可能会隐含着一些耗时操做,靠单纯阅读很是难以发现,如何发现这些耗时点呢?找到合适的工具就会事半功倍。

Time Profiler

Time Profiler是Xcode自带的时间性能分析工具,它按照固定的时间间隔来跟踪每个线程的堆栈信息,经过统计比较时间间隔之间的堆栈状态,来推算某个方法执行了多久,并得到一个近似值。Time Profiler的使用方法网上有不少使用教程,这里咱们也不过多介绍,附上一篇使用文档:Instruments Tutorial with Swift: Getting Started

火焰图

除了Time Profiler,火焰图也是一个分析CPU耗时的利器,相比于Time Profiler,火焰图更加清晰。火焰图分析的产物是一张调用栈耗时图片,之因此称为火焰图,是由于整个图形看起来就像一团跳动的火焰,火焰尖部是调用栈的栈顶,底部是栈底,纵向表示调用栈的深度,横向表示消耗的时间。一个格子的宽度越大,越说明其多是瓶颈。分析火焰图主要就是看那些比较宽大的火苗,特别留意那些相似“平顶山”的火苗。下面是美团平台开发的性能分析工具-Caesium的分析效果图:

经过对火焰图的分析,咱们发现了冷启动过程当中存在着很多问题,并成功优化了0.3S+的时间。优化内容总结以下:

优化点 举例
发现隐晦的耗时操做 发如今冷启动过程当中archive了一张图片,很是耗时
推迟&减小I/O操做 减小动画图片组的数量,替换大图资源等。由于相比于内存操做,硬盘I/O是很是耗时的操做
推迟执行的一些任务 如一些资源的I/O,一些布局逻辑,对象的建立时机等

8、优化串行操做

在冷启动过程当中,有不少操做是串行执行的,若干个任务串行执行,时间必然比较长。若是能变串行为并行,那么冷启动时间就可以大大缩短。

闪屏页的使用

如今许多App在启动时并不直接进入首页,而是会向用户展现一个持续一小段时间的闪屏页,若是使用恰当,这个闪屏页就能帮咱们节省一些启动时间。由于当一个App比较复杂的时候,启动时首次构建App的UI就是一个比较耗时的过程,假定这个时间是0.2秒,若是咱们是先构建首页UI,而后再在Window上加上这个闪屏页,那么冷启动时,App就会实实在在地卡住0.2秒,可是若是咱们是先把闪屏页做为App的RootViewController,那么这个构建过程就会很快。由于闪屏页只有一个简单的ImageView,而这个ImageView则会向用户展现一小段时间,这时咱们就能够利用这一段时间来构建首页UI了,一箭双雕。

缓存定位&首页预请求

美团外卖App冷启动过程当中一个重要的串行流程就是:首页定位-->首页请求-->首页渲染过程,这三个操做占了整个首页加载时间的77%左右,因此想要缩短冷启动时间,就必定要从这三点出发进行优化。

以前串行操做流程以下:

优化后的设计,在发起定位的同时,使用客户端缓存定位,进行首页数据的预请求,使定位和请求并行进行。而后当用户真实定位成功后,判断真实定位是否命中缓存定位,若是命中,则刚才的预请求数据有效,这样能够节省大概40%的时间首页加载时间,效果很是明显;若是未命中,则弃用预请求数据,从新请求。

9、数据监控

Time Profiler和Caesium火焰图都只能在线下分析App在单台设备中的耗时操做,局限性比较大,没法在线上监控App在用户设备上的表现。外卖App使用公司内部自研的Metrics性能监控系统,长期监控App的性能指标,帮助咱们掌握App在线上各类环境下的真实表现,并为技术优化项目提供可靠的数据支持。Metrics监控的核心指标之一,就是冷启动时间。

冷启动开始&结束时间节点

  1. 结束时间点:结束时间比较好肯定,咱们能够将首页某些视图元素的展现做为首页加载完成的标志。
  2. 开始时间点:通常状况下,咱们都是在main()以后才开始接管App,但以main()函数做为冷启动起始点显然不合适,由于这样没法统计到T1时间段。那么,起始时间如何肯定呢?目前业界常见的有两种方法,一是以可执行文件中任意一个类的+load方法的执行时间做为起始点;二是分析dylib的依赖关系,找到叶子节点的dylib,而后以其中某个类的+load方法的执行时间做为起始点。根据Dyld对dylib的加载顺序,后者的时机更早。可是这两种方法获取的起始点都只在Initializers阶段,而Initializers以前的时长都没有被计入。Metrics则另辟蹊径,以App的进程建立时间(即exec函数执行时间)做为冷启动的起始时间。由于系统容许咱们经过sysctl函数得到进程的有关信息,其中就包括进程建立的时间戳。
#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;
    }
}
复制代码

进程建立的时机很是早。通过实验,在一个新建的空白App中,进程建立时间比叶子节点dylib中的+load方法执行时间早12ms,比main函数的执行时间早13ms(实验设备:iPhone 7 Plus (iOS 12.0)、Xcode 10.0、Release 模式)。外卖App线上的数据则更加明显,一样的机型(iPhone 7 Plus)和系统版本(iOS 12.0),进程建立时间比叶子节点dylib中的+load方法执行时间早688ms。而在所有机型和系统版本中,这一数据则是878ms。

冷启动过程时间节点

咱们也在App冷启动过程当中的全部关键节点打上一连串测速点,Metrics会记录下测速点的名称,及其距离进程建立时间的时长。咱们没有采用自动打点的方式,是由于外卖App的冷启动过程十分复杂,而自动打点没法作到如此细致,并不实用。另外,Metrics记录的是时间轴上以进程建立时间为原点的一组顺序的时间点,而不是一组时间段,是由于顺序的时间点能够计算任意两个时间点之间的距离,便可以将时间点处理成时间段。可是,一组时间段可能没法还原为顺序的时间点,由于时间段之间可能并非首尾相接的,特别是对于异步执行或者多线程的状况。

在测速完毕后,Metrics会统一将全部测速点上报到后台。下图是美团外卖App 6.10版本的部分过程节点监控数据截图:

Metrics还会由后台对数据作聚合计算,获得冷启动总时长和各个测速点时长的50分位数、90分位数和95分位数的统计数据,这样咱们就能从宏观上对冷启动时长分布状况有所了解。下图中横轴为时长,纵轴为上报的样本数。

10、总结

对于快速迭代的App,随着业务复杂度的增长,冷启动时长会不可避免的增长。冷启动流程也是一个比较复杂的过程,当遇到冷启动性能瓶颈时,咱们能够根据App自身的特色,配合工具的使用,从多方面、多角度进行优化。同时,优化冷启动存量问题只是冷启动治理的第一步,由于冷启动性能问题并非一日形成的,也不能简单的经过一次优化工做就能解决,咱们须要经过合理的设计、规范的约束,来有效地管控性能问题的增量,并经过持续的线上监控来及时发现并修正性能问题,这样才可以长期保证良好的App冷启动体验。

做者简介

郭赛,美团点评资深工程师。2015年加入美团,目前做为外卖iOS团队主力开发,负责移动端业务开发,业务类基础设施的建设与维护。

徐宏,美团点评资深工程师。2016年加入美团,目前做为外卖iOS团队主力开发,负责移动端APM性能监控,高可用基础设施支撑相关推动工做。

招聘

美团外卖长期招聘Android、iOS、FE高级/资深工程师和技术专家,Base北京、上海、成都,欢迎有兴趣的同窗投递简历到chenhang03@meituan.com。