性能优化最佳实践#启动优化

99 阅读12分钟

性能优化最佳实践#启动优化

性能优化最佳实践#UI卡顿优化

性能优化最佳实践#内存优化

性能优化最佳实践#Crash机制

性能优化最佳实践#ANR优化

性能优化最佳实践#体积包优化

一.启动的分类

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));
        }
    }

它做了三件事

  1. 反射ClassLoader 的 pathList 字段
  2. 找到pathList 字段对应的类的makeDexElements 方法
  3. 通过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,以加快我们的启动速度。

  1. 在application中的attachBaseContext中通过文件标识,作为判断MultiDex是否加载完的条件,注意只有主进程才去做相关操作;
  2. 启动LoadDexActivity去在异步中加载MultiDex(LoadDexActivity在单独进程),加载完成删除文件标识;
  3. 开启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的缓存问题、以及一些数据的预加载、线程优化等一系列的问题,这些都是需要我们积累分析才会得到这些不同的解决方案。