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中。

相關文章
相關標籤/搜索