Android APP启动优化
应用启动的类型
冷启动
从点击应用图标到UI界面完全显示且用户可操作的全部过程。
启动流程
Application 阶段、handle message 间隙、Activity 阶段和数据加载间隙,全路径各部分细分涵盖的内容如下图所示:
APP 进程由 zygote 进程 fork 出来后会执行 ActivityThread 的 main 方法,该方法最终触发执行bindApplication,这也是 Application 阶段的起点;然后是我们在应用中能触达到的attachBaseContext阶段,4.x 的机型在该阶段具有较长的 MultiDex 耗时可以做针对性优化(可参考“抖音 BoostMultiDex 优化实践”),本阶段也是最早的预加载时机;接下来是installProvider阶段,很多三方 sdk 借助该时机来做初始化操作,很可能导致启动耗时的不可控情形,需要按具体 case 优化;此后就到了 Application 的onCreate阶段,这里有很多三方库和业务的初始化操作,是通过异步、按需、预加载等手段做优化的主要时机,它也是 Application 阶段的末尾。
在Application 阶段和 Activity 阶段之间往往会不可避免地被插入很多 post 到主线程的消息及相应待执行任务,这是拉长启动耗时的另一不可控问题点,需要加以监控治理或通过消息调度优化来尽量减小此间隙。
在来到 Activity 阶段后,首先经历的是其onCreate生命周期,这里涵盖了首屏业务优化的主要场景也是开启异步并发的主要时机,在其中有个重要的 setContentView 方法会触发 DecorView 的 install,可尝试对 DecorView 的构建进行预加载;后续自然来到View 构建的阶段,该阶段在抖音上相当耗时,可采用异步 Inflate 配合 X2C(编译期将 xml 布局转代码)并提升相应异步线程优先级的方法综合优化;再来到View 的整体渲染阶段,涵盖 measure、layout、draw 三部分,这里可尝试从层级、布局、渲染上取得优化收益。
最后是首屏数据加载阶段,这部分涵盖非常多数据相关的操作,也需要综合性优化,可尝试预加载、缓存或网络优先级调度等手段。
此外,针对全路径所有阶段还可以实施通用性的优化项,如:启动任务调度框架、类重排、IO 预加载、全局通用性框架优化等。
启动耗时成因分析
启动耗时成因分析:所有的耗时均因代码运行时不合理地消耗系统资源产生,而不合理的耗时点正是需要做归因分析之处。抖音按照不合理耗时点消耗的主要系统资源类型划分出五大成因,分别是:CPU Time、CPU Schedule、IO Wait、Lock Wait 和 IPC,下面分别对各成因进行剖析:
- CPU Time 指占用 CPU 进行计算所花费的时间绝对值,中断、挂起、休眠等行为是不会增加 CPU Time 的,所以因 CPU Time 开销占比高导致的不合理耗时点往往是逻辑本身复杂冗长需要消耗较多 cpu 时间片才能处理完。比较常见的高 CPU 占用是循环,比如抖音启动时遇到过一个 so 加载耗时,最后定位原因是在解压 so 的时候,遍历 ZipEntry 的次数过多导致,一个可行的优化策略就是可以把 so 所在的 ZipEntry 提前,遍历完 so 的 ZipEntry 之后可以提前中止遍历,而不需要遍历剩下的无效 ZipEntry。除循环之外,反射也是导致 CPU Time 的重要原因,像在序列化/反序列化、View Inflate 时,都有大量的反射操作,反射的耗时主要是字符串去查找 Method 或者 Field,这个优化策略也可以考虑提前查找 Method 和 Field 缓存起来,或者是通过内联来降低 Field 数量等。另外一个常见的 CPU 耗时是类加载,类的加载过程包括:Load,从 Dex 文件里读取类的信息,可通过类重排优化;Verify,验证指令是否合法等,通过关掉 Class Verify 可以优化该过程,同时高版本的 vdex 也是为了优化 verify 过程而设计,在 dex2oat 的时候做 verify,verify 之后的结果保存成 vdex,后续只需要加载 vdex;Link,给 Field、Method 分配内存,按照名字排序以方便后续反射的时候查找 Field、Method 等,这个过程的优化,art 虚拟机采用了 ImageSpace 的方案进行了优化,将 Link 后的内存保存为 image 文件,后续可以直接 load 这个 image 文件,省去了 Link 过程;Init,类的初始化。
- CPU Schedule 在分析时主要针对主线程,是指主线程处于可执行状态但获取不到 cpu 时间片,这类耗时可能和线程调度等有关,最终导致分配给主线程的 cpu 时间片不足以及时处理完其内任务。由于主线程的线程优先级比其他线程的优先级要高很多,通常影响并不大,事实上抖音做了线上用户的启动耗时统计,这部分的耗时占比也是不大的。不过有一个场景需要关注,就是渲染,渲染是需要 RenderThread 提交 GPU 的渲染命令,而 RenderThread 并没有主线程那么高的优先级,因此比较容易受 CPU 的负载的影响,导致渲染耗时,这个对于启动来说影响并不算大,启动只有一次首页的渲染,占整体时间的比例不算大,但对于流畅度的影响就会比较大。这类耗时的优化主要还是从降低 CPU 的负载的角度考虑,比如业务降级、业务打散等手段。抖音还通过对 RenderThread 优先级的提升优化,拿到了不错的收益。
- IO Wait 指发生了 IO 操作需要等待 IO 返回结果,这类耗时可能发生在读取资源和文件,类加载,甚至在内存不足时的 PageFault 都会导致 IO Wait。Resources 的相关的操作耗时,主要是需要从 apk 里读取资源文件,优化策略可以有预加载、资源重排、资源异步加载等。类加载的 IO Wait 和 Resources 类似,也可以通过类的重排、预加载等优化方案。文件读写导致的 IO Wait 又分为业务文件和系统文件,业务文件指业务逻辑的读写文件,一般都可以通过异步来解决,而系统文件的例子是 dex 的读写,抖音的 IO Wait 很大一块是它贡献的,目前的思路还是做 dex 的重排和 IO 的预读来尝试优化。
- Lock Wait 也是主要针对主线程,指其处于等锁状态,等待被其他线程唤醒或自己超时唤醒,导致这类耗时的问题种类多样,大体也是可以分为业务锁和系统锁,业务锁主要是被主线程等待的业务逻辑未能及时处理完,优化思路一般是移除主线程的锁等待逻辑或者加快被等待的业务逻辑的执行速度。系统锁主要有:String InternTable Lock,ClassLinker Lock,GC Wait Lock 等,目前抖音正在尝试优化这几类的锁耗时。
- IPC 指进程间通信,操作系统大都含有相应的机制,Android 中所特有的 IPC 机制是 Binder,由于进行 IPC 调用往往需要等待通信结果本质上这也算是一种 Lock Wait,但 Android 特有 Binder 机制所以单独列出,这类耗时可采用减少或替代 Binder 调用等手段来优化。
热启动
因为会从已有的应用进程启动,所以不会再创建和初始化Application,只会重新创建并初始化Activity。
启动耗时检测
开始时间
- /proc/self/stats starttime (接近体感)
- Process.getStartElapsedRealTime (7.0以后才提供,对低版本支持差)
- Application的attachBaseContext(工程最早能介入的地方,有一定指导意义)
结束时间
- onWindowFoucsChange (Activity可见,内部不可见)
- onResume (Activity完全不可见)
- 核心数据落地页加载显示完成(手百的Feed,搜索页,或者小程序)
指标建设
几种场景分开统计:
开屏广告、保活进程、push 拉起、deeplink 拉起、启动期间退后台、新用户启动等场景进行过滤或分开统计。
启动性能指标统计
在统计性能指标时有个关键点往往被大家忽略,就是分位值的概念,由于平均值相对更通俗易懂且对大盘突发问题敏感往往作为首要统计指标被关注,但其存在波动较大不利于大盘监控以及难以体现不同分位机型启动性能差异的问题,而分位值更有利于全面监控且各分位波动相对较小,此外对于低端机的性能问题能够更好地显现出来,有助于做专项优化,在优化抖音的启动性能时我们会重点关注 50 分位和 90 分的性能指标,不过分位值也存在一些缺点,比如:概念理解起来相对复杂、个别 bad case 分散到各分位不容易体现出来等,因此比较好的实践是:日常优化主要统计分位值,平均值作为辅助完善监控体系。
优化工具
SysTrace+插桩+自动化分析
总览性能表现,展现形式类似TraceView火焰图,可以查阅:应用中方法的调用堆栈,CPU调用信息,锁信息、I/O等信息
自动化分析是基于GooglePerfetto工具实现。Perfetto是一款工业品质的性能检测和跟踪分析的开源工具集合,Android10引入。Perfetto提供了强大的基于SQL分析的库,并且提供python版本的API,在此基础上使用python实现了一套自动化分析Trace的脚本。
ThorHook框架
根据监控项选择合适Hook插件,监控某一指定维度,如线程、图片使用、内存等维度。支持:JavaHook,NativeHook
防劣化的实践
防劣化的体系建设是个比较复杂的工程,要做好是有非常大的挑战的。从最早的线下手动的分版本测试开始,经过了逐步的摸索优化,演变到当前涵盖了代码提交时静态检测、线下自动化劣化测试和归因、灰度劣化发现和归因、线上常态化的劣化监控和归因。防劣化是一个漏斗,从代码提交阶段到线下测试阶段,再到灰度发布阶段,再到线上版本发布阶段,我们希望劣化能够更前置的发现,每个环节都尽可能的发现解决更多的劣化,保证更少的劣化被带到线上。
防劣化的有几个难点:一是劣化检测的准确率和召回率,为了更多更准确的发现劣化;二是劣化的准确归因,发现劣化之后,如果不能精准的指出劣化的原因,需要投入比较多的人力资源和时间定位劣化原因,影响劣化解决的效率;三是劣化的修复,如果是比较严重的劣化,可以采用阻塞发版限期解决的方式,是比较容易推进解决的。但是从实践来看,当启动优化到了深水区之后,优化的速度已经比较缓慢,需要关注几十毫秒级别的劣化了,假设我们解决了一二两个难点,发现了这些轻微的劣化,但是如何推进业务去解决这些小劣化也同样是一个难题。我们需要能够量化出这些劣化对业务的影响,针对不同的劣化量级,和业务对齐优先级,确定标准的劣化修复流程,才能够保证劣化不会被带到线上影响大盘用户。
优化实践
启动优化实践总结来看比较好的优先级策略是按照“投入产出比”来排布优化项,顾名思义:投入人力越少但优化幅度越大的优化项越应该排在前期,因为所有的性能优化历程都势必会经历从高收益到低收益的变化,那么相应的在排布优化项的前后顺序时也需顺应此规律,最终呈现的态势即为:前期以小成本快速降低大盘启动耗时,后期逐步提高投入突破各个瓶颈型耗时点(更后期大规模重构仅能减少几十毫秒启动时间的情形也应在预期之内),全过程同期加强防劣化机制,最终做到可持续优化。
- 正面优化:删减非必要的启动逻辑、开屏页与首页 Activity 合并、获取进程名从 IPC 转反射方式等;
- 按需优化:ContentProvider 中过早初始化逻辑转为使用时初始化、多进程由启动时加载转为使用时或特定场景触发加载等;
- 延迟优化:4.x 机型延迟执行 Multidex.install 中的 Odex 操作、主线程消息队列中非启动必要消息延迟执行、启动路径非高优业务逻辑延迟初始化等;
- 运行时优化:CPU 提频、语言层面优化(内联、替换反射、避免用 Kotlin 的 Range 循环)、关闭 Verify Class、4.x 机型抑制 GC、主动触发 AOT 编译、资源重排、类重排、dex 重排等;
- 异步优化:异步预加载(ShardPreference、实例化对象)、异步 inflate view、线程收敛等;
- 降级优化:极速版、组件化降级、非必要耗时逻辑按人群/地区降级等;
- 综合优化:启动任务调度框架、启动路径重构、前后台启动任务精细化重排、后台负载优化等,这些优化项属于前述优化思想的综合应用,一般不局限于单方面的优化。
具体落地
attachBaseContext 优化
获取进程名优化:
之前通过proc目录或者activityManager.runningAppProcesses获取,activityManager.runningAppProcesses是通过IPC方式去获取进程名。
改为Application.getProcessName(),内部是通过反射的方法去获取进程名,该方法需要API28以上。反射的效率高于IPC。
ContentProvider 优化
ContentProvider 即使在没有被调用到,也会在启动阶段被自动实例化并执行相关的生命周期。在进程的初始化阶段调用完 Application 的 attachBaseContext 方法后,会再去执行 installContentProviders 方法,对当前进程的所有 ContentProvider 进行 install。
这个过程将会对当前进程的所有 ContentProvider 通过 for 循环的方式逐一进行实例化、调用它们的 attachInfo 与 onCreate 生命周期方法,最后将这些 ContentProvider 关联的 ContentProviderHolder 一次性 publish 到 AMS 进程。
ContentProvider 这种在进程初始化阶段自动初始化的特性,使得在其作为跨进程通信组件的同时,也被一些模块用来进行自动初始化,这其中最为典型的就是官方的 Lifecycle 组件,其初始化就是借助了一个叫 ProcessLifecycleOwnerInitializer 的 ContentProvider 进行初始化的。
LifeCycle 的初始化只是进行了 Activity 的 LifecycleCallbacks 的注册耗时不多,我们在逻辑层面上不需要做太多的优化。值得注意的是,如果这类用于进行初始化的 ContentProvider 非常多,ContentProvider 本身的创建、生命周期执行等堆积起来也会非常耗时。针对这个问题,我们可以通过 JetPack 提供的 Startup 将多个初始化的 ContentProvider 聚合成一个来进行优化。
Startup 虽然同样是使用 ContentProvider 来进行库的初始化,但是会将应用中所有接入了Startup 的库的操作通过 <provider> <meta-data> 合并到一个 ContentProvider 中进行初始化。
onCreate阶段优化
调度优化
随着启动场景承载业务逐步庞大,手百逐渐成长为承载业务最多,体量巨大的航母级移动端应用,如何平衡启动性能与庞大业务的预加载,是启动性能优化中面临的较大难题。调度机制逐渐衍生出来,处理启动过程不同业务的预加载需求。
- 静态调度
- 线程池:根据启动阶段、任务依赖关系、任务属性、CPU核数等因素设计线程池,控制线程数量,减少线程切换带来的开销
- 延迟调度:分为主线程延时子线程延时,主线程延时依赖IdleHandler回调,子线程延时依赖首页绘制完成后回调,单线程池模型
-
分级调度
依据机型评分和分级配置,做到不同配置机器不同效果 -
个性化加载
依据机型评分和用户画像,做到千人千面 -
抖音优化
- 基于落地页进行调度:抖音启动除了进入首页,还有授权登录、push 拉活等不同的落地页,这些不同的落地页在任务的执行上是有比较大差异的,我们可以在 Application 阶段通过反射主线程消息队列中的消息获取待启动的目标页面,基于落地页进行针对性的任务调度;
- 基于设备性能调度:采集设备的各类性能数据在后台对设备进行打分与归一化处理,将归一化之后的结果下发到端上,端上根据所在的性能等级进行任务的调度;
- 基于功能活跃度调度:统计用户对各个功能的使用情况,为用户计算出每个功能的一个活跃度数据,并将他们下发到端上,端上根据功能活跃度高低来进行调度;
- 基于端智能的调度:在端上通过端智能的方式预测用户的后续行为,为后续功能进行预热等;
启动功能降级:对于部分性能较差的设备与用户,对启动阶段的任务、功能进行降级,将其延后到启动之后再去执行,甚至完全不执行,以保证整体体验。
Activity 阶段优化
SP优化
SharedPerence初次调用的时候需要读文件,系统会主动等待文件加载,此时会主动wait,耗时比较长,线程创建过多,一个SP就是一个线程的串建。替换为百度内部的UniKV。
锁优化
历史组件遗留问题,用了大量的synchronzied,重量级锁,优化后使用读写锁。
UI优化
LayoutInflate 进行 xml 加载包括三个步骤:
- 将 xml 文件解析到内存中 XmlResourceParser 的 IO 过程;
- 根据 XmlResourceParser 的 Tag name 获取 Class 的 Java 反射过程;
- 创建 View 实例,最终生成 View 树。
业务层面上,我们可以通过优化 xml 层级、使用 ViewStub 方式进行按需加载等方式进行优化,这些优化可以在一定程度上优化 xml 的加载时长,必要时候可以代码代替XML。
通用优化原则
后台任务优化
前面的案例基本都是主线程相关耗时的优化,事实上除了主线程直接的耗时,后台任务的耗时也是会影响到我们的启动速度的,因为它们会抢占我们前台任务的 cpu、io 等资源,导致前台任务的执行时间变长,因此我们在优化前台耗时的同时也需要优化我们的后台任务。一般来说后台任务的优化与具体的业务有很强的关联性,不过我们也可以整理出来一些共性的优化原则:
- 减少后台线程不必要的任务的执行,特别是一些重 CPU、IO 的任务;
- 对启动阶段线程数进行收敛,防止过多的并发任务抢占主线程资源,同时也可以避免频繁的线程间调度降低并发效率。
GC抑制
后台任务影响启动速度中还有还有另一个比较典型的 case 就是 GC,触发 GC 后可能会抢占我们的 cpu 资源甚至导致我们的线程被挂起,如果启动过程中存在大量的 GC,那么我们的启动速度将会受到比较大的影响。
解决这个问题的一个方法就是减少我们启动阶段代码的执行,减少内存资源的申请与占用,这个方案需要我们去改造我们的代码实现,是解决 gc 影响启动速度的最根本办法。同时我们也可以通过 GC 抑制的通用办法去减少 GC 对启动速度的影响,具体来说就是在启动阶段去抑制部分类型的 GC,以达到减少 GC 的目的。
google针对10以上的机型有改动,在fork出来的2秒内减少GC。
其他全局优化
- 高频方法优化:对服务发现(spi)、实验开关读取等高频调用方法进行优化,将原本在运行时的注解读取、反射等操作前置到编译阶段,通过编译阶段直接生成目标代码替换原有调用实现执行速度的提升;
- IO 优化:通过减少启动阶段不必要的 IO、对关键链路上的 IO 进行预读以及其他通用的 IO 优化方案提升 IO 效率;
- binder 优化:对启动阶段一些会多次调用的 binder 进行结果缓存以减少 IPC 的次数,比如我们应用自身的 packageinfo 的获取、网络状态获取等;
- 锁优化:通过去除不必要的锁、降低锁粒度、减少持锁时间以及其他通用的方案减少锁问题对启动的影响
- 字节码执行优化:通过方法调用内联的方式,减少一些不必要的字节码的执行,目前已经以插件的方式集成在抖音的字节码开源框架 Bytex 中(详见 Bytex 介绍);
- 预加载优化:充分利用系统的并发能力,通过用户画像、端智能预测等方式在异步线程对各类资源进行精准精准预加载,以达到消除或者减少关键节点耗时的目的,可供预加载的内容包括 sp、resource、view、class 等;
- 线程调度优化:通过任务的动态优先级调整以及在不同 CPU 核心上的负载均衡等手段,降低 Sleeping 状态和 Uninterrupible Sleeping 耗时,在不提高 CPU 频率的情况下,提高 CPU 时间片的利用率(由 Client Infrastructure-App Health 团队提供解决方案);
- 厂商合作:与厂商合作通过 CPU 绑核、提频等方式获取到更多的系统资源,以达到提升启动速度的目的;
(1)第三方库懒加载
按需初始化,如图片库的初始化等等。
(2)异步初始化
异步加载器
(3)延迟初始化
- 利用IdleHandler特性,在CPU空闲时执行,对延迟任务进行分批初始化。
(4)页面数据预加载
在主页空闲时,将其它页面的数据加载好保存到内存或数据库,等到打开该页面时,判断已经预加载过,就直接从内存或数据库取数据并显示。
(5)首页布局优化
闪屏优化,布局层级优化
(6)sp优化
(7)线程优化
统一使用优先级的线程池
(8)分机体验
高中低端机加载不同需求的业务
4.启动器
分级
- 基础框架任务
- 异步任务框架任务(主页加载完成后)
- IDLE闲时框架任务
- 个性化任务(对机型评分和用户画像进行个性化加载)
5.启动优化黑科技
(1)启动阶段抑制GC
启动时CG抑制,允许堆一直增长,直到手动或OOM停止GC抑制。(只支持Dalvik)
(2)CPU锁频
在Android系统中,CPU相关的信息存储在/sys/devices/system/cpu目录的文件中,通过对该目录下的特定文件进行写值,实现对CPU频率等状态信息的更改。
(3)数据重排
Dex文件用到的类和APK里面各种资源文件都比较小,读取频繁,且磁盘地址分布范围比较广。我们可以利用Linux文件IO流程中的page cache机制将它们按照读取顺序重新排列在一起,以减少真实的磁盘IO次数。
6.自己APP做的冷启动优化
- 每次主列表加载的数据变少
- 启动一个Service来延迟加载第三方库
- ViewStub延迟记载一些非必要的View
- 主ListView的Item使用代码布局