通過預安裝給MultiDex加速

eooh1311 7年前發布 | 5K 次閱讀 安卓開發 Android開發 移動開發

在Android Kikat及以前的Android系統上,構建或安裝Apk會出現“65535方法數超標”以及“INSTALL_FAILED_DEXOPT”問題,MultiDex是Google為了解決這個問題問題而開發的一個Support庫。

MultiDex的使用雖然很簡單便捷,但是有個比較蛋疼的問題,就是在App第一次冷啟動的時候會產生明顯的卡頓現象。經過測試和統計,根據Apk包的大小、Android系統版本的不同,這個卡頓時間一般是2000到5000毫秒左右,極端的情況下甚至可以到20000+毫秒。通過之前的分析,我們知道具體的卡頓產生在MultiDex解壓、優化dex這兩個過程,而且只在第一次冷啟動的時候才會觸發這兩個過程。那么優化的方式也很簡單,在安裝Apk前先對新版本的Apk做好解壓和優化工作,就能在安裝后第一次冷啟動的時候避開這兩個耗時的過程了。

MultiDex是如何判斷是否需要重新解壓和優化dex的

在之前的章節里面講到,MultiDex在第一次做完解壓和優化dex之后,會保留當前Apk的一些信息,下一次啟動時候后讀取這些配置信息再判斷是否需要重新解壓和優化dex文件。

這個判斷主要是在MultiDexExtractor#load(Context, ApplicationInfo, File, boolean)方法里進行。

static List<File> load(Context context, ApplicationInfo applicationInfo, File dexDir,
            boolean forceReload) throws IOException {

        try {
            ...
            if (!forceReload && !isModified(context, sourceApk, currentCrc)) {
                try {
                    files = loadExistingExtractions(context, sourceApk, dexDir);
                } catch (IOException ioe) {
                    ...
                    files = performExtractions(sourceApk, dexDir);
                    putStoredApkInfo(context,
                            getTimeStamp(sourceApk), currentCrc, files.size() + 1);
                }
            } else {
                ...
                files = performExtractions(sourceApk, dexDir);
                putStoredApkInfo(context, getTimeStamp(sourceApk), currentCrc, files.size() + 1);
            }
        }
        ...
        return files;
    }

第一次調用這個方法的時候,forceReload為false,則不需要強制重新解壓dex。然后調用了isModified這個方法判斷當前App的Apk包是否被修改過。

private static boolean isModified(Context context, File archive, long currentCrc) {
        SharedPreferences prefs = getMultiDexPreferences(context);
        return (prefs.getLong(KEY_TIME_STAMP, NO_VALUE) != getTimeStamp(archive))
                || (prefs.getLong(KEY_CRC, NO_VALUE) != currentCrc);
    }

isModified方法主要是判斷當前App的Apk包的CRC值是否和上一次解壓dex時記錄的Apk包CRC一樣(CRC值可以認為是一個稀疏的MD5算法,它的時間復雜度低很多,但是計算結果容易產生沖突),以及Apk文件的修改時間(文件的Last Modified Time)是否一致。如果這兩項都一致的話就認為Apk文件沒有產生變化(沒有覆蓋安裝過),因此上一次解壓和優化dex得到的緩存文件可以復用。

當然,光Apk包沒有修改過這一項條件還不夠,接下來調用了這個判斷主要是在MultiDexExtractor#loadExistingExtractions(Context, File, File)。

private static List<File> loadExistingExtractions(Context context, File sourceApk, File dexDir)
            throws IOException {

        final String extractedFilePrefix = sourceApk.getName() + EXTRACTED_NAME_EXT;
        int totalDexNumber = getMultiDexPreferences(context).getInt(KEY_DEX_NUMBER, 1);
        final List<File> files = new ArrayList<File>(totalDexNumber);
        for (int secondaryNumber = 2; secondaryNumber <= totalDexNumber; secondaryNumber++) {
            String fileName = extractedFilePrefix + secondaryNumber + EXTRACTED_SUFFIX;
            File extractedFile = new File(dexDir, fileName);
            if (extractedFile.isFile()) {
                files.add(extractedFile);
                if (!verifyZipFile(extractedFile)) {
                    throw new IOException("Invalid ZIP file.");
                }
            } else {
                throw new IOException("Missing extracted secondary dex file '" +
                        extractedFile.getPath() + "'");
            }
        }
        return files;
    }

這里先通過SharePreference讀取上一次MultiDex保存的Apk包的dex數量totalDexNumber,然后挨個加載預定的文件路徑上的dex文件,加載文件的的同時還通過verifyZipFile方法判斷dex文件的合法性。如果這個過程出現異常就認為獲取上一次緩存的dex文件失敗,需要重新解壓。

static boolean verifyZipFile(File file) {
        try {
            ZipFile zipFile = new ZipFile(file);
            try {
                zipFile.close();
                return true;
            } catch (IOException e) {
                Log.w(TAG, "Failed to close zip file: " + file.getAbsolutePath());
            }
        } catch (ZipException ex) {
            Log.w(TAG, "File " + file.getAbsolutePath() + " is not a valid zip file.", ex);
        } catch (IOException ex) {
            Log.w(TAG, "Got an IOException trying to open zip file: " + file.getAbsolutePath(), ex);
        }
        return false;
    }

verifyZipFile這個方法非常簡單,解壓dex文件的時候,解壓出來的文件被保存成Zip包,這個方法這是檢查緩存的dex文件是否是Zip包。感覺不靠譜,雖然檢查MD5值比較耗時不適合這種情景,不過好歹也像檢查Apk包的CRC值和修改時間一樣,檢查dex緩存文件的CRC和修改時間啊。不過讀取SharePreference配置是一個IO操作,如果保存的數值太多的話,也是有增加耗時和IO異常的風險的。

到這里我們的方案就清晰了:

  1. 在安裝新Apk前,先做好dex的解壓和優化,得到dex壓縮包(.zip)列表和dexopt后的odex文件(.dex)列表。

  2. 把dex/odex文件保存到一個內部存儲路徑PATH_A,同時使用SP記錄新版本Apk的CRC、dex數量,以及解壓出來的每一個dex的CRC值。

  3. 安裝新版本Apk后,啟動時在執行MultiDex前,把PATH_A路徑上的緩存文件移動(rename)到MultiDex的緩存路徑PATH_B上,同時保存當前Apk的CRC、修改時間以及dex數量到MultiDex對應的SP配置上。

  4. 執行原有MultiDex邏輯,讓MultiDex以為之前已經做過解壓和優化dex工作,從而繞開第一次MultiDex時候的耗時。

  5. 第一次成功啟動新Apk后,對dex進行校驗工作,如果校驗失敗則清除dex緩存,強制讓App在下一次啟動的時候再執行一遍MultiDex。

預解壓(PreMultiDex)詳細的流程圖

注:

  1. 流程圖的綠色部分為文件鎖(FileLock)操作,主要是為了多進程同步。

  2. 紅色部分為耗時的操作。

  3. Dex路徑為MultiDex過程中用于存儲解壓出來的dex文件的路徑(/data/data/<package>/code_cache)。

  4. PreDex路徑為存儲預解壓得到的緩存文件的內部路徑(/data/data/<package>/code_cache_pre)。

  5. MultiDex從Apk包解壓出來的dex文件會被壓縮成Zip包(.zip),而執行dexopt操作后生成的odex文件文件名為.dex,這兩個容易搞混。

安裝新Apk前先解壓和優化dex

這個環節必須在升級Apk前,由舊版本的Apk進行,也就是要求App擁有自主更新的邏輯。

第一次運行新Apk時,移動預先安裝好的dex文件

從舊版的Apk覆蓋安裝新的Apk后,第一次運行App時MultiDex主要的耗時過程。這時需要把在舊版本Apk預安裝得到的dex緩存文件移動到MultiDex使用的存儲路徑上。

第一次運行新Apk后,檢查dex文件是否正確

原有的MultiDex,dex文件時同步從Apk包里解壓出來的,所以不存在dex文件和Apk版本對不上的問題。而PreMultiDex的方案的一個問題ui是,解壓dex文件和使用dex文件這兩個過程是分開的,無論版本控制做得再精確,理論上也存在版本出錯的問題(比如從A版本解壓得到了dex文件,而用戶卻選擇覆蓋安裝了B版本,這時候由于代碼邏輯的不嚴謹導致B版本的Apk使用了A版本解壓出來的dex文件)。如果想要確保dex文件的正確性,需要對Apk包里面的dex文件和解壓出來的dex文件做一下MD5值校驗,而這個過程比較耗時,不適合在App啟動的時候做,不然PreMultiDex就失去了意義。因此,需要在第一次運行新Apk后,啟動dex的校驗工作,在Worker線程對dex進行校驗,如果校驗失敗則清除dex緩存,強制讓App在下一次啟動的時候再執行一遍MultiDex。

恢復MultiDex

在MultiDex校驗失敗后,需要清空MultiDex的緩存文件,禁用PreMultiDex功能,并且強制讓App在下一次啟動的時候再執行一遍MultiDex。

一些小細節

dex文件、odex文件?

dex文件是Android虛擬機使用的可執行文件(從Java類編譯得到),相當于JVM虛擬機用的class文件。但是與class文件不同,Android系統并不能直接使用dex文件,需要先使用dexopt工具對dex文件進行一次優化工作(Optimize),優化得到的odex文件才能被虛擬機加載。不同的Android設備需要不同格式的odex文件,所以這個過程只能在Android設備上進行,而不能在構建Apk的時候就處理好。

dex文件在Apk包里的文件后綴名是.dex,MultiDex從Apk包里解壓出dex文件后會壓縮成Zip包,文件后綴名是.zip。對dex文件進行dexopt操作后,會生成相同文件名的odex文件,后綴名是.dex,odex文件會比dex文件大許多,不要搞混這些文件。

至于為什么MultiDex解壓dex文件時會進行壓縮工作,可能是因為壓縮后的壓縮包會占用比較小的內部存儲空間,因為MultiDex本來就是給舊版本的Android系統使用,一些早期的Android設備擁有的內部存儲空間非常有限,而這些dex文件對于App的運行時必須的,所以才需要盡量壓縮dex的體積。壓縮過程會有明顯的耗時,經過測試,如果不進行壓縮,直接從Apk里解壓dex文件,則MultiDex過程會有大約1/3的加速效果。

dexopt緩存

MultiDex其實并沒有刻意保留dexopt后的緩存,如果只保留dex文件,而不保留odex文件,那么下一次啟動執行MultiDex的時候,不需要重新解壓dex文件,但是依然需要dexopt并產生odex文件,這個過程大概會占用MultiDex總耗時的一般左右。如果odex文件存在,但是已經損壞了,或者是一個非法的odex文件,依然會觸發dexopt工作。也就是說,加載dex文件并創建DexFile對象的時候,Android系統會判斷odex的緩存,以及緩存文件是否正確,具體過程在 dalvik_system_DexFile.cpp 里實現,有興趣的同學可以找找dex文件結構分析的文章,這里就不挖坑了。

關于dex文件校驗

其實,如果dex文件和Apk的版本對不上的話,一般在啟動App的時候就會出現ClassNotFound異常而導致App崩潰,接著再次啟動由于沒有重新MultiDex也會繼續崩潰。而崩潰的時候,可能App崩潰上報系統還沒來得及初始化,所以沒有辦法發現崩潰的問題。

為了防止這種問題,可以開發一個恢復模式或者安全模式的功能,當App出現連續的崩潰的時候,會進入恢復模式的狀態,清空一些可能導致異常的數據(比如PreMultiDex的緩存),這樣就能避免App因為連續崩潰而不能使用。至于怎么實現恢復,這已經是另一個領域的功能了,這里不再展開。

 

 

來自:https://zhuanlan.zhihu.com/p/24311062

 

 本文由用戶 eooh1311 自行上傳分享,僅供網友學習交流。所有權歸原作者,若您的權利被侵害,請聯系管理員。
 轉載本站原創文章,請注明出處,并保留原始鏈接、圖片水印。
 本站是一個以用戶分享為主的開源技術平臺,歡迎各類分享!