Android 项目优化(三):MultiDex 优化

在整理MultiDex优化以前,先了解一下Apk的编译流程,这样有助于后面针对MultiDex优化。html

1、Apk 编译流程

Android Studio 按下编译按钮后发生了什么?java

1. 打包资源文件,生成R.java文件(使用工具aapt,这个工具在Android 使用 aapt 命令查看 apk 包名 提到过,感兴趣的能够了解一下)数组

2. 处理aidl文件,生成java代码(没有aidl 则忽略)缓存

3. 编译 java 文件,生成对应.class文件(java compiler)app

4. class 文件转换成dex文件(dex)框架

5. 打包成没有签名的apk(使用工具apkbuilder)异步

6. 使用签名工具给apk签名(使用工具Jarsigner)ide

在第4步,将class文件转换成dex文件,默认只会生成一个dex文件,单个dex文件中的方法数不能超过65536,否则编译会报错,可是咱们在开发App时确定会集成一堆库,方法数通常都是超过65536的,解决这个问题的办法就是:一个dex装不下,用多个dex来装,gradle增长一行配置:multiDexEnabled true。工具

具体配置方案能够参考:Android 分包 MultiDex 策略总结oop

2、MultiDex 原理

虽然配置好了MultiDex分包策略,可是咱们发如今Android 4.4 的手机上仅执行 MultiDex.install(context) 就可能消耗1秒多的时间,那么为何会这么耗时呢?这里先分析一下MultiDex的原理。

2.1 MultiDex 原理

首先咱们来看一下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");
        }
}

从上面的源码能够看到,若是虚拟机自己就支持加载多个dex文件,那就啥都不用作;若是是不支持加载多个dex(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);
       } 
    }
}

 看一下 1. MultiDexExtractor#load 具体都执行了哪些内容:

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

查找dex文件,有两个逻辑,有缓存就调用loadExistingExtractions方法,没有缓存或者缓存读取失败就调用performExtractions方法,而后再缓存起来。使用到缓存,那么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,遍历出里面的dex文件,例如class1.dex,class2.dex,而后又压缩成class1.zip,class2.zip...,而后返回zip文件列表。

只有第一次加载才会执行解压和压缩过程,第二次进来读取sp中保存的dex信息,直接返回file list,因此第一次启动的时候比较耗时。dex文件列表找到了,回到上面MultiDex#doInstallation方法的注释2,找到的dex文件列表,而后调用installSecondaryDexes方法进行安装,怎么安装呢?方法点进去看SDK 19 以上的实现:

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); //dex二、dex3...拷贝到新的数组
        jlrField.set(instance, combined); //将dexElements 从新赋值为新的数组
    }

就是建立一个新的数组,把原来数组内容(主dex)和要增长的内容(dex二、dex3...)拷贝进去,反射替换原来的dexElements为新的数组,以下图:

Tinker热修复的原理也是经过反射将修复后的dex添加到这个dex数组去,不一样的是热修复是添加到数组最前面,而MultiDex是添加到数组后面。这样讲可能还不是很好理解?来看看ClassLoader怎么加载一个类的就明白了~

2.2 ClassLoader 加载类原理

不论是 PathClassLoader仍是DexClassLoader,都继承自BaseDexClassLoader,加载类的代码在 BaseDexClassLoader中,具体文件路径以下:/dalvik/src/main/java/dalvik/system/BaseDexClassLoader.java。

代码如图:

1.构造方法经过传入dex路径,建立了DexPathList。

2. ClassLoader的findClass方法最终是调用DexPathList 的findClass方法

接下来看一下DexPathList源码/dalvik/src/main/java/dalvik/system/DexPathList.java

DexPathList里面定义了一个dexElements 数组,findClass方法中用到,看下

findClass方法逻辑很简单,就是遍历dexElements 数组,拿到里面的DexFile对象,经过DexFile的loadClassBinaryName方法加载一个类。

最终建立Class是经过native方法,就不追下去了,你们有兴趣能够看下native层是怎么建立Class对象的。

那么问题来了,5.0如下这个dexElements 里面只有主dex(能够认为是一个bug),没有dex二、dex3...,MultiDex是怎么把dex2添加进去呢?

答案就是反射DexPathList的dexElements字段,而后把dex2添加进去,固然,dexElements里面放的是Element对象,只有dex2的路径,必须转换成Element格式才行,因此反射DexPathList里面的makeDexElements 方法,将dex文件转换成Element对象便可。

dex二、dex3...经过makeDexElements方法转换成要新增的Element数组,最后一步就是反射DexPathList的dexElements字段,将原来的Element数组和新增的Element数组合并,而后反射赋值给dexElements变量,最后DexPathList的dexElements变量就包含新加的dex在里面了。

makeDexElements方法会判断file类型,上面讲dex提取的时候解压apk获得dex,而后又将dex压缩成zip,压缩成zip,就会走到第二个判断里去。仔细想一想,其实dex不压缩成zip,走第一个判断也没啥问题吧,那谷歌的MultiDex为何要将dex压缩成zip呢?

