一.启动的分类
1.冷启动
比如设备开机后应用的第一次启动、系统杀掉应用进程后再次启动等。所以,冷启动的启动时间最长,因为和热启动方式相比,系统和我们的应用要做的工作最多,需要经过IPC调用通过ams去查询pms应用信息,在到通知Zygote fork进程启动、再到Application、Activity的创建等初始化过程在到绘制、显示界面。
2.热启动
热启动比冷启动简单得多且开销更低,在热启动时,系统会将Activity从后台切回到前台,如果应用的所有Activity仍旧驻留在内存中,那么应用可以避免重复对象初始化、布局加载和绘制。然而,如果应用响应了系统内存清理的通知清理了内存,比如回调 onTrimMemory(),那么这些被清理的对象在热启动就会被重新创建。
二.排查手段
1.adb指令查看耗时
我们可以通过adb shell am start -w 加全类名访问的形式查看启动耗时:
adb shell am start -W com.seven.performance/.MainActivity
Starting: Intent { act=android.intent.action.MAIN cat=[android.intent.category.LAUNCHER] cmp=com.seven.performance/.MainActivity }
Status: ok
LaunchState: COLD
Activity: com.seven.performance/.MainActivity
TotalTime: 490
WaitTime: 492
Complete
对于上面的打印分析如下:
TotalTime :表示应用启动的耗时,包括创建启动应用进程和入口Activity的启动耗时,但不包括前一个应用Activity pause的耗时(即所有Activity启动耗时)。一般我们主要关心这个数值,这个时间才是自己应用真正启动的耗时。
WaitTime :返回从其他应用进程 startActivity() 到应用首帧完全显示这段时间,即总的耗时,包括前一个应用Activity pause的时间和新应用启动的时间(即AMS启动Activity的总耗时)
多次重复查看耗时:
adb shell am start -S -W -R 10 com.seven.performance/.MainActivity
-S:关闭Activity所属的App进程后再启动Activity
-W:等待启动完成-R:重复次数
2.traceView查看耗时
Debug.startMethodTrace()是系统给我们提供的可以抓取耗时的一个方法,下面是一个使用案例:
class MainActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
Debug.startMethodTracing("dosth");
doSth()
Debug.stopMethodTracing();
}
fun doSth(){
Thread.sleep(1000);
}
}
它会在/sdcard/Android/data/packagename/files;生成一个trace文件,我们把trace文件导出来,直接拖到as中查看:
3.logcat查看耗时
这个就比较简单通过过滤Displayed就可以看到耗时时间了。
4.工具类查看耗时
object LauncherTimerUtils {
private var time: Long = 0
fun startTrace() {
time = System.currentTimeMillis();
}
fun endTrace() {
Log.d("Trace", "costTime:${System.currentTimeMillis() - time}")
}
}
class MainActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
Debug.startMethodTracing("dosth");
LauncherTimerUtils.startTrace()
doSth()
LauncherTimerUtils.endTrace()
Debug.stopMethodTracing();
}
fun doSth(){
Thread.sleep(1000);
}
}
三.常见优化手段
1.启动白屏问题
热启动和冷启动展示在屏幕的行为相同:系统进程展示一个空白屏幕直到应用绘制完成显示出Activity。那为什么会有白屏?
其实,白屏现象很容易理解,在冷启动一个 APP 的时候,启动页还没完成布局文件的加载,此时显示的是 Window 窗口背景,我们看到的白屏就是 Window 窗口背景。对于白屏问题,我们有以下解决方案:针对背景颜色的更改
我们一般会选择和主题一样的背景图片,这里只是简单的示意修改。当然,我们也可以参考网易的解决方案:
1.设置主题:
<resources xmlns:tools="http://schemas.android.com/tools">
<!-- Base application theme. -->
<style name="Theme.Performance" parent="Theme.MaterialComponents.DayNight.DarkActionBar">
<!-- Primary brand color. -->
<item name="colorPrimary">@color/purple_500</item>
<item name="colorPrimaryVariant">@color/purple_700</item>
<item name="colorOnPrimary">@color/white</item>
<!-- Secondary brand color. -->
<item name="colorSecondary">@color/teal_200</item>
<item name="colorSecondaryVariant">@color/teal_700</item>
<item name="colorOnSecondary">@color/black</item>
<!-- Status bar color. -->
<item name="android:statusBarColor">?attr/colorPrimaryVariant</item>
<!-- Customize your theme here. -->
<!-- <item name="android:windowBackground">@color/black</item>-->
<item name="windowNoTitle">true</item>
<item name="android:windowBackground">@drawable/splash</item>
</style>
</resources>
<?xml version="1.0" encoding="utf-8"?>
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="@android:color/holo_red_dark"></item>
<item android:top="30dp">
<bitmap
android:gravity="top"
android:src="@drawable/kotlin"></bitmap>
</item>
</layer-list>
2.我们在Activity的xml中可以设置Image
<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".MainActivity">
<!--会有Activity渐入的效果-->
<ImageView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:background="@mipmap/ic_launcher"
android:layout_alignParentBottom="true"
android:layout_centerHorizontal="true"
android:layout_marginBottom="30dp">
</ImageView>
</RelativeLayout>
2.multiDex优化
1.App打包分析
当我们打包app时它会有如下操作:
1.打包资源文件,生成R.java文件(使用工具AAPT)
2.处理AIDL文件,生成java代码(没有AIDL则忽略)
3.编译 java 文件,生成对应.class文件(java compiler)
4.将.class 文件转换成dex文件(dex)
5.打包成没有签名的apk(使用工具apkbuilder)
6.使用签名工具给apk签名(使用工具Jarsigner)
7.对签名后的.apk文件进行对齐处理,不进行对齐处理不能发布到Google Market(使用工具zipalign)
2.multiDex由来
我们知道了打包流程,当我们的在打包流程的第四步即将class文件打包成dex文件时,方法超过65535时会出现,需要多个dex来解决,于是我们有了multidex方案,我们在as中开启它:
在build.gradle中添加配置
在myApp中开启它,必须要开启,不然如果是在5.0以上手机运行正常,但是5.0以下手机运行直接crash,报错 Class NotFound xxx。Android 5.0以下,ClassLoader加载类的时候只会从class.dex(主dex)里加载,ClassLoader不认识其它的class2.dex、class3.dex、...,当访问到不在主dex中的类的时候,就会报错:Class NotFound xxx,因此谷歌给出兼容方案,MultiDex.install()。
3.源码解析
我们调用到Multidex.install时会执行下面的操作
public static void install(Context context) {
Log.i("MultiDex", "Installing application");
if (IS_VM_MULTIDEX_CAPABLE) { //5.0 以上VM基本支持多dex,啥事都不用干
Log.i("MultiDex", "VM has multidex support, MultiDex support library is disabled.");
} else if (VERSION.SDK_INT < 4) { //
throw new RuntimeException("MultiDex installation failed. SDK " + VERSION.SDK_INT + " is unsupported. Min SDK version is " + 4 + ".");
} else {
...
doInstallation(context, new File(applicationInfo.sourceDir), new File(applicationInfo.dataDir), "secondary-dexes", "", true);
...
Log.i("MultiDex", "install done");
}
}
可以看到在5.0以下时会走到doInstallation这个方法中,我们跟下:
private static void doInstallation(Context mainContext, File sourceApk, File dataDir, String secondaryFolderName, String prefsKeyPrefix, boolean reinstallOnPatchRecoverableException) throws IOException, IllegalArgumentException, IllegalAccessException, NoSuchFieldException, InvocationTargetException, NoSuchMethodException, SecurityException, ClassNotFoundException, InstantiationException {
...
//获取非主dex文件
File dexDir = getDexDir(mainContext, dataDir, secondaryFolderName);
MultiDexExtractor extractor = new MultiDexExtractor(sourceApk, dexDir);
IOException closeException = null;
try {
// 1. 这个load方法,第一次没有缓存,会非常耗时
List files = extractor.load(mainContext, prefsKeyPrefix, false);
try {
//2. 安装dex
installSecondaryDexes(loader, dexDir, files);
}
...
}
}
}
}
我们可以看到,第一步如果文件没有缓存会加载
List<? extends File> load(Context context, String prefsKeyPrefix, boolean forceReload) throws IOException {
if (!this.cacheLock.isValid()) {
throw new IllegalStateException("MultiDexExtractor was closed");
} else {
List files;
if (!forceReload && !isModified(context, this.sourceApk, this.sourceCrc, prefsKeyPrefix)) {
try {
//读缓存的dex
files = this.loadExistingExtractions(context, prefsKeyPrefix);
} catch (IOException var6) {
Log.w("MultiDex", "Failed to reload existing extracted secondary dex files, falling back to fresh extraction", var6);
//读取缓存的dex失败,可能是损坏了,那就重新去解压apk读取,跟else代码块一样
files = this.performExtractions();
//保存标志位到sp,下次进来就走if了,不走else
putStoredApkInfo(context, prefsKeyPrefix, getTimeStamp(this.sourceApk), this.sourceCrc, files);
}
} else {
//没有缓存,解压apk读取
files = this.performExtractions();
//保存dex信息到sp,下次进来就走if了,不走else
putStoredApkInfo(context, prefsKeyPrefix, getTimeStamp(this.sourceApk), this.sourceCrc, files);
}
Log.i("MultiDex", "load found " + files.size() + " secondary dex files");
return files;
}
}
我们继续跟下解压apk的代码,即:performExtractions这个方法。
private List<MultiDexExtractor.ExtractedDex> performExtractions() throws IOException {
//先确定命名格式
String extractedFilePrefix = this.sourceApk.getName() + ".classes";
this.clearDexDir();
List<MultiDexExtractor.ExtractedDex> files = new ArrayList();
ZipFile apk = new ZipFile(this.sourceApk); // apk转为zip格式
try {
int secondaryNumber = 2;
//apk已经是改为zip格式了,解压遍历zip文件,里面是dex文件,
//名字有规律,如classes1.dex,class2.dex
for(ZipEntry dexFile = apk.getEntry("classes" + secondaryNumber + ".dex"); dexFile != null; dexFile = apk.getEntry("classes" + secondaryNumber + ".dex")) {
//文件名:xxx.classes1.zip
String fileName = extractedFilePrefix + secondaryNumber + ".zip";
//创建这个classes1.zip文件
MultiDexExtractor.ExtractedDex extractedFile = new MultiDexExtractor.ExtractedDex(this.dexDir, fileName);
//classes1.zip文件添加到list
files.add(extractedFile);
Log.i("MultiDex", "Extraction is needed for file " + extractedFile);
int numAttempts = 0;
boolean isExtractionSuccessful = false;
while(numAttempts < 3 && !isExtractionSuccessful) {
++numAttempts;
//这个方法是将classes1.dex文件写到压缩文件classes1.zip里去,最多重试三次
extract(apk, dexFile, extractedFile, extractedFilePrefix);
...
}
//返回dex的压缩文件列表
return files;
}
我们可以看到,首先它把apk压缩成zip,然后从中抽取classX.dex,并且压缩陈zip文件,并且返回zip列表。
返回后我们就可以跟下安装dex的方法了,即 installSecondaryDexes(loader, dexDir, files);我们跟下v19的方法:
private static final class V19 {
private V19() {
}
static void install(ClassLoader loader, List<? extends File> additionalClassPathEntries, File optimizedDirectory) throws IllegalArgumentException, IllegalAccessException, NoSuchFieldException, InvocationTargetException, NoSuchMethodException, IOException {
Field pathListField = MultiDex.findField(loader, "pathList");//1 反射ClassLoader 的 pathList 字段
Object dexPathList = pathListField.get(loader);
ArrayList<IOException> suppressedExceptions = new ArrayList();
// 2 扩展数组
MultiDex.expandFieldArray(dexPathList, "dexElements", makeDexElements(dexPathList, new ArrayList(additionalClassPathEntries), optimizedDirectory, suppressedExceptions));
...
}
private static Object[] makeDexElements(Object dexPathList, ArrayList<File> files, File optimizedDirectory, ArrayList<IOException> suppressedExceptions) throws IllegalAccessException, InvocationTargetException, NoSuchMethodException {
Method makeDexElements = MultiDex.findMethod(dexPathList, "makeDexElements", ArrayList.class, File.class, ArrayList.class);
return (Object[])((Object[])makeDexElements.invoke(dexPathList, files, optimizedDirectory, suppressedExceptions));
}
}
它做了三件事
- 反射ClassLoader 的 pathList 字段
- 找到pathList 字段对应的类的
makeDexElements方法 - 通过
MultiDex.expandFieldArray这个方法扩展dexElements数组,怎么扩展?看下代码:
private static void expandFieldArray(Object instance, String fieldName, Object[] extraElements) throws NoSuchFieldException, IllegalArgumentException, IllegalAccessException {
Field jlrField = findField(instance, fieldName);
Object[] original = (Object[])((Object[])jlrField.get(instance)); //取出原来的dexElements 数组
Object[] combined = (Object[])((Object[])Array.newInstance(original.getClass().getComponentType(), original.length + extraElements.length)); //新的数组
System.arraycopy(original, 0, combined, 0, original.length); //原来数组内容拷贝到新的数组
System.arraycopy(extraElements, 0, combined, original.length, extraElements.length); //dex2、dex3...拷贝到新的数组
jlrField.set(instance, combined); //将dexElements 重新赋值为新的数组
}
4.dex类加载
前面我们对multidex做了简单的解析,大致的流程就是压缩apk,获取dex文件并缓存起来,然后使用classLoader将其拼接到dex1后面,那其实不管是 PathClassLoader还是DexClassLoader,都继承自BaseDexClassLoader,加载类的代码在 BaseDexClassLoader中,它的大致流程就是:ClassLoader.loadClass -> DexPathList.loadClass -> 遍历dexElements数组 ->DexFile.loadClassBinaryName,我们在5.0以下的版本由于只有主dex,没有dex2...所以我们加载时会出现找不到类的错误,但是我们通过Multidex.install可以将dex拼接主dex中去,大致流程是:
5.方案优化
通过上面的源码解析和原理分析,我们知道只要在5.0以下的机器,在多dex时就需要使用multidex来帮助我们app来合并dexpathList中的dexElements数组对象,以保证我们能正常的加载类,但是这过程可能会比较耗时,这里要压缩解压缩等合并等流程,如果我们在application中直接执行multidex.install可能会有anr的分险,为此我们想到了一些解决方法:
1.子线程启动
我们在开启一个子线程,帮助我们去加载dex,但是这个需要确保我们要呈现的类在主dex中,并且当我们进到app中相对应的dex一定是加载完成了,我们可以通过如下配置来确保。
multiDexKeepProguard file('multiDexKeep.pro') // 打包到main dex的这些类的混淆规制,没特殊需求就给个空文件
multiDexKeepFile file('maindexlist.txt') // 指定哪些类要放到main dex
但是这样做的风险比较大,因为一些第三方的库会在借助contentprovide启动时启动,启动时机如下:
这个时候我们的dex可能还未加载完,就有可能出现一堆的bug。
2.多进程中异步启动
这里我们参考今日头条的方案:开启一个进程去并通过其执行异步操作来install dex,以加快我们的启动速度。
- 在application中的attachBaseContext中通过文件标识,作为判断MultiDex是否加载完的条件,注意只有主进程才去做相关操作;
- 启动LoadDexActivity去在异步中加载MultiDex(LoadDexActivity在单独进程),加载完成删除文件标识;
- 开启while循环,直到sp中的boolean值改变才跳出循环,进入Application的onCreate等方法。
这样就解决了在5.0以下的设备多dex可能会出现anr的异常问题。但是这里需要注意的是:LoadMultiDexActivity需要配置到主dex中去。
3.三方框架的懒加载
很多第三方开源库都说在Application中进行初始化,十几个开源库都放在Application中,肯定对冷启动会有影响,所以可以考虑按需初始化,例如Glide,可以放在自己封装的图片加载类中,调用到再初始化,其它库也是同理,让Application变得更轻。比如我们之前做三方对接时都会考虑到这点。
4.webView的优化
我们知道webView初始化的速度比较慢,我们可以通过在application的缓存池,把webView缓存起来,避免重复创建。
5.数据预加载
这种方式一般是在主页空闲的时候,将其它页面的数据加载好,保存到内存或数据库,等到打开该页面的时候,判断已经预加载过,直接从内存或数据库读取数据并显示。这个是用空间换时间,具体使用的话根据项目需求来操作。
6.线程优化
尽量使用线程池,不要去new一个Thread去操作,因为在任务众多的情况下,系统要为每一个任务创建一个线程,而任务执行完毕后会销毁每一个线程,所以会造成线程频繁地创建与销毁;多个线程频繁地创建会占用大量的资源,并且在资源竞争的时候就容易出现问题,同时这么多的线程缺乏一个统一的管理,容易造成界面的卡顿;多个线程频繁地销毁,会频繁地调用GC机制,这会使性能降低,又非常耗时,总而言之,不断的创建线程会导致性能消耗、且不利于管理。
7.startup管理组件的初始化
我们知道很多第三方框架都利用了contentProvider来初始化,它执行在application的attachBaseContent之后,onCreate之前,startup可以帮助我们管理对依赖contentProvider获取context初始化的操作,不过这个需要你的组件实现了Initializer启动的,使用方法。具体使用和原理可以参考下这篇博客:juejin.cn/post/684490…
8.一些小的细节优化
尽量在onReume方法中减少耗时方法的执行。
四.启动优化总结
对于启动优化,我们首先要明白冷热启动的含义,需要知道通过一些手段来准确的拿到启动时间,或者通过对怀疑的某个点来做更加深入的分析处理,当然对于一些通用的处理启动慢的问题,我们也可以通过一些通用的手段去解决,比如白屏问题、multidex低版本问题、让我们application轻装上阵的懒加载、webView的缓存问题、以及一些数据的预加载、线程优化等一系列的问题,这些都是需要我们积累分析才会得到这些不同的解决方案。