在整理MultiDex优化之前,先了解一下Apk的编译流程,这样有助于后面针对MultiDex优化。
一、Apk 编译流程
Android Studio 按下编译按钮后发生了什么?
1. 打包资源文件,生成R.java文件(使用工具aapt,这个工具在Android 使用 aapt 命令查看 apk 包名 提到过,感兴趣的可以了解一下)
2. 处理aidl文件,生成java代码(没有aidl 则忽略)
3. 编译 java 文件,生成对应.class文件(java compiler)
4. class 文件转换成dex文件(dex)
5. 打包成没有签名的apk(使用工具apkbuilder)
6. 使用签名工具给apk签名(使用工具Jarsigner)
在第4步,将class文件转换成dex文件,默认只会生成一个dex文件,单个dex文件中的方法数不能超过65536,不然编译会报错,但是我们在开发App时肯定会集成一堆库,方法数一般都是超过65536的,解决这个问题的办法就是:一个dex装不下,用多个dex来装,gradle增加一行配置:multiDexEnabled true。
具体配置方案可以参考:Android 分包 MultiDex 策略总结。
二、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); //dex2、dex3...拷贝到新的数组 jlrField.set(instance, combined); //将dexElements 重新赋值为新的数组 }
就是创建一个新的数组,把原来数组内容(主dex)和要增加的内容(dex2、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),没有dex2、dex3...,MultiDex是怎么把dex2添加进去呢?
答案就是反射DexPathList的dexElements字段,然后把dex2添加进去,当然,dexElements里面放的是Element对象,只有dex2的路径,必须转换成Element格式才行,所以反射DexPathList里面的makeDexElements 方法,将dex文件转换成Element对象即可。
dex2、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中加载到目标类,经过分析后最终整理出来的原理图如下:
三、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相关的逻辑。
应用进程不存在的情况下,从点击桌面应用图标,到应用启动(冷启动),大概会经历以下流程:
-
Launcher startActivity
-
AMS startActivity
-
Zygote fork 进程
-
ActivityThread main()
4.1. ActivityThread attach
4.2. handleBindApplication
4.3 attachBaseContext
4.4. installContentProviders
4.5. Application onCreate -
ActivityThread 进入loop循环
-
Activity生命周期回调,onCreate、onStart、onResume...
整个启动流程我们能干预的主要是 4.3、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中。