得物 App 安卓冷启动优化 -Application 篇
前言
冷启动指标是 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
本文属得物技术原创,更多精彩文章请看:得物技术官网
未经得物技术许可严禁转载,否则依法追究法律责任!
版权声明: 本文为 InfoQ 作者【得物技术】的原创文章。
原文链接:【http://xie.infoq.cn/article/b9956434e02eb193d21a35bae】。文章转载请联系作者。
评论