“ 只要你有一件合理的事去做,你的生活就会显得特别美好。 —— 爱因斯坦 ”
一、背景
冷启动时长是App性能的重要指标,作为用户体验的“首秀”,直接决定了用户对App的第一印象。东方航空iOS客户端从2015年6月份开始,经历了几十个版本的迭代开发,并于2019年1月份开始着手进行了重构,产品形态不断完善,业务功能日趋复杂;同时东航钱包项目也已经由原来的独立业务演进成为当今线上东航App的一个特色平台,陆续接入了东航新商城、联盟互售等其他新业务。因此,更多更复杂的工作需要在App冷启动的时候被完成,这给App的冷启动性能带来了挑战。对此,我们的团队基于业务形态的变化和航空App的特点,对冷启动进行了持续并且有针对性的优化工作,目的就是为了呈现更加流畅的用户体验。
二、冷启动定义
一般而言,大家把iOS冷启动的过程定义为:从用户点击app图标开始到appDelegate didFinishLaunching方法执行完成为止。这个过程主要分为两个阶段:
• T1:main()函数之前,即操作系统加载App可执行文件到内存,然后执行一系列的加载&链接等工作,最后执行至main()函数。
• T2:main()函数之后,即从main()开始,到appDelegate didFinishLaunchingWithOptions方法执行完毕。
然而,当didFinishLaunchingWithOptions 执行完成时,用户还没看到App的主界面,也不能开始使用App,还需要做一些初始化工作,然后经历定位、首页请求、首页渲染等过程后,用户才能真正看到数据内容并开始使用,我们认为这个时候冷启动才算完成。我们把这个过程定义为T3。
综上,我们把App冷启动过程定义为:从用户点击APP图标开始到用户能够看到APP主界面内容为止这个过程,即 (T1 + T2 + T3)。在APP冷启动过程当中,这三个阶段中的每个阶段都存在很多可以被优化的点。
三、问题现状
性能存量问题
东方航空iOS客户端经过了几十个版本的迭代开发后,在冷启动过程中已经积累了若干性能问题,解决这些性能瓶颈是冷启动优化工作的首要任务,这些问题主要包括:
注:启动项的定义,在App启动过程中需要被完成的某项工作,我们称之为一个启动项。例如某个SDK的初始化、某个功能的预加载等。
性能增量问题
一般情况下,在App早期阶段,冷启动不会有明显的性能问题。冷启动性能问题也不是在某个版本突然出现,而是随着版本迭代,App功能越来越复杂,启动任务越来越多,冷启动时间也一点点延长。最后当我们注意到,并想优化它的时候,这个问题已经变得很棘手了。航空App的性能问题增量主要来自启动项的增加,伴随着版本迭代,启动项任务简单粗暴地堆积在启动流程中。如果每个版本冷启动时间增加0.1s,那么几个版本下来,冷启动时长就会明显增加很多。
四、治理思路
冷启动性能问题的治理目标主要有三个:
1. 解决存量问题:优化当前性能瓶颈点,优化启动流程,缩短冷启动时间。
2. 管控增量问题:冷启动流程规范化,通过代码范式和文档指导后续冷启动过程代码的维护,控制时间增量。
3. 完善监控:完善冷启动性能指标监控,收集更详细的数据,及时发现性能问题。
五、规范启动流程
截止2019年底,东方航空常客用户已经突破300万,而东航App也已经基本完成了从支撑单一业务的App向支持多业务平台型App的演进,集团的一些新兴业务也陆续集成到东航App当中。下面是App的架构图,主要分为三层,底层是基础组件层,中层是业务平台层,平台层向下管理基础组件,向上为业务组件提供统一的适配接口,上层是对外打包层,包括机票业务拆分的子业务组件和接入的其他非机票业务。
App的平台化为业务方提供了高效、标准的统一平台,但与此同时,平台化和业务的快速迭代也给冷启动带来了问题:
1. 现有的启动项堆积严重,拖慢启动速度。
2. 新的启动项缺乏添加范式,代码杂乱无章,修改风险大,难以阅读和维护。
面对这个问题,我们首先梳理了目前启动流程中所有的启动项,然后针对App平台化设计了新的启动项管理方式:分阶段启动和启动项自注册。
分阶段启动
App开发早期由于业务比较简单,所有启动项都没有加以区分,简单地堆积到didFinishLaunchingOptions方法中,随着业务的复杂度不断增加,越来越多的启动项代码堆积在一起,代码臃肿混乱,形成蝴蝶效应,导致App启动性能变差。
通过对使用的各种SDK进行梳理和分析,我们发现启动项也需要根据所完成的任务被分类,有些启动项是需要刚启动就执行的操作,如crash监控、统计上报等,否则会导致信息收集的缺失;有些启动项需要在较早的时间节点完成,例如一些提供用户信息的SDK、定位功能的初始化、网络初始化等;有些启动项则可以被延迟执行,如一些自定义配置,一些业务服务的调用、支付SDK、地图SDK等。
我们所做的分阶段启动,首先就是把启动流程合理的划分成为若干个启动阶段,然后依据每个启动项所做的事情的优先级把它们分配到相应的启动阶段,优先级高的放在靠前的阶段,优先级低的放在靠后的阶段。
下面是我们对东航App启动阶段进行的重新定义,对所有启动项进行梳理和重新分类,把它们对应到合理的启动阶段。这样整理之后一方面可以推迟执行那些不必过早执行的启动项,另一方面,把启动项进行归类,方面后续的阅读和维护。最后把这些规则落地形成启动项的维护文档,指导后续启动项的添加和维护。
通过上面的工作,我们梳理出了十几个可以推迟执行的启动项,约占所有启动项的30%左右,有效的优化了这些启动项所占用的冷启动时间。
启动项自注册
确定了启动项分阶段启动的方案后,我们面对的问题就是如何执行这些启动项。比较容易想到的方案是:在启动时创建一个启动管理器,然后读取所有启动项,当时间节点到来时由启动管理器触发启动项执行。这种方式存在两个问题:
1. 所以启动项需要预先写到一个文件中(.m文件或者.plist文件),这种集中化的写法在业务复杂度越来越高时容易导致代码臃肿,难以阅读维护。
2. 启动项代码难以复用,启动项无法插拔到子业务库内部,例如在东航机票App和掌上东航App中要重复实现,这和我们设计App平台化的方向不符。
而我们希望的是启动项维护方式可插拔,启动项之间、业务模块之间低耦合,并且一次实现可在多场景复用。
下图是我们采用的启动项管理方式,根据其特点我们把它称为启动项的自注册:一个启动项定义在子业务模块内部,被封装成一个方法,并且自声明启动阶段(例如一个启动项C,在独立App中可以声明为在willFinishLaunch阶段被执行,在钱包平台中则声明在resignActive阶段被执行)。这种方式下启动项就实现了两端复用,不相关的启动项相互隔离,进行添加/删除都很方便。
那么如何给一个启动项声明启动阶段呢?又如何在正确的时机触发启动项的执行呢?编写代码时,一个启动项最终都会对应到一个函数的执行,所以在运行时只要能获取到函数的指针,就可以触发启动项。基于此,我们开发了东航App启动治理组件(化名CCStartManager),它的核心思想是:在编译时把数据(如函数指针)写入到可执行文件的_DATA段中,运行时再从_DATA段取出数据进行相应的操作(调用函数)。之所以借用_DATA段来处理数据是因为它能够覆盖所有的启动阶段,比如在main函数之前的那些阶段。其中提供的编译器函数中有一个section()函数,它提供了二进制段的读写能力,能够将一些在编译期就可以确定的常量写入数据段。在具体的实现中,主要分为编译时和运行时两个部分。
在编译时,编译器会将标记了attribute((section()))的数据写到指定的数据段中,例如写一个{stage,*pointer}数据对到数据段(stage代表不同的启动阶段),到运行时,在合适的时间节点,再根据stage读取函数指针,完成函数的调用。
上述方式可以封装成一个宏来达到代码的简化,例如以调用CCSTART_FUNCTIONS_EXPORT("stage")为例,编译器把启动项函数注册到启动阶段A:
在启动流程中,当进入到启动阶段STAGE_KEY_A时触发所有注册到STAGE_KEY_A时间点的启动项,通过这种方式,几乎没有任何额外的辅助代码,我们用一种很简洁的完成了启动项的自注册。
完成对现有启动项的梳理和优化之后,我们也输出了后续启动项的添加与维护规范,用于规范后续启动项的分类原则、优先级、以及启动阶段。目的是管控性能问题增量,保证优化成果的延续性。
六、优化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会调用main()函数,main()会调用UIApplicationMain()。就此,在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):
通过这种方法,我们排查了几十个无用的类和方法,有针对性的清理了一部分资源,有效的进行了代码瘦身。
+load优化
iOS App中多多少少都会写一些+load方法。用于在App启动时执行一些操作,+load方法在Initializers阶段被执行,但过多的+load方法则会拖慢启动速度,对于大中型体量App来说这种影响是不可忽略的。
我们通过对App中+load的方法分析,发现很多代码虽然需要在App启动时较早的时机进行初始化,但并不需要在+load这样非常靠前的位置,完全可以延迟到App冷启动之后的某个时间点,比如一些路由操作。其实+load方法也可以被当做一项启动项来处理,所以在替代+load方法的具体实现上,我们任然可以采用上面的CCStartManager方式。使用示例:
七、优化耗时操作
上面已经叙述过在main()函数之后的主要工作是进行各种启动项的执行,主界面的构建(比如TabBarVC、HomeVC等),资源的加载(比如图片I/O、图片解码、archive文档等)。这些操作中可能会隐含一些比较耗时而且难以依靠阅读代码来发现的耗时操作,那如何找到这些耗时呢?结合适当的分析工具就会事半功倍。
Timer Profiler
Timer Profiler是Xcode自带的时间性能分析工具,它可以按照固定的时间间隔来跟踪每一个线程的堆栈信息,然后通过统计比较时间间隔之间的堆栈状态,来推算某个方法执行了多久,并获取到一个近似值。Timer Profiler使用教程在网上有很多,这里不再赘述。附上一篇使用文档:Instruments Tutorial with Swift: Getting Started
通过Timer Profiler提供的数据分析,我们发现了冷启动过程中存在的一些耗时问题,并成功优化了耗时操作,优化内容总结如下:
八、优化串行执行
在冷启动过程中,有很多串行执行的操作,如果有大量的任务串行执行,那么执行时间也会随之增加。所以我们想如果能够将一些任务由串行改为并行,那么冷启动时间就能够有效缩短。
闪屏广告页的使用
现在很多App流行在启动时先向用户展示一个持续一小段时间的闪屏广告页,然后才进入首页面展示,这个闪屏页如果使用恰当就能够帮助开发者利用这个时段进行首页构建。当一个App比较复杂,启动时首次构建App的UI就是一个很耗时的过程,假如这个过程需要0.3秒,如果我们先进行首页UI构建,然后再进行闪屏广告的展示,那么App很可能就会开在0.3秒,体验很不友好,如果先把闪屏页作为App的rootVC,那么这个构建过程就会拥有并行时间,在视觉上很流畅,因为一个闪屏页往往只有一个ImageView展示,内容很简单,在向用户展示ImageView的同时,开发者就可以利用这段时间来构建复杂的首页UI,一举两得。
缓存定位 & 首页预请求
东航App冷启动过程中一个重要的串行流程就是:首页定位->首页请求->首页渲染 ,这三个操作占据了整个首页加载时间的七成左右,所以想要缩短冷启动时长,就要从这三点出发进行优化。
一般情况下通用串行操作流程如下:
优化后的设计思路是在发起定位的同时,使用客户端缓存定位,然后进行首页数据的预请求,使定位和请求并行进行。然后当定位成功后,判断真实定位是否命中缓存定位,如果命中则预请求数据有效,这样可以节省部分首页加载时间,效果也比较显著,如果未命中,则放弃预请求数据,重新请求。
九、数据监控
Timer Profiler只能在线下分析App在单台设备上的耗时操作,局限性比较大,无法在线上监控App在用户设备上的表现。东航App目前使用听云SDK进行线上数据监控,如果自研还未成熟,接入第三方数据监控平台也是不错的选择,但最好还是需要公司内部自研性能监控系统,以期长期监控App的性能指标,帮助我们掌握App在线上线下各种环境中的真实表现,并为技术优化提供可靠的数据支持。
十、总结
对于快速迭代的App,随着业务复杂度的不断增加,冷启动时长不可避免的增加。冷启动流程也是一个比较复杂的过程,当遇到冷启动性能瓶颈时,我们的研发团队可以根据App自身的特点,配合恰当的工具使用,从多角度、多方面着手优化。同时,优化冷启动存量问题只是冷启动治理的开始,包括冷启动在内的其他性能问题都不是一日造成的,也不可能一蹴而就地完美解决,我们需要合理的设计、规范的约束,来有的放矢的管控问题,并通过持续的线上监控来及时收集和修正性能问题,这样才能够保证App具备稳定流畅的冷启动体验。
笔者简介
崔新宽,东方航空移动端研发工程师。2015年加入东航,目前作为机票业务iOS团队主力开发,负责移动端业务开发,业务类基础设施的建设与维护。
如发现文章有错误、对内容有疑问,都可以在留言区给我留言,大家一起讨论、学习和成长。
如果感觉棒极啦,那奖励一杯咖啡吧!