背景
为什么重提启动优化?首先,用户进入APP唯一的路径就是启动,这是体验核心链路的第一环。启动分为冷启动、热启动和温启动,本文中「启动」一词如果没有特别说明,均为冷启动。启动时间过长,会造成用户流失,也就是冷启跳失。从APP体验的全局视角来看,启动处在一个核心卡口位置,是极其重要的一环,是APP核心指标之一。随着业务越来越复杂,需要在启动过程中做的事情越来越多,很多APP都面临着原有的启动方案不断腐化导致体验变差的问题,我们也不例外。启动优化是个老生常谈的问题,本文会着重从1688的现状出发,和大家分享我们具体做了什么,希望能给大家带来一些收获。
启动技术发展历程
在谈启动优化之前,我们先回顾一下启动优化相关技术的发展历程。从一开始的windowBackground加图到后来五花八门的各种操作,我用一张图简单表示一下:
图中我只简单列举了一些大家都比较熟知的方案,并不代表全部,仅供参考,经常做启动优化的小伙伴可能比较了解。在启动方面,很多公司不遗余力甚至用了不少黑科技来榨取性能达到优化的效果。对于开发成本有限的个人或者团队来说,有些东西是不太适合做的。下面会讲讲我们的思路,在有限的成本下获取更大的收益。
注:本文内容适用于所有Android开发同学。
1688启动优化做了什么
在优化前,要先分析现状。1688 APP名为「阿里巴巴」,各大应用商店均可下载,以下统称阿里巴巴 APP。阿里巴巴 APP的现状是怎么样的呢?
-
利用了windowBackground增强体感
-
针对耗时任务已经做过单点优化
-
利用了多线程分批调度任务
分批调度的实现简述:将启动任务分为四个批次,首先在Application onCreate中执行第一批次,然后子线程执行第二批次,HomeActivity onCreate里子线程执行第三批次,首页渲染完之后子线程执行第四批次。同一个批次中根据设置的参数进行分组,同组并发执行,其余根据参数顺序执行。这个调度方案有个巨大的弊端:除了Application里的任务是确定性跑完的,其他子线程的任务无法知道具体在什么确切的时机跑完,只能说尽量早点开始跑。随着业务复杂度的提升,一些必须保证进入首页前跑完的任务,只能加在Application那个阶段里。启动任务一条一条写又有点麻烦,于是就出现了一个任务里做好几件事情,任务也跟着腐化了。
启动定义
首先,我们要先明白,什么叫「冷启动」。Android官方对启动的定义,可以参考文档:https://developer.android.com/topic/performance/vitals/launch-time,我们可以在Logcat中,通过Displayed的flitter筛出启动时间。例如:
那么阿里集团是如何定义Android应用启动的呢?主要分为以下四个阶段:
- 系统初始化
- 应用初始化
- 首屏展示
- 启动完成(可交互)
本文讲重点讲述「应用初始化」和「首屏展示」两个阶段的优化。应用初始化是指从Application启动到首页Activity的onResume之前,首屏展示是指从Application启动到首页渲染80%完成的时候。下文中,应用初始化阶段优化指从Application启动到首页Activity onResume之前的阶段,着重讲述Application阶段如何利用多核能力进行更优的任务调度,从而减少单任务的CPU idle耗时。首页展示优化着重讲述从首页Activity onResume之后到首页80%渲染完成这个阶段,重点突出首页的优化策略。
先来一张图展示启动优化的整体方案:
应用初始化阶段优化
上文中,已经提到了老启动框架的几个弊端:
-
除了Application里的任务是确定性跑完的,其他任务不知道什么时候跑完,后续场景需要大量的check init
-
任务逐渐繁重、腐化
-
任务之间存在锁竞争,在不同机型上体验差异较大,单任务耗时加长,上层也感知不到
随着业务的不断发展,我们越来越需要在确定的时机调度确定的任务,能让后续流程的任何地方明确模块需要的任务是否已经初始化成功,首页的前置初始化任务以及后续的弹性调度方案也需要确定性,以前子线程之间互相藏起来的问题,现在必须暴露出来并且处理掉。其次,需要把启动任务重新打散、编排,确定单一职责,这也可以防止未来任务的不断腐化,同时这也是任务「无锁化」调度的基础,更好的利用多核能力,减少单任务idle耗时,达到整体最优。面向多场景的情况,例如唤端、web外链启动、自动登录、前后台切换等场景,均需要支持启动任务面向场景做定制化编排。
在此基础上,我们还需要更全面的监控能力,可以感知到线上任何一个任务的运行情况,以及在开发期可以和测试平台集成做卡口强控启动任务,感知是否有异常耗时的任务出现等。让组里每一位Android同学都非常清晰的看到启动任务是如何调度的,自己所负责的业务模块所关联的启动链路在不同的启动场景下是如何执行的。
基于以上的诉求,我们重新开发了启动框架,并将其命名为:亚索。
且随疾风前行,身后亦须留心。
众所周知,面对疾风吧!我们希望新的启动框架能让APP启动如疾风般迅速,所以取名为亚索。
那么亚索整体架构是什么样的呢?我们基于手淘的DAG调度框架,进一步定制封装了中间层能力,对接了我们原有的启动库,以较小的成本改造了整套启动链路。
上图用最简单的分层架构方式展示了亚索的整体架构。向下接入了手淘DAG调度能力,向上承接了1688上一套启动框架,其中核心的模块有:
-
core(基于DAG的上层封装)
-
statistics(监控统计模块)
-
common(公共模块)
-
config(启动配置模块)
-
api(启动任务调用的api模块)
-
bootstrap(启动器入口)
我们主要做了几件比较重要的事情:
-
启动任务打散,重新细粒度拆分,遵循单一职责
-
亚索框架构建
-
任务重新编排,通过各种手段尽量实现无锁化调度
对于外部同学来说,DAG调度框架已经有不少开源的方案,大家可以选用合适的框架接入从而实现类似「亚索」的启动器,思路大多是共通的。
启动任务打散,这是个细活儿,说起来简单做起来比较繁琐。首先要对所有启动任务有相对全面的认知,找到「拆」的下手点。由于我们的APP已经发展多年,启动任务基本上没有一个人能说明白具体都做了什么,一点点去看、去拆,花了不少功夫,最终从原有的不到40个任务,拆到了70多个。
任务重新编排,这是更费精力的。大家都知道,「祖传代码」一般人不敢动,动了怕出问题,尤其还是在启动链路上,一出问题搞不好就是线上事故。这次,我们是深入到祖传代码的海洋中,一顿操作猛如虎。编排要考虑最重要的三点:
-
无锁化
-
顺序(显性依赖和隐性依赖)
-
多场景下编排方案
首先,深入集团二方库,做拆分和无锁化编排,根据内部代码决定顺序依赖。其次,仔细研究我们自己的启动任务,和组内同学一起一点点细抠,无数次实验,经历了两三个版本的迭代,才确定了最终的编排方案(泪目)。在启动阶段,除了我们自己的任务之间可能产生的锁竞争以外,大家可能会遇到的共性问题有:
-
SharedPreferences读数据的锁。SharedPreferences第一次读文件会new Thread读取
-
loadLibrary0的锁(load so)
-
缓存读文件的锁
-
如果做了view的提前inflate,要注意如果使用的是同一个Context,inflate也是有锁的
针对这些问题,主要的解决方案就是SharedPreferences和缓存预取,load so的任务尽量平摊到各个阶段执行。这里推荐使用Android官方的分析工具systrace进行分析,亚索内部的模块在所有启动任务和阶段中都打了trace,开启trace开关之后就可以在release包中进行分析,最接近真实数据。也推荐大家使用Google新出的工具:https://ui.perfetto.dev/,既可以展示systrace的html文件,也可以在高版本Android系统上录制trace,其他更多功能,大家可以通过文档自行发现。
阿里巴巴APP线上最新版本(9.10.3.0)启动任务调度部分图:
可以看出尤其是前置链路,基本上无锁化了。无锁化不是追求一定不能有任何一点点锁竞争,而是从分阶段的角度看,解决耗时峰值,让阶段耗时更平滑,达到总体最优,同时要考虑多机型的兼容问题,要充分考虑ROI。同时单阶段的任务排布,也要考虑线程的调度能力,还可以利用隐性依赖(阶段之间的依赖)解决一些棘手的锁竞争问题。切记问题要通盘来看,取平衡。
最终,在主进程冷启动阶段,我们启用了以下阶段(包括唤端):
- Application attach
- Application onCreate(其中包含3个小阶段)
- 第一个Activity的onCreate
- 外链唤端启动
- push Activity onCreate(离线push唤端启动)
- 启动可交互完成之后触发一个阶段
- 主线程idle后
- 主线程idle 5s后
此外还有小程序进程等其他进程的阶段。共调度超过70个任务,其中80%的任务是在启动可交互之前就执行完毕的,也就是说,这部分任务在用户进入首页可以交互之前就已经初始化完毕可以使用了,和老的启动框架相比,吞吐量提升4~5倍。
阿里巴巴APP线上最新版本(9.10.3.0)启动阶段图(普通冷启):
对于应用初始化这个大阶段来说,新的方案总体耗时和老的方案相比,优化的空间不大,从整体大盘均值上来看,100-200ms的耗时减少,但是这对比的是我们老框架在Application阶段只调度了10个任务的情况。它能够为后面首页展示等阶段提供非常大的可操作空间,做到了全盘启动可控,研发期卡口,上线后可感知,以及为后续弹性启动方案提供了基础。
这里提供一个小tip:如果启动图是类似阿里巴巴APP的这种,大底色+logo或者内容的形式,建议使用layer-list实现,底色使用item shape色值代替,logo或者内容使用一张小图,这样会减少decode windowBackground的耗时,改动很小,收益不错。
首页展示优化
首先介绍下首页的基本结构:
首页由二楼容器、静态的主页面(包含搜索组件、多Tab组件)、动态搭建的的首页Tab和行业Tab页面(CyberT容器搭建)。
1688首页的特点在于,业务形态复杂,响应式任务较多,容器嵌套多,动态能力要求高,而且1688经过多年的发展,首页的各种营销能力比如弹层/浮窗/浮条等犬牙交错,在不影响业务能力及交互的前提下,考虑到ROI,借用前文亚索启动框架为我们争取到的大量空间,最立竿见影的战略方针即以空间换时间。
因此在上述背景下,依赖亚索框架提供的确定性的启动阶段和**可编排的任务体系,**虽然其未能减少太多启动时长,但给我们首页的优化提供了大量的操作空间,以便我们能充分利用多核能力给首页优化争取时间。
主要的优化思路分以下三点:
-
确保启动时的主线程环境,无太多干扰因素**(耗时任务前置/后置)**
-
渲染容器改造将首页渲染的部分逻辑放在Application初始化阶段执行**(渲染时间的折叠)**
-
渲染数据管控/刷新管控,防止刚启动时大量重复操作占用主线程 (保证渲染内容)
1、确保启动时的主线程环境
首先,应用刚刚启动的时候,是CPU最繁忙的一段时间,大量二方库的初始化导致子线程数量暴增,此阶段主线程对时间片的竞争变得异常激烈。因此在执行到首页展示阶段时,需要保证主线程不被其它任务占用。比如WebView初始化、Flutter初始化这些需要占用主线程操作的任务,可以适当腾挪下时间,在首页渲染完或idle时再进行任务触发。
WebView初始化任务被提前执行问题:
在进入首页时,往往会弹出一个送红包/会场引流的一个营销弹层能力。该弹层由H5实现,依赖于WebView初始化任务。在调试祖传代码时我们发现,老启动的设计原则是「尽可能」保证模块执行前相关初始化模块已执行完毕,但不一定能保证其顺序性,因此老代码执行前会调用check init ,即若相关初始化模块尚未执行,则强行唤起并执行该初始化任务。导致进入首页Activity前,就进行了WebView的初始化,WebView的初始化任务耗时又相当长,进而浪费了首页展示阶段的CPU资源。
因此针对此现象我们进行了几步改造:
-
拆分WebView初始化任务,将营销弹层所需的模块拆分出来,减少耗时防止成为阶段瓶颈并提前在Activity onCreate阶段执行
-
将营销弹层的触发逻辑后置在首页渲染80%后回调触发
-
营销层弹出逻辑前置判断 ---- 提前执行该弹层是否需要展示的逻辑,防止初始化后页面不展示带来的资源浪费,以此来确保营销弹层唤起率的业务效果以及性能的耗时。(该方案上线后给大盘带来了200ms左右的提升)
2、首页提前渲染
首页渲染框架使用CyberT容器是1688自研的一套组件化的页面投放体系。通过动态下发组件的模板、样式、数据来进行页面的搭建和渲染,同时具备了电商业务的强动态性和首页Native性能的体验诉求。首页Tab和行业Tab的页面均采用CyberT容器搭建。
为了充分利用亚索的编排能力为我们带来的空间,我们改造并沉淀了在CyberT上的渲染优化逻辑,将部分逻辑提前在应用初始化阶段进行,分别实现了:
-
CyberT协议解析,View预生成
-
布局文件的预生成
-
首页组件的模板预创建
CyberT协议解析,View预生成:
在调试过程中我们发现,协议解析的主线程回调在首页展示阶段,由于主线程繁忙导致过长时间的idle迟迟无法执行CyberT页面的生成,而CyberT页面的生成在CyberT初始化任务结束后即可开始,因此我们在不成为启动阶段瓶颈的前提下,在应用初始化阶段利用缓存的协议和组件数据,预先执行了协议解析、View的创建并将其保存在内存中,等待首页Fragment生成时直接add进View树并执行后续渲染逻辑。全程利用Application上下文做Context透出并做好Use Once逻辑,及时回收以防止内存泄漏。并做好线上成功率监控,将机型评分与成功率进行关联,下发配置开关,在不同评分的机型上弹缩优化策略,以期方案收益最大化。(该方案上线后能给大盘带来150ms~200ms的提升)
布局文件的预生成:
通过分析trace文件,我们发现对xml的解析存在一定的主线程耗时,因此针对首页必然执行的Activity、Fragment、二楼View的布局文件,我们在Application阶段也进行了预创建,为了避免线程的过多创建和inflate过程中Application的锁竞争问题,我们将其放在了单线程池顺序执行。同样也做好成功率监控和优化策略的弹缩。
模板预创建:
CyberT在渲染搭建页面时时往往会先去请求或读取组件模板以生成相应区块的View。我们制定了模板预创建的初始化任务,将模板文件和组件的缓存数据提前生成为View并放入缓存池,减少后续IO等待带来的刷新对资源的浪费,把组件生成时间压缩至微秒级。
3、渲染数据管控/刷新管控
为了尽快达到让用户可交互的目的,1688目前采用了优先渲染缓存数据,等网络数据回调后再进行diff刷新的渲染策略。此处的管控其实是对代码的合理性排查,代码逻辑中是否存在不合理的回调导致页面频繁刷新用户不可交互?是否存在缓存数据尚未渲染完成,网络数据强行刷新导致用户可交互时间后延?我们对上述问题进行了收拢,强控可交互前后的渲染逻辑,防止渲染过程中逻辑上的不合理造成资源浪费。在优化初期,将祖传代码中的不合理之处变为合理,往往能带来意想不到的收益。
启动性能弹缩
上文讲到,在进行首页展示优化的过程中,部分策略存在成功率的问题,通过线下机型观察和线上数据对比我们发现,对于CPU能力较弱的低端机型来说,View的预生成和预创建会存在较大概率的缓存失败问题,导致时间片资源的浪费甚至负优化。因此对于长尾用户,我们设计了一套性能动态弹缩方案。整个方案的大体流程是根据用户机型评分/人群圈选/网络环境等,结合远端平台下发的配置,输入到客户端上的决策引擎,将决策策略存储在本地于下次启动生效。并观察线上View Cache成功率、低端机启动时长、不同人群启动时长等指标验证策略收益。
例如我们在低端机上我们采用了关闭预加载预生成能力、控制同屏Video播放数、gif动图控制、耗时操作WebView Init/Flutter Init等任务后延至idle 5s后场景等策略以优先保证低端机上的体验。在高端机型上不仅开启相关策略以保证优化策略的收益最大化,并且在不成为阶段耗时瓶颈的情况下对营销能力/营销页面进行预载以快速响应营销策略,在用户体验与业务转化中寻找平衡点。
如何防控
我们沉淀了启动容器:亚索。在容器侧,具备了线上监控、研发卡口、测试平台集成的能力,可以在开发到上线之前的各个阶段进行把控,防止任务腐化。首页侧,一部分优化是基于CyberT容器开发,容器侧本身做了卡口,还有一部分是首页本身的优化,借助于我们正在开发的全链路日志体系,在关键位置做了切面埋点,具备了监控的能力。在出现异常的时候,能及时的通知到我们。
在开发期,我们使用git管理代码仓库,关于启动库、启动池相关的仓库权限收拢,改动上线可明显感知,配合代码review机制,减少了核心链路出问题的可能性。同时启动编排类和启动池类由对应的脚本生成,脚本内容简单易维护,也可以降低维护成本。
效果
截止本文撰写之日,阿里巴巴APP启动时间从八九月份的3s左右,优化到了目前的1.9s,下降了36.67%。
冷启跳失率和八九月份的数据相比,下降了75%。
对B类买家用户和潜在B类买家用户的下钻分析发现,整体数据优于大盘,后续会有人群优化策略。
随着弹缩策略的上线,针对机型、人群、环境等策略下发,还会有更好的效果。
Android 学习笔录
Android 性能优化篇:https://qr18.cn/FVlo89
Android Framework底层原理篇:https://qr18.cn/AQpN4J
Android 车载篇:https://qr18.cn/F05ZCM
Android 逆向安全学习笔记:https://qr18.cn/CQ5TcL
Android 音视频篇:https://qr18.cn/Ei3VPD
Jetpack全家桶篇(内含Compose):https://qr18.cn/A0gajp
OkHttp 源码解析笔记:https://qr18.cn/Cw0pBD
Kotlin 篇:https://qr18.cn/CdjtAF
Gradle 篇:https://qr18.cn/DzrmMB
Flutter 篇:https://qr18.cn/DIvKma
Android 八大知识体:https://qr18.cn/CyxarU
Android 核心笔记:https://qr21.cn/CaZQLo
Android 往年面试题锦:https://qr18.cn/CKV8OZ
2023年最新Android 面试题集:https://qr18.cn/CgxrRy
Android 车载开发岗位面试习题:https://qr18.cn/FTlyCJ
音视频面试题锦:https://qr18.cn/AcV6Ap