在Android开发高手课中看到张绍文也提到这一点:

也就是说,这个压缩过程是多余的,后面咱们会介绍一下头条App参考谷歌的MultiDex优化这个多余的压缩过程,后续会介绍一下头条的方案。

这里咱们先总结一下ClassLoader的加载原理 <==>  ClassLoader.loadClass -> DexPathList.loadClass -> 遍历dexElements数组 ->DexFile.loadClassBinaryName。

通俗点说就是:ClassLoader加载类的时候是经过遍历dex数组,从dex文件里面去加载一个类,加载成功就返回,加载失败则抛出Class Not Found 异常。

2.3 MultiDex原理总结

在明白ClassLoader加载类原理以后,咱们能够经过反射dexElements数组,将新增的dex添加到数组后面,这样就保证ClassLoader加载类的时候能够重新增的dex中加载到目标类,通过分析后最终整理出来的原理图以下:

3、MultiDex 优化

咱们了解了MultiDex原理以后,就应该考虑如何优化MultiDex了。

MultiDex的优化的重点在于解决install过程耗时,耗时的缘由主要是涉及到解压apk取出dex、压缩dex、将dex文件经过反射转换成DexFile对象、反射替换数组。

想到优化此耗时问题,首先咱们会想到异步,也就是开启一个子线程执行install操做,可是这样作真的可行吗?实践事后就发现,方案存在很大的问题。

3.1 子线程install(不推荐)

这个方案的思路为:在闪屏页开一个子线程去执行MultiDex.install,而后加载完才跳转到主页。须要注意的是闪屏页的Activity,包括闪屏页中引用到的其它类必须在主dex中,否则在MultiDex.install以前加载这些不在主dex中的类会报错Class Not Found。

如何保证闪屏页在主dex里面呢?这里咱们可使用Gradle来配置:

    defaultConfig {
        //分包,指定某个类在main dex
        multiDexEnabled true
        multiDexKeepProguard file('multiDexKeep.pro') // 打包到main dex的这些类的混淆规制,没特殊需求就给个空文件
        multiDexKeepFile file('maindexlist.txt') // 指定哪些类要放到main dex
    }

maindexlist.txt 文件指定哪些类要打包到主dex中,内容格式以下

com/lanshifu/launchtest/SplashActivity.class

可是,真正在已有项目中用使用这种方式,会发现编译运行在Android 4.4的机器上,启动闪屏页,加载完准备进入主页直接报错NoClassDefFoundError。NoClassDefFoundError 在这里出现知道就是主dex里面没有该类,通常状况下,这个方案的报错会出如今三方库的中,尤为是ContentProvider相关的逻辑。

应用进程不存在的状况下,从点击桌面应用图标,到应用启动(冷启动),大概会经历如下流程:

  1. Launcher startActivity

  2. AMS startActivity

  3. Zygote fork 进程

  4. ActivityThread main()
    4.1.  ActivityThread attach
    4.2. handleBindApplication
    4.3  attachBaseContext
    4.4. installContentProviders
    4.5. Application onCreate

  5. ActivityThread 进入loop循环

  6. Activity生命周期回调,onCreate、onStart、onResume...

整个启动流程咱们能干预的主要是 4.三、4.5 和6,应用启动优化主要从这三个地方入手。理想情况下,这三个地方若是不作任何耗时操做,那么应用启动速度就是最快的,可是现实很骨感,不少开源库接入第一步通常都是在Application onCreate方法初始化,有的甚至直接内置ContentProvider,直接在ContentProvider中初始化框架,不给你优化的机会。

子线程install的方案之因此出现问题也正是由于上述的原理所说,即:ContentProvider初始化太早了,若是不在主dex中,还没启动闪屏页就已经crash了。

总结一下这种方案的缺点:

1. MultiDex加载逻辑放在闪屏页的话,闪屏页中引用到的类都要配置在主dex。

2. ContentProvider必须在主dex,一些第三方库自带ContentProvider,维护比较麻烦,要一个一个配置。

下面咱们看一下今日头条是如何优化MultiDex的。

3.2 今日头条优化方案

1.在主进程Application 的 attachBaseContext 方法中判断若是须要使用MultiDex,则建立一个临时文件,而后开一个进程(LoadDexActivity),显示Loading,异步执行MultiDex.install 逻辑,执行完就删除临时文件并finish本身。

2. 主进程Application 的 attachBaseContext 进入while代码块,定时轮循临时文件是否被删除,若是被删除,说明MultiDex已经执行完,则跳出循环,继续正常的应用启动流程。

3.MultiDex执行完以后主进程Application继续走,ContentProvider初始化和Application onCreate方法,也就是执行主进程正常的逻辑。

注意:LoadDexActivity 必需要配置在main dex中。

相关文章
相关标签/搜索