前言
冷启动指标是App体验中相当重要的指标,在电商App中更是对用户的留存意愿有着举足轻重的影响。通常是指App进程启动到首页首帧出现的耗时,但是在用户体验的角度来看,应当是从用户点击App图标,到首页内容完全展示结束。
将启动阶段工作分配为任务并构造出有向无环图的设计已经是现阶段组件化App的启动框架标配,但是受限于移动端的性能瓶颈,高并发度的设计使用不当往往会让锁竞争、磁盘IO阻塞等耗时问题频繁出现。如何百尺竿头更进一步,在启动阶段有限的时间里,将有限的资源最大化利用,在保障业务功能稳定的前提下尽可能压缩主线程耗时,是本文将要探讨的主题。
本文将介绍我们是如何通过对启动阶段的系统资源做统一管控,按需分配和错峰加载等手段将得物App的线上启动指标降低10%,线下指标降低34%,并在同类型的电商App中提升至Top3。
一、指标选择
传统的性能监控指标,通常是以Application的attachBaseContext回调作为起点,首页decorView.postDraw任务执行作为结束时间点,但是这样并不能统计到dex加载以及contentProvider初始化的耗时。
因此为了更贴近用户真实体验,在启动速度监控指标的基础上,我们添加了一个线下的用户体感指标,通过对录屏文件逐帧分析,找到App图标点击动画开始播放(图标变暗)作为起始帧,首页内容出现的第一帧作为结束帧,计算出结果作为启动耗时。
例:启动过程为03:00 - 03:88,故启动耗时为880ms。
二、Application优化
App在不同的业务场景下可能会落到不同的首页(社区/交易/H5),但是Application运行的流程基本是固定的,且很少变更,因此Application优化是我们的首要选择。
得物App的启动框架任务在近几年已经先后做过多轮优化,常规的抓trace寻找耗时点并异步化已经不能带来明显的收益,得从锁竞争,CPU利用率的角度去挖掘优化点,这类优化可能短期收益不会特别明显,但从长远来看能够提前规避很多劣化问题。
1.WebView优化
App在首次调用webview的构造方法时会拉起系统对webview的初始化流程,一般会耗时200+ms,如此耗时的任务常规思路都是直接丢到子线程去执行,但是chrome内核中加入了非常多的线程检查,使得webview只能在构造它的线程中使用。
为了加速H5页面的启动,App通常会选择在Application阶段就初始化webview并缓存,但是webview的初始化涉及跨进程交互和读文件,因此CPU时间片,磁盘资源和binder线程池中任何一种不足都会导致其耗时膨胀,而Application阶段任务繁多,恰恰很容易出现以上资源短缺的情况。
因此我们将webview拆分成三个步骤,分散到启动的不同阶段来执行,这样可以降低因为竞争资源导致的耗时膨胀问题,同时还可以大幅度降低出现ANR的几率。
1.1 任务拆分
a. provider预加载
WebViewFactoryProvider是用于和webview渲染进程交互的接口类,webview初始化的第一步就是加载系统webview的apk文件,构建出classloader并反射创建了WebViewFactoryProvider的静态实例,这一操作并没有涉及线程检查,因此我们可以直接将其交给子线程执行。
b. 初始化webview渲染进程
这一步对应着chrome内核中的WebViewChromiumAwInit.ensureChromiumStartedLocked()方法,是webview初始化最耗时的部分,但是和第三步是连续执行的。走码分析发现WebViewFactoryProvider暴露给应用的接口中,getStatics这个方法会正好会触发ensureChromiumStartedLocked方法。
至此,我们就可以通过执行WebSettings.getDefaultUserAgent()来达到仅初始化webview渲染进程的目的。
c. 构造webview
即new Webview()
1.2 任务分配
为了最大程度缩短主线程耗时,我们的任务安排如下:
a. provider预加载,可以异步执行,且没有任何前置依赖,因此放在Application阶段最早的时间点异步执行即可。
b. 初始化webview渲染进程,必须在主线程,因此放到首页首帧结束之后。
c. 构造webview,必须在主线程,在第二步完成时post到主线程执行。这样可以确保和第二步不在同一个消息中,降低ANR的几率。
1.3 小结
尽管我们已经将webview初始化拆分为了三个部分,但是耗时占比最高的第二步在低端机或者极端情况还是可能触达ANR的阈值,因此我们做了一些限制,例如当前设备会统计并记录webview完整初始化的耗时,仅当耗时低于配置下发的阈值时,开启上述的分段执行优化。
App如果是通过推送、投放等渠道打开,一般打开的页面大概率是H5营销页,因此这类场景不适用于上述的分段加载,所以需要hook主线程的messageQueue,解析出启动页面的intent信息,再做判断。
受限于开屏广告功能,我们目前只能对无开屏广告的启动场景开启此优化,后续将计划利用广告倒计时的间隙执行步骤2,来覆盖有开屏广告的场景。
2.ARouter优化
在当下组件化流行的时代,路由组件已经几乎是所有大型安卓App必备的基础组件,目前得物使用的是开源的ARouter框架。
ARouter 框架的设计是它默认会将注解中注册path路径中第一个路由层级 (例如 "/trade/homePage"中的trade)作为该路由信息所的Group, 相同Group路径的路由信息会合并到最终生成的同一个类 的注册函数中进行同步注册。在大型项目中,对于复杂业务线同一个Group下可能包含上百个注册信息,注册逻辑执行过程耗时较长,以得物为例,路由最多的业务线在初始化路由上的耗时已经来到了150+ms。
路由的注册逻辑本身是懒加载的,即对应Group之下的首个路由组件被调用时会触发路由注册操作。然而ARouter通过SPI(服务发现)机制来帮助业务组件对外暴露一些接口,这样不需要依赖业务组件就可以调用一些业务层的视线,在开发这些服务时,开发者一般会习惯性的按照其所属的组件为其设置路由path,这使得首次构造这些服务的时候也会触发同一个Group下的路由加载。
而在Application阶段肯定需要用到业务模块的服务中的一些接口,这就会提前触发路由注册操作,虽然这一操作可以在异步线程执行,但是Application阶段的绝大部分工作都需要访问这些服务,所以当这些服务在首次构造的耗时增大时,整体的启动耗时势必会随之增长。
2.1 ARouter Service路由分离
ARouter采用SPI设计的本意是为了解耦,Service的作用也应该只是提供接口,所以应当新增一个空实现的Service专门用于触发路由加载,而原先的Service则需要更换一个Group,后续只用于提供接口,如此一来Application阶段的其他任务就不需要等待路由加载任务的完成。
2.2 ARouter支持并发装载路由
我们在实现了路由分离之后,发现现有的热点路由装载耗时总和是大于Application耗时,而为了保证在进入闪屏页之前完成对路由的加载,主线程不得不sleep等待路由装载完毕。
分析可知ARouter的路由装载方法加了类锁,因为他需要将路由装载到仓库类中的map,这些map是线程不安全的HashMap,相当于所有的路由装载操作其实都是在串行执行,而且存在锁竞争的情况,最终导致耗时累加大于Application耗时。
分析trace可知耗时主要来自频繁调用装载路由的loadInto操作,再分析这里锁的作用,可知加类锁是主要是为了确保对仓库WareHouse中map操作的线程安全。
因此我们可以将类锁降级对GroupMeta这个class对象加锁(这个class是ARouter apt生成的类,对应apk中的ARouter$$Provider$$xxx类),来确保路由装载过程中的线程安全,至于在此之前对map操作的线程安全问题,则完全可以通过将这些map替换为concurrentHashMap解决,在极端并发情况下会有一些线程安全问题,也可以按照图中添加判空来解决。
至此,我们就实现了路由的并发装载,随后我们根据木桶效应对要预载的service进行合理分组,再放到协程中并发执行,确保最终整体耗时最短。
3.锁优化
Application阶段执行的任务多为基础SDK的初始化,其运行的逻辑通常相对独立,但是SDK之间会有依赖关系(例如埋点库会依赖于网络库),且大部分都会涉及读文件,加载so库等操作,Application阶段为了压缩主线程的耗时,会尽可能地将耗时操作放到子线程中并发运行,充分利用CPU时间片,但是这也不可避免的会导致一些锁竞争的问题。
3.1 Load so锁
System.loadLibrary()方法用于加载当前apk中的so库,这个方法对Runtime对象加了锁,相当于一个类锁。
基础SDK在设计上通常会将load so的操作写到类的静态代码块中,确保在SDK初始化代码执行之前就准备好了so库。如果这个基础SDK恰巧是网络库这类基础库,会被很多其他SDK调用,就会出现多个线程同时竞争这个锁的情况。那么在最坏的情况下,此时IO资源紧张,读so文件变慢,并且主线程是锁等待队列中最后一个,那么启动耗时将远超预期。
为此,我们需要将loadSo的操作统一管控并收敛到一个线程中执行,强制他们以串行的方式运行,这样就可以避免以上情况的出现。值得一提的是,前面webview的provider预加载的过程中也会加载webview.apk中的so文件,因此需要确保preloadProvider的操作也放到这个线程。
so的加载操作会触发native层的JNI_onload方法,一些so可能会在其中执行一些初始化工作,因此我们不能直接调用System.loadLibrary()方法来进行so加载,否则可能会重复初始化出现问题。
我们最终采用了类加载的方式,即将这些so加载的代码全部挪到相关类的静态代码块中,然后再去触发这些类的加载即可,利用类加载的机制确保这些so的加载操作不会重复执行,同时这些类加载的顺序也要按照这些so使用的顺序来编排。
除此之外,so的加载任务不建议和其他需要IO资源的任务并发执行,在得物App中实测这两种情况下该任务的耗时相差巨大。
4.启动框架优化
目前常见的启动框架设计是将启动阶段的工作分配到一组任务节点中,再由这些任务节点的依赖关系构造出一个有向无环图,但是随着业务迭代,一些历史遗留的任务依赖已经没有存在的必要,但是他会拖累整体的启动速度。
启动阶段大部分工作都是基础SDK的初始化,他们之间往往有着复杂的依赖关系,而我们在做启动优化时为了压缩主线程的耗时,通常都会找出主线程的耗时任务并丢到子线程去执行,但是在依赖关系复杂的Application阶段,如果只是将其丢到异步执行未必能有预期的收益。
我们在做完webview优化之后发现启动耗时并没有和预期一样直接减少了webview初始化的耗时,而是只有预期的一半左右,经分析发现我们的主线程任务依赖着子线程的任务,所以当子线程任务没有执行完时,主线程会sleep等待。
并且webview之所以放在这个时间点初始化不是因为有依赖限制这它,而是因为这段时间主线程正好有一段比较长的sleep时间可以利用起来,但是异步的任务工作量是远大于主线程的,即便是七个子线程并发在跑,其耗时也是大于主线程的任务。
因此想进一步扩大收益,就得对启动框架中的任务依赖关系做优化。
以上第一张图为优化之前得物App启动阶段任务的有向无环图,红框表示该任务在主线程执行。我们着重关注阻塞主线程任务执行的任务。
可以观察到主线程任务的依赖链路上存在几个出口和入口特别多的任务,出口多表明这类任务通常是非常重要的基础库(例如图中的网络库),而入口多表明这个任务的前置依赖太多,他开始执行的时间点波动较大。这两点结合起来就说明这个任务执行结束的时间点很不稳定,并且将直接影响到后续主线程的任务。
这类任务优化的思路主要是:
拆解任务自身,将可以提前执行或者延后执行的操作分出去,但是分出去之前要考虑到对应的时间段还有没有时间片余量,或者会不会加重IO资源竞争的情况出现;
优化该任务的前置任务,让该任务执行结束的时间点尽可能提早,就可以降低后续任务等待该任务的耗时;
移除非必要的依赖关系,例如埋点库初始化只是需要注册一个监听器到网络库,并非发起网络请求。(推荐)
可以看到我们在优化之后的第二张有向无环图里,任务的依赖层级明显变少,入口和出口特别多的任务也都基本不再出现。
对比优化前后的trace,也可以看到子线程的任务并发度明显提高,但是任务并发度并不是越高越好,在时间片本身就不足的低端机上并发度越高表现可能会越差,因为更容易出锁竞争,IO等待之类的问题,因此要适当留下一定空隙,并在中低端机上进行充分的性能测试之后再上线,或者针对高中低端机器使用不同的任务编排。
三、首页优化
1.通用布局耗时优化
系统解析布局是通过inflate方法读取布局xml文件并解析构建出view树,这一过程涉及IO操作,很容易受到设备状态影响,因此我们可以在编译期通过apt解析布局文件生成对应的view构建类。然后在运行时提前异步执行这些类的方法来构建并组装好view树,这样可以直接优化掉页面inflate的耗时。
2.消息调度优化
在启动阶段我们通常会注册一些ActivityLifecycleListener来监听页面生命周期,或者是往主线程post了一些延时任务,如果这些任务中有耗时操作,将会影响到启动速度,因此可以通过hook主线程的消息队列,将页面生命周期回调和页面绘制相关的msg移动到消息队列的队头,这样就可以加快首页首帧内容展示的速度。
详情可期待本系列后续内容。
四、稳定性
性能优化对App只能算作锦上添花,稳定性才是生命红线,而启动优化改造的又都是执行时机非常早的Application阶段,稳定性风险程度非常高,因此务必要在准备好崩溃防护的前提下做优化,即便有不可避免的稳定性问题,也要将负面影响降到最低。
1.崩溃防护
由于启动阶段执行的任务都是重要的基础库初始化,因此发生崩溃时将异常识别并吃掉的意义不大,因为大概率会导致后续崩溃或功能异常,因此我们主要的防护工作都是发生问题之后的止血。
配置中心SDK的设计通常都是从本地文件中读出缓存的配置使用,待接口请求成功后再刷新。所以如果当启动阶段命中了配置之后发生了crash,是拉不到新配置的。这种情况下只能清空App缓存或者卸载重装,会造成非常严重的用户流失。
- 崩溃回退
对所有改动点加上try-catch保护,捕捉到异常之后上报埋点并往MMKV中写入崩溃标记位,这样该设备在当前版本下都不会再开启启动优化相关的变更,随后再抛出原异常让他崩溃掉。至于native crash则是在Crash监控的native崩溃回调里执行同样操作即可。
- 运行状态检测
Java Crash我们可以通过注册unCaughtExceptionHandler来捕捉到,但是native crash则需要借助crash监控SDK来捕捉,但是crash监控未必能在启动最早的时间点初始化,例如Webview的Provider的预加载,以及so库的预加载都是早于crash监控,而这些操作都涉及native层的代码。
为了规避这种场景下的崩溃风险,我们可以在Application的起始点埋入MMKV标记位,在结束点改为另一个状态,这样一些执行时间早于配置中心的代码就可以通过获取这个标记位来判断上一次运行是否正常,如果上次启动发生了一些未知的崩溃(例如发生在crash监控初始化之前的native崩溃),那么通过这个标记位就可以及时关闭掉启动优化的变更。
结合崩溃之后自动重启的操作,在用户视角其实是观察不到闪退的,只是会感觉到启动的耗时约是平时的1-2倍。
- 配置有效期
线上的技改变更通常都会配置采样率,结合随机数实现逐渐放量,但是配置下发SDK的设计通常都是默认取上次的本地缓存,在发生线上崩溃等故障时,尽管及时回滚了配置,但是缓存的设计会导致用户还会因为缓存遭遇至少一次的崩溃。
为此,我们可以为每一个开关配置加一个配套的过期时间戳,限制当前放量的开关只在该时间戳之前生效,这样在遇到线上崩溃等故障时确保可以及时止血,而且时间戳的设计也可以避免线上配置生效的滞后性导致的crash。
用户视角下,添加配置有效期前后对比:
五、总结
至此,我们已经对安卓App中比较通用的冷启动耗时案例做了分析,但是启动优化最大的痛点往往还是App自身的业务代码,应当结合业务需求合理的进行任务分配,如果一味的靠预加载,延迟加载和异步加载是不能从根本上解决耗时问题的,因为耗时并没有消失只是转移,随之而来的可能是低端机启动劣化或功能异常。
做性能优化不仅需要站在用户的视角,还要有全局观,如果因为启动指标算是首页首帧结束就把耗时任务都丢到首帧之后,势必会造成用户后续的体验有卡顿甚至ANR。所以在拆分任务时不仅需要考虑是否会和与其并发的任务竞争资源,还需要考虑启动各个阶段以及启动后一段时间内的功能稳定性和性能是否会受之影响,并且需要在高中低端机器上都验证下,至少要确保都没有劣化的表现。
1.防劣化
启动优化绝不是一次性的工作,它需要长时间的维护和打磨,基础库的一次技改可能就会让指标一夜回到解放前,因此防劣化必须要尽早落地。
通过在关键点添加埋点,可以做到在发现线上指标劣化时迅速定位到劣化代码大概位置(例如xxActivity的onCreate)并告警,这样不仅可以帮助研发迅速定位问题,还可以避免线上特定场景指标劣化线下无法复现的情况,因为单次启动的耗时波动范围最高能有20%,如果直接去抓trace分析可能连劣化的大概范围都难以定位。
例如两次启动做trace对比时,其中一次因为遇到IO阻塞导致某次读文件的操作都明显变慢,而另一次IO正常,这就会误导开发者去分析这些正常的代码,而实际导致劣化的代码可能因为波动正好被掩盖。
2.展望
对于通过点击图标启动的普通场景,默认会在Application执行完整的初始化工作,但是一些层级比较深的功能,例如客服中心,编辑收货地址这类,即使用户以最快速度直接进入这些页面,也是需要至少1s以上的操作时间,所以这些功能相关的初始化工作也是可以推迟到Application之后的,甚至改为懒加载,视具体功能的重要性而定。
通过投放,push来做召回/拉新的启动场景通常占比较少,但是其业务价值要远大于普通场景。由于目前启动耗时主要来源于webview初始化以及一些首页预载相关的任务,如果启动落地页并不需要所有基础库(例如H5页面),那么这些我们就可以将它不需要的任务统统延迟加载,这样启动速度可以得到大幅度增长,做到真正意义上的秒开。
*文/Jordas