当前位置: 首页 > 科技观察

美团外卖iOSApp冷启动管理

时间:2023-03-19 13:48:44 科技观察

1.后台冷启动时长是衡量App性能的重要指标。作为用户体验的第一道“大门”,直接决定了用户对App的第一印象。美团外卖iOS客户端于2013年11月上线,历经数十个版本迭代开发,产品形态不断完善,业务功能日趋复杂。同时,外卖APP也从独立的商务APP发展为平台APP。访问其他新服务,例如闪购和跑腿。因此,App在冷启动时需要完成越来越复杂的任务,这给App的冷启动性能带来了挑战。对此,我们团队根据业务形态的变化和外卖APP的特点,对冷启动进行了持续、针对性的优化,以呈现更流畅的用户体验。2、冷启动的定义一般来说,大家对iOS冷启动的流程定义为:从用户点击App图标开始,到appDelegatedidFinishLaunching方法执行完毕。这个过程主要分为两个阶段:T1:在main()函数之前,操作系统将App的可执行文件加载到内存中,然后执行一系列加载&链接任务,最后执行App的main()函数。T2:main()函数之后,即从main()到appDelegate的didFinishLaunchingWithOptions方法完成。但是当didFinishLaunchingWithOptions执行完成后,用户还没有看到App的主界面,也无法开始使用App。例如,在一个外卖应用中,应用还需要做一些初始化工作,经过定位、首页请求、首页渲染等过程,用户才能真正看到数据内容并开始使用。我们认为此时冷启动已经完成。我们将这个过程定义为T3。综上所述,外卖APP将冷启动流程定义为用户点击APP图标到用户可以看到APP主界面内容的流程,即T1+T2+T3。在App冷启动过程中,这三个阶段都有很多可以优化的点。3、问题状态,性能盘点问题,美团外卖iOS客户端几十个版本后,在冷启动过程中积累了几个性能问题。解决这些性能瓶颈是冷启动优化工作的首要目标。这些问题主要包括:注:启动项的定义,App启动过程中需要完成的某项工作,我们称之为启动项。比如某个SDK的初始化,某个功能的预加载等等。性能增量问题一般在app的早期阶段,冷启动不会出现明显的性能问题。冷启动性能问题并不是某个版本突然出现的,而是随着版本的迭代,app功能越来越复杂,启动任务越来越多,冷启动时间也一点点延长。当我们最终注意到并想要对其进行优化时,问题已经变得棘手了。外卖APP的性能提升主要是因为启动项的增加。随着版本的迭代,启动项任务简单粗暴地堆放在启动过程中。如果每个版本的冷启动时间增加0.1s,那么经过几个版本之后,冷启动时间会明显增加。4.治理思路冷启动性能问题的治理目标主要有3个:解决存量问题:优化当前性能瓶颈,优化启动流程,缩短冷启动时间。增量问题管控:规范冷启动流程,通过代码范式和文档指导后续冷启动流程代码的维护,控制时间增量。完善监控:完善冷启动性能指标的监控,收集更详细的数据,及时发现性能问题。5、规范创业流程截至2017年底,美团外卖用户规模已达2.5亿,美团外卖APP也完成了从支持单一业务的APP到支持多种业务的平台APP的演变(美团外卖iOS多终端复用的推广、支持和思考),公司的一些新兴业务也被整合到外卖APP中。下面是外卖app的架构图。外卖架构主要分为三层。底层为基础组件层,中间层为外卖平台层,平台层向下管理基础组件,向上为业务组件提供统一的适配接口。上层为基础组件层,包括外卖业务拆分的子业务组件(美团App中的外卖APP和外卖渠道可以复用子业务组件)和其他非外卖服务连接的。App的平台化为业务端提供了一个高效、规范的统一平台,但同时,平台化和业务的快速迭代也给冷启动带来了问题:现有启动项积累严重,速度变慢启动速度。新的启动项缺乏添加的范式,杂乱无章,修改风险大,难以阅读和维护。面对这个问题,我们首先梳理了当前启动过程中的所有启动项,然后为App平台化设计了一种新的启动项管理方式:阶段性启动和启动项自注册堆积在didFinishLaunchingWithOptions方法中,但是随着业务的增多,越来越多的启动项代码堆积在一起,性能很差,代码臃肿混乱。通过梳理和分析SDK,我们发现启动项也需要按照完成的任务进行分类。有些启动项需要在启动后立即执行,比如Crash监控、统计上报等,否则会导致信息采集不到位;有些启动项需要在更早的时间节点完成,比如一些提供用户信息的SDK、定位功能的初始化、网络初始化等;有些启动项可以延迟,比如一些自定义配置,一些业务服务调用,PaymentSDK,mapSDK等。我们做的分阶段启动就是把启动过程合理的分成几个启动阶段,然后分配给对应的启动阶段根据每个启动项的优先级。在较早的阶段,优先级较低的放在较晚的阶段。下面是我们对美团外卖APP启动阶段的重新定义,对所有启动项进行梳理和重新分类,映射到一个合理的启动阶段。一方面,这样可以延迟那些不需要提前执行的启动项的执行,缩短启动时间;另一方面可以对启动项进行分类,方便后续的阅读和维护。然后将这些规则放入启动项的维护文档中,以指导后续启动项的添加和维护。通过以上工作,我们整理出了十几个可以延迟的启动项,约占所有启动项的30%,有效优化了启动项占用冷启动时间的部分。启动项自注册在确定了启动项的阶段性启动方案后,我们面临的问题就是如何执行这些启动项。比较容易想到的方案是:在启动时创建一个startupmanager,然后读取所有的启动项,然后当时间节点到来时,launcher触发启动项的执行。这种方法有两个问题:所有的启动项都必须预先写到一个文件中(导入到.m文件中,或者组织到.plist文件中)。这种集中式的编写方式会导致代码臃肿,难以阅读和维护。启动项代码无法复用:启动项无法汇聚到子业务库中,需要在外卖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__")))staticconstKLN_DATA__kylin__0=(KLN_DATA){(KLN_DATA_HEADER){"Key",KLN_STRING,KLN_IS_ARRAY},"Value"};举个例子,编译器将启动项函数注册到启动阶段A:KLN_FUNCTIONS_EXPORT(STAGE_KEY_A)(){//在a.m文件中,通过注册一个宏,声明要在STAGE_KEY_A阶段执行的启动项A//StartupitemcodeA}KLN_FUNCTIONS_EXPORT(STAGE_KEY_A)(){//在b.m文件中,在STAGE_KEY_A阶段声明启动项B为Execute在启动过程中,STAGE_KEY_A会触发所有在启动阶段注册到STAGE_KEY_A时间节点的启动项。这样,几乎没有额外的辅助代码,非常简洁的完成了启动项。自助注册。-(BOOL)application:(UIApplication*)applicationdidFinishLaunchingWithOptions:(NSDictionary*)launchOptions{//其他逻辑[[KLNKylinsharedInstance]executeArrayForKey:STAGE_KEY_A];//这里触发注册到STAGE_KEY_A时间节点的所有启动项//其他逻辑返回YES;}在对现有启动项进行梳理和优化后,我们也输出了后续启动项的添加维护规范,规范了后续启动项的分类原则、优先级和启动阶段-上项目。目的是控制性能问题的增量,保证优化结果。6、优化main()之前在调用main()函数之前,基本上所有的工作都是由操作系统完成的,开发者可以介入的地方并不多,所以这个时候如果要优化,首先要了解,操作系统在main()之前做了什么。操作系统在main()之前所做的工作是将可执行文件(Mach-O格式)加载到内存空间,然后加载动态链接库dyld,然后进行一系列的动态链接操作和初始化操作(loading、绑定和初始化方法)。网上有很多这方面的资料,但是重复率很高。这是一个WWDC主题:优化应用程序启动时间。加载过程——从exec()到main()真正的加载过程是从exec()函数开始的,exec()是一个系统调用。操作系统首先为进程分配一段内存空间,然后进行如下操作:将App对应的可执行文件加载到内存中。将Dyld加载到内存中。Dyld用于动态链接。下面简单分析一下dyld在每个阶段做了什么:最后dyld会调用main()函数,main()会调用UIApplicationMain(),main()之前的流程就完成了。了解了main()之前的加载过程,我们可以分析一些影响T1时间的因素:加载的动态库越多,启动越慢。ObjC类,方法越多,启动越慢。ObjC的+load越多,启动越慢。C中的构造函数越多,启动越慢。C++静态对象越多,启动越慢。针对以上几点,我们做了以下优化工作:代码瘦身随着业务的迭代,新的代码不断加入,无用的代码和资源文件也被丢弃,但是经常有无用的代码和文件在项目遗弃在角落里,没有及时清理。这些无用的部分一方面增加了App的包体积,另一方面也拖慢了App的冷启动速度,所以需要及时清理这些无用的代码和资源。通过对Mach-O文件的了解,可以知道__TEXT:__objcmethname:包含了代码中的所有方法,而\_DATA__objc_selrefs包含了所有使用到的方法的引用,可以通过取两组差值得到获取所有未使用的代码。核心方法如下,详见:objc_cover:dereferenced_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//真正用于line的方法inlines:results=re_sel.findall(line)ifresults:refs.add(results[0])returnrefs}通过这个方法,我们检查了十几个无用类和250+个无用方法。+load优化目前iOS应用中或多或少都写了一些+load方法,用于在应用启动时执行一些操作。+load方法在Initializers阶段执行,但是+load方法过多会减慢启动速度。对于大中型应用尤其如此。通过分析App中的+load方法,发现很多代码虽然需要在App启动的更早的时候进行初始化,但是并不需要像+load一样放在很高的位置,可以延迟到App冷启动后。某个时间节点,比如一些路由操作。其实+load也可以当作启动项,所以在替换+load方法的具体实现中,我们还是沿用了上面的Kylin方法。使用示例://将+load语句替换为WMAPP_BUSINESS_INIT_AFTER_HOMELOADING语句,其他不需要改动WMAPP_BUSINESS_INIT_AFTER_HOMELOADING(){//原+load方法中的代码}//在合适的时间触发注册到该阶段的所有方法,例如冷启动后的[[KLNKylinsharedInstance]executeArrayForKey:@kWMAPP_BUSINESS_INITIALIZATION_AFTER_HOMELOADING_KEY]}。第七,优化耗时操作。main()之后,主要工作是执行各种启动项(上面有介绍),构建主界面,比如TabBarVC,HomeVC等。资源的加载,如图片I/O、图片解码、存档文件等。这些操作可能隐含着一些耗时的操作,单纯阅读是很难发现的。如何找到这些耗时点?找到合适的工具会事半功倍。TimeProfilerTimeProfiler是Xcode自带的时间性能分析工具。它按照固定的时间间隔跟踪每个线程的堆栈信息,通过统计比较时间间隔之间的堆栈状态,计算出一个方法执行了多长时间,得到一个近似值。TimeProfiler的使用教程网上有很多,这里就不过多介绍了,附上一份使用文档:InstrumentsTutorialwithSwift:GettingStarted。火焰图除了TimeProfiler,火焰图也是分析CPU耗时的利器。与TimeProfiler相比,火焰图更加清晰。火焰图分析的产物是调用栈的耗时图。之所以称为火焰图,是因为整个图看起来像一个跳舞的火焰。火焰的顶部是调用栈的顶部,底部是栈底。垂直方向代表调用栈的深度,水平方向代表耗时。网格的宽度越大,它就越有可能成为瓶颈。分析火焰图主要是看那些比较大的火焰,特别注意那些类似“平顶山”的火焰。下面是美团平台自研的性能分析工具Caesium的分析效果图:通过对火焰图的分析,我们发现了冷启动过程中的诸多问题,成功优化了0.3S+的时间。优化内容总结如下:8.优化串行操作冷启动过程中,很多操作是串行执行的,几个任务是串行执行的,时间肯定比较长。如果能将串行改为并行,那么冷启动时间就可以大大缩短。splashscreenpages的使用现在很多app在启动的时候并不会直接进入首页,而是会给用户展示一个持续时间很短的splashscreenpage。如果使用得当,这个闪屏页面可以帮助我们节省一些启动时间。因为当一个App比较复杂的时候,在启动时第一次构建AppUI是一个比较耗时的过程。假设这个时间是0.2秒。如果我们先构建首页UI,然后在Window上添加这个闪屏页面,那么app在冷启动的时候实际上会卡住0.2秒,但是如果我们先把闪屏页面作为应用程序的RootViewControllerapp,那么构建过程会非常快。因为闪屏页面只有一个简单的ImageView,而这个ImageView会给用户展示的时间很短,那么我们就可以利用这段时间来搭建首页UI,一举两得。缓存定位&首页预请求美团外卖App冷启动过程中一个重要的串行过程是:首页定位-->首页请求-->首页渲染过程,这三个操作约占77%整个首页的加载时间,所以想要缩短冷启动时间,就要从这三点优化。之前的串行操作流程如下:优化设计在发起定位的同时,使用客户端缓存定位预请求首页数据,使得定位和请求并行进行。然后当用户真实定位成功后,判断真实位置是否命中缓存位置。如果命中,则刚才的预请求数据是有效的,可以节省大约40%的加载首页时间。效果非常明显;如果没有被击中,则将其丢弃。预请求数据,重新请求。9、数据监控TimeProfiler和Cesiumflamegraph只能在线下分析App在单个设备上的耗时运行,局限性比较大,无法在线监控App在用户设备上的性能。外卖APP采用公司自主研发的Metrics性能监控系统,对APP各项性能指标进行长时间监控,帮助我们掌握APP在各种在线环境下的真实性能,为技术优化项目提供可靠的数据支持。Metrics监控的核心指标之一就是冷启动时间。冷启动start&endtime节点结束时间点:结束时间比较容易确定。我们可以将某些视图元素在主页上的显示作为主页已加载的标志。启动时间点:一般情况下我们是在main()之后开始接管App的,但是把main()函数作为冷启动的起点显然是不合适的,因为T1时间段不能这样算.那么,如何确定开始时间呢?目前,业界有两种常见的方法。一种是以可执行文件中任意一个类的+load方法的执行时间为起点;另一种是分析dylib的依赖关系,找到叶子节点。dylib,然后以其中一个类的+load方法的执行时间为起点。根据dyld对dylibs的加载顺序,后者的加载时间更早。但是,这两种方法获取的起始点都只是在Initializers阶段,Initializers之前的时长不计算在内。Metrics采用另一种方式,使用App的进程创建时间(即exec函数的执行时间)作为冷启动的开始时间。因为系统允许我们通过sysctl函数获取进程的信息,包括进程创建的时间戳。#import#import+(BOOL)processInfoForPID:(int)pidprocInfo:(structkinfo_proc*)procInfo{intcmd[4]={CTL_KERN,KERN_PROC,KERN_PROC_PID,pid};size_tsize=sizeof(*procInfo);返回sysctl(cmd,sizeof(cmd)/sizeof(*cmd),procInfo,&size,NULL,0)==0;}+(NSTimeInterval)processStartTime{structkinfo_proccProcInfo;if([selfprocessInfoForPID:[[NSProcessInfoprocessInfo]processIdentifier]procInfo:&kProcInfo]){returnkProcInfo.kp_proc.p_un.__p_starttime.tv_sec*1000.0+kProcInfo.kp_proc.p_un.__p_starttime.tv_usec/1000.0;}else{NSAssert(NO,@");return0;}}进程创建的时机非常经过实验,在一个新创建的空白App中,进程创建时间比叶子节点dylib中的+load方法执行时间早12ms,比main函数执行时间早13ms(实验设备:iPhone7Plus(iOS12.0),Xcode10.0,Release模式)外卖APP的线上数据更加明显,同机型(iPhone7Plus)和系统版本(iOS12.0),进程创建时间为688ms早于叶子节点dylib中+load方法的执行时间,在所有机型和系统版本中,这个数据我878毫秒。冷启动过程的时间节点我们还在APP冷启动过程中的所有关键节点上标注了一系列的测速点。Metrics会记录速度测量点的名称和从进程创建时间算起的时间。我们没有采用自动结账的方式,因为外卖app的冷启动过程很复杂,自动结账不可能做到这么细,不实用。另外,Metrics记录的是时间轴上以进程创建时间为原点的一组连续时间点,而不是一组时间段,因为连续时间点可以计算任意两个时间点之间的距离,即将时间点处理成时间段。但是,一组时间段可能不会还原为顺序时间点,因为时间段可能不是顺序的,特别是对于异步执行或多线程。测速完成后,Metrics会将所有测速点统一上报给后台。下图是美团外卖6.10版本部分流程节点监控数据截图:Metrics也会在后台对数据进行聚合计算,得到总冷启动的50%、90%、95%每个速度测量点的持续时间和持续时间。分位数统计,让我们从宏观上了解冷启动时长的分布情况。下图中,横轴为时长,纵轴为上报样本数。10.总结对于快速迭代的应用,随着业务复杂度的增加,冷启动时间必然会增加。冷启动过程也是一个比较复杂的过程。当遇到冷启动性能瓶颈时,我们可以根据APP本身的特点和工具的使用,从多方面多角度进行优化。同时,优化冷启动库存问题只是冷启动治理的第一步,因为冷启动性能问题不是一天造成的,不是简单的通过一次优化工作就能解决的。我们需要通过合理的设计和规范的约束,有效控制性能问题的增量,并通过持续的在线监控,及时发现并纠正性能问题,从而保证APP长时间良好的冷启动体验。作者简介郭赛,美团点评高级工程师。2015年加入美团,目前是外卖iOS团队的主要开发人员,负责移动业务的开发、业务基础设施的建设和维护。徐宏,美团点评高级工程师。2016年加入美团,目前作为外卖iOS团队的主要开发人员,负责移动端APM性能监控,高可用基础设施支持相关推广工作。

猜你喜欢