启动速度优化的意义
Android性能优化三把板斧:稳定性、内存、启动速度、包体积,可以没做过,但必须要了解其中的知识
应用的启动速度是用户使用时产生的第一印象,从心理学的角度,对于秒开的应用,用户更容易产生黏性和依赖。如果点击应用图标后,等待很久才看到首帧画面显示,会带来极差的用户体验,甚至流失这个用户。
应用启动流程
根据应用启动前后的状态不同,可分为三种启动方式。
冷启动
点击应用图标,创建应用进程,进行一系列初始化,直至应用首屏界面完全展示。通常我们讨论的启动速度就是指冷启动速度。关于冷启动的过程,可以阅读这篇文章《Android从点击应用图标到首帧画面显示全过程》。
过程涉及到Application、Activity的生命周期,这也是我们业务上进行优化的主要对象。
热启动
应用从后台切换回前台。
温启动
应用从后台切换回前台,只重走Activity
生命周期,不需要再次创建进程。
启动耗时检测
开发环境的启动耗时检测
Logcat
在Logcat中过滤Displayed
,可以查看到应用的冷启动耗时。
adb shell
adb shell am start -W [packageName]/[Activity全路径]
,如:
bash
adb shell am start -W pro.lilei.leiapp/.MainActivity
可以得到3个时间:
ThisTime
:当前Activity的启动耗时TotalTime
:所有Activity的启动耗时------一般以这个时间为准,包括【创建应用进程+应用进程初始化+Activity初始化+界面绘制】WaitTime
:AMS启动Activity的总耗时
生产环境的启动耗时检测
手动函数打点
最朴实的实现方式,通过记录函数的开始和结束时间戳,相减得到函数耗时。可以用一个单例对象来管理。
对于应用冷启动来说,记录的时间点:
- 开始计时:
Application.attachBaseContext
函数结束处 - 结束计时(页面初始化完成,但不一定有数据):
Activity.onCreate
函数结束处
这会统计出Application和Activity的初始化时间,但并不意味着此时用户可以交互or界面有数据显示。如果要记录用户交互时间,可以覆写Activity.onWindowFocusChanged
函数,对于RecyclerView的场景,则可以在onBindViewHolder
时通过添加ViewTreeObserver
,在onPreDraw
时作为可操作点,来监听条目内容显示。
java
if (helper.getLayoutPosition() == 1 && !mHasRecorded) {
mHasRecorded = true;
helper.getView(R.id.item_search_pager_group).getViewTreeObserver().addOnPreDrawListener(new ViewTreeObserver.OnPreDrawListener() {
@Override
public boolean onPreDraw() {
helper.getView(R.id.item_search_pager_group).getViewTreeObserver().removeOnPreDrawListener(this);
LogHelper.i("FeedShow");
return true;
}
});
}
AOP打点
利用AspectJ,在关键函数前后打点,有几个关键概念:
- 切面Aspect:在代码中的横切面,也是我们代码的切入点
- 连接点JointPoint:被拦截到的字段、方法、构造器等
- 切入点PointCut:具体切入的位置
- 通知Advice:发生拦截后要执行的代码,分为前置Before、后置After、环绕Around三种
举个例子,如果我们对Application类中所有函数进行拦截并计算它们的运行耗时:
java
@Aspect
public class ApplicationAop {
@Around("call (* pro.lilie.leiapp.BaseApplication.**(..))")
public void getTime(ProceddingJointPoint jointPoing) {
Signature signature = jointPoint.getSignature();
String name = signature.toShortString(); // 函数名
long time = System.currentTimeInMillis(); // 函数执行前的时间戳
try {
jointPoint.proceed();
} catch (Throwable t) {
t.printStackTrace();
}
long costTime = System.currentTimeInMillis() - time; // 函数耗时
Log.i(TAG, name + " cost" + costTime);
}
}
相比于手动函数计时,AOP的优点是无侵入性,易于管理和维护。
启动耗时分析工具
TraceView
使用方式有以下两种:
- 代码中控制开始(
Debug.startMethodTracing
)和结束(Debug.stopMethodTracing
),生成.trace
文件,在Android Studio的Profiler中进行查看 - 直接在AS的Profiler中进行抓取
CPU耗时和方法耗时:前者指执行函数所消耗的CPU时间片,后者指执行函数总耗时。前者<=后者
TraceView工具有如下特点:
- 用图形的方式展示执行时间、调用栈
- 信息全面,包含所有线程
- 运行时工具自身开销严重,并不能真实反映线上状态
- 找到最耗费时间的路径:
Flame Chart
、Top Down
- 找到最耗费时间的节点:
Bottom Up
主要用于热点分析,找出单次执行最耗时的方法,以及执行次数最多的方法。
SysTrace
在系统关键链路(如SystemServer、虚拟机、Binder驱动)插入一些Label
,然后通过Label
的开始和结束计算某个核心过程执行时间。Android Framework
里的一些重要模块都已经插入了Label
信息,用户App中也可以添加自定义Label
。
SysTrace工具有如下特点:
- 结合Android内核数据,生成
Html
报告 - 系统版本越高,Framework中的
Label
就越丰富 - 必须手动缩小范围,以聚焦核心流程
可记录系统关键方法耗时,主要用于分析绘制性能方面问题。
启动优化方案
以下是业内常见的启动优化套路,建议根据业务实际情况,组合进行使用:
- 主题设置
- 懒加载
- 异步初始化
- 延迟初始化
- Multidex预加载优化
- 类预加载优化
- WebView启动优化
- 页面数据预加载
- 闪屏、主页绘制优化
1.主题设置
在点击图标后,应用进程初始化完成之前,有时我们会看到界面闪过一个白屏,这是由于应用Activity没有设置主题导致的。为了解决白屏,有两个实现思路,直接用代码体现。
xml
<!-- 思路1:透明主题 -->
<style name="AppTheme" parent="Theme.AppCompat.Light.DarkActionBar">
<item name="android:windowFullscreen">true</item>
<item name="android:windowIsTransulent">true</item>
</style>
<!-- 思路2:闪屏图片 -->
<style name="AppTheme" parent="Theme.AppCompat.Light.DarkActionBar">
<item name="android:windowBackground">@mipmap/launch</item>
<item name="android:windowFullscreen">true</item>
<item name="android:windowContentOverlay">@null</item>
</style>
思路1是设置透明主题,这样的好处是在Activity首屏展示出来前,用户看到的都是桌面,但会给用户带来"手机反应慢"的误解。
思路2是设置过度图片,这样应用启动后会立刻展示图片,当Activity初始化完成后显示Activity的内容。
思路1&2都是治标不治本的处理手段,只是主观上让用户感觉启动变"快"了,客观上并没有减少哪怕1s的启动耗时,不仅如此,在中低端机上(<6.0
)甚至还会因为设置主题导致启动变得更慢。因此,不建议用主题的方式来提升启动体验,如果一定要使用的话,需要进行版本区分。
2.懒加载
典型实现方式是饱汉式
(一生成就吃饱)的单例,只有当用到某个模块时才对其进行初始化。
3.异步初始化
Android开发中常用的异步手段有多种,按照推荐顺序排列:协程、线程池、AsyncTask、IntentService、HandlerThread、Thread等。不建议直接使用线程,因为线程的创建、销毁、切换都会占用大量的CPU时间,产生性能损耗。
常用的实现方式是,对于IO密集型任务、CPU密集型任务各创建一个线程池,这两个线程池采取不同的配置。
- IO密集型:文件读写、网络请求等,不占用CPU,并发量可以很大
- CPU密集型:复杂的计算、循环操作,核心池取决于CPU核心数
配置 | IO密集型任务线程池 | CPU密集型任务线程池 |
---|---|---|
核心线程数 | 0 | CPU核心数-1 |
最大线程数 | Integer.MAX | CPU核心数-1 |
线程保活时间(s) | 60 | 5 |
任务队列 | SynchronousQueue | LinkedBlockingQueue |
使用线程时应当注意,核心思想是线程收敛,使用线程池进行操作,同时对每个线程设置其name
,便于追溯,避免单独创建线程对象。
异步启动器的设计思想
要设计一个异步启动器,需要解决以下核心问题:
- 任务抽象 :把每一个初始化任务抽象成一个
Task
- 组织有向无环图:梳理全部任务,哪些之间存在先后依赖,以及每个任务是要在主线程还是子线程执行
- 依赖关系:将存在先后依赖的任务添加到同一个子线程
- 主线程&子线程:使用MainHandler把任务传递给主线程,使用线程池来处理子线程任务
- 并行:对于不存在依赖关系的初始化任务,放在子线程并行处理
如果存在多重依赖(任务C同时依赖并发的两个任务A、B),可以使用CountDownLatch锁来实现。
4.延迟初始化
对于一些必须要在主线程执行的初始化任务,将它们从Application.onCreate
中抽出,放在Application的其它生命周期中进行。
常规方案
在闪屏页的停留时间(如广告页)进行初始化,优点是实现简单,缺点是时机不好控制,用户可以点击"跳过"来减少闪屏页的停留,如果任务被推迟到了首页时候,用户会有明显卡顿(如滑动列表)。
优化方案:延迟启动器
核心思想是利用IdleHandler特性,在CPU空闲时执行延迟任务初始化。Android主线程是运行在一个无限的循环当中的,当界面处于闪屏广告页时,用户操作极为有限,消息消息队列大概率处于Idle状态,此时正是执行初始化任务的好时机。
对于IdleHandler原理,可以阅读这篇文章掘金 IdleHandler的原理分析和妙用
以下就是典型的在闪屏页添加IdleTask
的代码,onWindowFocusChanged
是页面绘制完成可以交互的时间点。
java
@Override
public void onWindowFocusChanged(boolean hasFocus) {
super.onWindowFocusChanged(hasFocus);
GlobalHandler.getInstance().getHandler().post((Runnable) () -> {
if (hasFocus) {
DelayInitDispatcher delayInitDispatcher = new DelayInitDispatcher();
delayInitDispatcher.addTask(new InitOtherTask())
.start();
}
});
}
5.MultiDex预加载优化
MultiDex使用
Android 5.0之前不支持加载多个Dex,所以google提供了MultiDex
支持在运行时加载和使用多个Dex,开启MultiDex
的步骤是:
- 在项目的
gradle
文件中开启开关 - 让自定义的
Application
继承MultiDexApplication
- 在
attachBashContext()
中执行MultiDex.install(this)
gradle
android {
defaultConfig {
multiDexEnabled true
}
}
xml
<application
android:name="androidx.multidex.MultiDexApplication"
... />
</application>
MultiDex原理
APK编译时,由dx工具将.class
文件编译成dex文件,期间使用--multi-dex
参数拆分成多个dex文件,最终一起打包到APK当中。
在运行时,执行MultiDex.install(this)
,会进行当前虚拟机版本判断:
- Android 4.4及以下 :系统层面不支持多
dex
文件处理,将2级dex
文件解压缩到应用特定目录,得到2级dex
列表,注入到PathClassLoader
中 - Android 5.0及以上 :应用安装时,多个
dex
被合并成一个oat
文件,运行时直接使用oat
文件
在Dalvik
首次冷启动的过程中,生成ZIP
和ODEX
文件的过程都是比较耗时的,如果一个 APP 中有很多个 Secondary DEX
文件,就会加剧这一问题。尤其是生成ODEX
的过程,Dalvik 虚拟机会把DEX
格式的文件进行遍历扫描和优化重写处理,从而转换为ODEX
文件,这就是其中最大的耗时瓶颈。
MultiDex优化思路
- 模块拆分:将首页用到的类放入1级dex,将二级页面放入2级dex,调用时先判断是否完成install
- 多线程加载:对于4.4及以下,启动工作线程执行odex任务,同时主线程Application进入while循环,监听odex任务是否完成
- 抖音BoostMultiDex方案:通过hook dalvik虚拟机代码,在冷启动时直接使用原始dex,同时另启动一个进程进行dex的opt工作。提速4.4及以下版本的冷启动速度达80%
6.类预加载优化
在Application中提前异步装载耗时较长的类,可以通过替换系统ClassLoader来记录类的加载时间,从而找出哪些类耗时较长。
Class.forName()
:只加载类本身及其静态变量的引用类Class.newInstance()
:可以额外加载类成员变量的引用类
7.WebView启动优化
WebView的首次创建耗时较多,因此可以提前初始化,通过WebView对象池来减少新建过程带来的消耗的消耗。
8.页面数据预加载
- 在闪屏时预加载主页数据
- 在主页时预加载二级页数据
9.闪屏、主页绘制优化
- 减少层级嵌套,避免过度绘制
- 合理使用
merge
节点(将子节点直接添加到parent
中,可以减少中间一个层级)- 只能用在布局xml的根元素
- 必须指定一个ViewGroup作为父元素,并且设置
attachToRoot=true
- 使用
ViewStub
进行布局懒加载,仅当布局可见时才加载它- 注意:
ViewStub
只能加载一次,之后ViewStub
对象会被置为空,不适用于按需显示隐藏的场景 - 只能用来加载一个布局文件,不是某个
View
- 注意:
启动优化面试问答思路
Q:讲讲你对启动速度优化的理解?
(总-先上结论和答题框架)启动速度是应用性能指标里非常重要的一环,我做性能优化时在这方面有过一些实践和探索,以下是我的经验和理解。
(分-明确概念)通常我们讲启动速度,指的是冷启动的速度,具体的话就是从Application.attachBaseContext
到Activity.onCreate
完成所消耗的时间。(引出问题-启动流程)
(分-优化思路)首先,在优化前应当记录启动耗时,在开发环境可以通过Logcat
查看,在生产环境则需要借助打点来计算。然后,使用TraceView
和SysTrace
这样的工具,分析过程中耗时的方法。找出耗时的方法后,就是采取一些手段进行实际优化。常用的方式有主题设置、懒加载、异步初始化、延迟初始化、MultiDex预加载优化、类预加载优化、页面数据预拉取等。
(总-引出下文)接下来我想讲一下我们的一次具体实践。(引出下一问题)
Q:在你们实践当中是如何做的?
(强调工程意识,数据驱动)首先进行任何性能优化的依据,一定是数据驱动。我们先通过埋点、AOP框架等,对应用启动耗时进行统计。结合排查代码后,发现主线程中初始化的任务太多,对这些任务进行梳理后,把一些放在异步线程中去进行初始化(引导提问-异步初始化实现思路)。此外,我们还发现有些任务的优先级并不高,所以把这些任务放在闪屏页进行延迟初始化(引导提问-延迟初始化,IdleHandler
方案)。通过IdleHandler
我们实现了一个延迟初始化的框架,在主线程空闲时间进行初始化,以减少启动耗时导致的卡顿现象。
最后,为了长期保持效果,防止启动速度劣化,我们还开发了一个启动器框架,结合埋点进行线上数据监控。
Q:讲一讲异步初始化的设计思路?
最初我们采用的是普通的异步方案,直接new出Thread,并设置线程的优先级,在Application.onCreate
函数中进行异步初始化。但由于这样无法对线程创建进行约束和复用,我们后面使用线程池的方式对创建进行管理。但还有一些复杂的场景,比如初始化任务之间存在先后依赖关系,我们又思考新的一个解决方案。能够完美滴解决我们前面遇到的这些问题。
这个方案就是我们现在使用的启动器,在这个框架里,把每一个启动任务抽象成Task
,它有几个关键属性,比如所运行的线程、存在哪些依赖等。所有Task
构成了一个有向无环图
,接着用一个异步队列
进行,这个异步队列关联到的线程数是跟处理器核心数
强相关的,从而最大程度保证我们的主线程、工作线程都能充分利用CPU。
Q:期间使用了哪些工具?
CPU Profiler
:可以在AS中开启,能通过可视化的方式展示函数调用次数和时间,主要可以用来找出启动过程中哪些函数调用的次数最多、耗时最多;以及哪些调用路径的耗时最多SysTrace
:系统默认地在关键链路中插入一些Label,进而计算出某个核心过程的耗时。Android Framework一些核心模块都已经插入了Label,用户也可以在自己的应用中自定义添加一些Label。可以生成Html报告,重要用于分析绘制性能方面的问题AspectJ
:是切面化框架,可以无痛集成到项目中,统计一些关键函数的耗时,比如Application、Activity中各个onXXX的函数
Q:有没有遇到过疑难问题?
首先是延迟初始化的方案,一开始我们是采用在界面显示后用MainHandler
去post
一个Runnable
的初始化任务,单这里有个参数很难界定,就是delay
时长,因为不同的手机性能不同、网络条件不同,我们希望是在闪屏广告页进行延迟初始化,但实际上可能要等到首页加载出来后才去执行这个初始化逻辑。这会导致一个问题,就是用户滑动首页列表时,由于初始化任务正在跑,让用户滑动发生了卡顿。因此我们采用IdleHandler
来实现当CPU空闲时候才执行耗时任务,提升用户体验,避免因启动耗时任务而导致的页面卡顿。
(引出MultiDex优化方案)此外,对于低版本手机的初始化我也有一些技术上的理解,低版本手机上应用安装或者更新后,首次冷启动非常慢,这是因为系统进行dex
的opt
优化导致的。有延迟加载、异步加载和字节的Boost
方案几种处理方式。
最后,性能优化尤其是启动速度优化是一个需要长期坚持的工作,需要一些机制来保证后续版本迭代,启动速度不会因为新功能的引入而劣化。有以下几个方式。
- 【上线前】限制对重点类的修改,如Application,跟修改人确认新引入的代码耗时多少、能否异步、能否延迟或者懒加载
- 【上线后】完善线上监控,不仅是整体的启动时间,粒度还要细化,比如Application中onCreate、onAttachBaseContext的耗时,Activity启动到页面可交互之间的耗时等等。