在整理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中。