Android 項目優化(三):MultiDex 優化


在整理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相關的邏輯。

應用進程不存在的情況下,從點擊桌面應用圖標,到應用啟動(冷啟動),大概會經歷以下流程:

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

 


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM