在Android Kikat及之前的Android系統上,構建或安裝Apk會出現「65535方法數超標」以及「INSTALL_FAILED_DEXOPT」問題,MultiDex是Google爲了解決這個問題問題而開發的一個Support庫。MultiDex出現的具體背景、使用方式能夠參考給App啓用 MultiDex功能,而MultiDex Support庫的工做機制、源碼分析能夠參考MultiDex工做原理分析和優化方案。java
MultiDex的使用雖然很簡單便捷,可是有個比較蛋疼的問題,就是在App第一次冷啓動的時候會產生明顯的卡頓現象。通過測試和統計,根據Apk包的大小、Android系統版本的不一樣,這個卡頓時間通常是2000到5000毫秒左右,極端的狀況下甚至能夠到20000+毫秒。經過以前的分析,咱們知道具體的卡頓產生在MultiDex解壓、優化dex這兩個過程,並且只在第一次冷啓動的時候纔會觸發這兩個過程。那麼優化的方式也很簡單,在安裝Apk前先對新版本的Apk作好解壓和優化工做,就能在安裝後第一次冷啓動的時候避開這兩個耗時的過程了。android
在以前的章節裏面講到,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獲得的緩存文件能夠複用。app
固然,光Apk包沒有修改過這一項條件還不夠,接下來調用了這個判斷主要是在MultiDexExtractor#loadExistingExtractions(Context, File, File)。ide
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異常的風險的。源碼分析
到這裏咱們的方案就清晰了:測試
在安裝新Apk前,先作好dex的解壓和優化,獲得dex壓縮包(.zip)列表和dexopt後的odex文件(.dex)列表。
把dex/odex文件保存到一個內部存儲路徑PATH_A,同時使用SP記錄新版本Apk的CRC、dex數量,以及解壓出來的每個dex的CRC值。
安裝新版本Apk後,啓動時在執行MultiDex前,把PATH_A路徑上的緩存文件移動(rename)到MultiDex的緩存路徑PATH_B上,同時保存當前Apk的CRC、修改時間以及dex數量到MultiDex對應的SP配置上。
執行原有MultiDex邏輯,讓MultiDex覺得以前已經作過解壓和優化dex工做,從而繞開第一次MultiDex時候的耗時。
第一次成功啓動新Apk後,對dex進行校驗工做,若是校驗失敗則清除dex緩存,強制讓App在下一次啓動的時候再執行一遍MultiDex。
注:
流程圖的綠色部分爲文件鎖(FileLock)操做,主要是爲了多進程同步。
紅色部分爲耗時的操做。
Dex路徑爲MultiDex過程當中用於存儲解壓出來的dex文件的路徑(/data/data/<package>/code_cache)。
PreDex路徑爲存儲預解壓獲得的緩存文件的內部路徑(/data/data/<package>/code_cache_pre)。
MultiDex從Apk包解壓出來的dex文件會被壓縮成Zip包(.zip),而執行dexopt操做後生成的odex文件文件名爲.dex,這兩個容易搞混。
這個環節必須在升級Apk前,由舊版本的Apk進行,也就是要求App擁有自主更新的邏輯。
從舊版的Apk覆蓋安裝新的Apk後,第一次運行App時MultiDex主要的耗時過程。這時須要把在舊版本Apk預安裝獲得的dex緩存文件移動到MultiDex使用的存儲路徑上。
原有的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的緩存文件,禁用PreMultiDex功能,而且強制讓App在下一次啓動的時候再執行一遍MultiDex。
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的加速效果。
MultiDex其實並無刻意保留dexopt後的緩存,若是隻保留dex文件,而不保留odex文件,那麼下一次啓動執行MultiDex的時候,不須要從新解壓dex文件,可是依然須要dexopt併產生odex文件,這個過程大概會佔用MultiDex總耗時的通常左右。若是odex文件存在,可是已經損壞了,或者是一個非法的odex文件,依然會觸發dexopt工做。也就是說,加載dex文件並建立DexFile對象的時候,Android系統會判斷odex的緩存,以及緩存文件是否正確,具體過程在dalvik_system_DexFile.cpp裏實現,有興趣的同窗能夠找找dex文件結構分析的文章,這裏就不挖坑了。
其實,若是dex文件和Apk的版本對不上的話,通常在啓動App的時候就會出現ClassNotFound異常而致使App崩潰,接着再次啓動因爲沒有從新MultiDex也會繼續崩潰。而崩潰的時候,可能App崩潰上報系統還沒來得及初始化,因此沒有辦法發現崩潰的問題。
爲了防止這種問題,能夠開發一個恢復模式或者安全模式的功能,當App出現連續的崩潰的時候,會進入恢復模式的狀態,清空一些可能致使異常的數據(好比PreMultiDex的緩存),這樣就能避免App由於連續崩潰而不能使用。至於怎麼實現恢復,這已是另外一個領域的功能了,這裏再也不展開。
參考連接:
Google Multidex
著做信息:
本文章出自 Kaede 的博客,原創文章若無特別說明,均遵循 CC BY-NC 4.0 知識共享許可協議4.0(署名-非商用-相同方式共享),能夠隨意摘抄轉載,但必須標明署名及原地址。