1. 程式人生 > >其實你不知道MultiDex到底有多坑

其實你不知道MultiDex到底有多坑

專案APP進行Testin深度測試,最終有13款機型啟動失敗,其中6款4.4的系統,5款4.2的系統,1款4.1的系統。 檢視日誌發現全都是發生了ANR,再進一步追查似乎與MultiDex分包有關係。 最後查到了下面這篇文章,感覺總結了很多幹貨,這裡轉載一下。

愉快地寫著Android程式碼的總悟君往工程裡引入了一個默默無聞的jar然後Run了一下, 經過漫長的等待AndroidStudio構建失敗了。
於是總悟君帶著疑惑檢視錯誤資訊。

  1. UNEXPECTED TOP-LEVEL EXCEPTION: java.lang.IllegalArgumentException
    : method ID notin[0,0xffff]:65536
  2. at com.android.dx.merge.DexMerger$6.updateIndex(DexMerger.java:501)
  3. at com.android.dx.merge.DexMerger$IdMerger.mergeSorted(DexMerger.java:276)
  4. at com.android.dx.merge.DexMerger.mergeMethodIds(DexMerger.java:490)
  5. at com.android.dx.merge.DexMerger.mergeDexes(DexMerger
    .java:167)
  6. at com.android.dx.merge.DexMerger.merge(DexMerger.java:188)
  7. at com.android.dx.command.dexer.Main.mergeLibraryDexBuffers(Main.java:439)
  8. at com.android.dx.command.dexer.Main.runMonoDex(Main.java:287)
  9. at com.android.dx.command.dexer.Main.run(Main.java:230)
  10. at com.android.dx.command
    .dexer.Main.main(Main.java:199)
  11. at com.android.dx.command.Main.main(Main.java:103):Derp:dexDerpDebug FAILED

看起來是:在試圖將 classes和jar塞進一個Dex檔案的過程中產生了錯誤。
早期的Dex檔案儲存所有classes的方法個數的範圍在0~65535之間。業務一直在增長,總悟君寫(copy)的程式碼越來越長引入的庫越來越多,超過這個範圍只是時間問題。
怎麼解??太陽底下木有新鮮事,淡定先google一發,找找已經踩過坑的小夥伴。
StackOverflow 的網友們對該問題表示情緒穩定,談笑間丟擲multiDex。
這是Android官網對當初的短視行為給出的補丁方案。文件說,Dalvik Executable (DEX)檔案的總方法數限制在65536以內,其中包括Android framwork method, lib method (後來總悟君發現僅僅是Android 自己的框架的方法就已經佔用了1w多),還有你的 code method ,所以請使用MultiDex。 對於5.0以下版本,請使用multidex support library (這個是我們的補丁包!build tools 請升級到21)。而5.0及以上版本,由於ART模式的存在,app第一次安裝之後會進行一次預編譯(pre-compilation) ,如果這時候發現了classes(..N).dex檔案的存在就會將他們最終合成為一個.oat的檔案,嗯看起來很厲害的樣子。
同時Google建議review程式碼的直接或者間接依賴,儘可能減少依賴庫,設定proguard引數進一步優化去除無用的程式碼。嗯,這兩個實施起來倒是很簡單,但是治標不治本,躲得過初一躲不過十五。 在Google給出這個解決方案之前,他們的開發人員先給了一個簡陋簡易版本的multiDex具體參看這裡。(懷疑後來的官方解決方案就有這傢伙參與)。簡單地說就是:1.先把你的app 的class 拆分成主次兩個dex。2.你的程式執行起來後,自己把第二個dex給load進來。看就這麼簡單!而且這就是個動態載入模組的框架! 然而總悟君早已看穿Dalvik VM 這種動態載入dex 的能力歸根結底還是因為java 的classloader類載入機制。沿著這條道走,Android模組動態化載入,包括dex級別和apk級別的動態化載入,各種玩法層出不窮。參見這裡123456

還是先解決打包問題,回頭再研究那些高深的動態化載入技術。偷懶一下咯考慮到投入產出比,決定使用Google官方的multiDex解決。(Google的補丁方案啊,不會再有坑了吧?後面才發現還是太天真) 該方案有兩步:
1.修改gradle指令碼來產生多dex。
2.修改manifest 使用MulitDexApplication。
步驟1.在gradle腳本里寫上:

  1. android {
  2. compileSdkVersion 21
  3. buildToolsVersion "21.1.0"
  4. defaultConfig {
  5. ...
  6. minSdkVersion 14
  7. targetSdkVersion 21
  8. ...
  9. // Enabling multidex support.
  10. multiDexEnabled true
  11. }
  12. ...
  13. }
  14. dependencies {
  15. compile 'com.android.support:multidex:1.0.0'
  16. }

步驟2. manifest宣告修改

  1. <?xml version="1.0" encoding="utf-8"?>
  2. <manifestxmlns:android="http://schemas.android.com/apk/res/android"
  3. package="com.example.android.multidex.myapplication">
  4. <application
  5. ...
  6. android:name="android.support.multidex.MultiDexApplication">
  7. ...
  8. </application>
  9. </manifest>

如果有自己的Application,繼承MulitDexApplication。如果當前程式碼已經繼承自其它Application沒辦法修改那也行,就重寫 Application的attachBaseContext()這個方法。 

  1. @Override
  2. protectedvoid attachBaseContext(Contextbase){
  3. super.attachBaseContext(base);
  4. MultiDex.install(this);
  5. }

run一下,可以了!但是dex過程好像變慢了。。。
文件還寫明瞭multiDex support lib 的侷限。瞄一下是什麼:
1.在應用安裝到手機上的時候dex檔案的安裝是複雜的(complex)有可能會因為第二個dex檔案太大導致ANR。請用proguard優化你的程式碼。呵呵
2.使用了mulitDex的App有可能在4.0(api level 14)以前的機器上無法啟動,因為Dalvik linearAlloc bug(Issue 22586) 。請多多測試自祈多福。用proguard優化你的程式碼將減少該bug機率。呵呵
3.使用了mulitDex的App在runtime期間有可能因為Dalvik linearAlloc limit (Issue 78035) Crash。該記憶體分配限制在 4.0版本被增大,但是5.0以下的機器上的Apps依然會存在這個限制。
4.主dex被dalvik虛擬機器執行時候,哪些類必須在主dex檔案裡面這個問題比較複雜。build tools 可以搞定這個問題。但是如果你程式碼存在反射和native的呼叫也不保證100%正確。呵呵

感覺這就是個坑啊。補丁方案又引入一些問題。但是外掛化方案要求對現有程式碼有比較大的改動,代價太大,而且動態化載入框架意味著維護成本更高,會有更多潛在bug。所以先測試,遇到有問題的版本再解決。

呵呵,部分低端2.3機型(話說2.3版本的android機有高階機型麼)安裝失敗!INSTALL_FAILED_DEXOPT。這個就是前面說的Issue 22586問題。
apk是一個zip壓縮包,dalvik每次載入apk都要從中解壓出class.dex檔案,載入過程還涉及到dex的classes需要的雜七雜八的依賴庫的載入,真耗時間。於是Android決定優化一下這個問題,在app安裝到手機之後,系統執行dexopt程式對dex進行優化,將dex的依賴庫檔案和一些輔助資料打包成odex檔案。存放在cache/dalvik_cache目錄下。儲存格式為apk路徑 @ apk名 @ classes.dex。這樣以空間換時間大大縮短讀取/載入dex檔案的過程。
那剛才那個bug是啥問題呢,原來dexopt程式的dalvik分配一塊記憶體來統計你的app的dex裡面的classes的資訊,由於classes太多方法太多超過這個linearAlloc 的限制 。那減小dex的大小就可以咯。
gradle指令碼如下:

  1. android.applicationVariants.all {
  2. variant ->
  3. dex.doFirst{
  4. dex->
  5. if(dex.additionalParameters ==null){
  6. dex.additionalParameters =[]
  7. }
  8. dex.additionalParameters +='--set-max-idx-number=48000'
  9. }
  10. }

--set-max-idx-number= 用於控制每一個dex的最大方法個數,寫小一點可以產生好幾個dex。 踩過更多坑的FB的工程師表示這個linearAlloc的限制不僅僅在安裝時候的dexopt程式裡7,還在你的app的dalvik rumtime裡。(很顯然啊dvk vm的宿主程序fork自於同一個母體啊)。為了表示對這個坑的不滿以及對Google的產品表示遺憾,FB工程師Read The Fucking Source Code找到了一個hack方案。這個linearAlloc的size定義在c層而且是一個全域性變數,他們通過對結構體的size的計算成功覆蓋了該值的內容,這裡要特別感謝C語言的指標和記憶體的設計。C的世界裡,You Are The King of This World。當然實際情況是大部分使用者用這把利刃割傷了自己。。。別問總悟君誰是世界上最好的語言。。。
為FB的工程師的機智和務實精神點贊!然而總悟君不願意花那麼多精力實現FB的hack方法。(dvk虛擬機器c層程式碼在2.x 4.x 版本里有變更,找到那個記憶體地址太難,未必搞得定啊)我們有偷懶的解決方案,為了避免2.3機型runtime 的linearAlloclimit ,最好保持每一個dex體積<4M ,剛才的的value<=48000
好了 現在2.3的機器可以安裝run起來了!

問題又來了!這次不僅僅是2.3 的機型!還有一些中檔配置的4.x系統的機型。問題現象是:第一次安裝後,點選圖示,1s,2s,3s... 程式沒有任何反應就好像你沒點圖示一樣。
5s過去。。。程式ANR! 
其實不僅僅總悟君的App存在這個問題,其他很多App也存在首次安裝執行後幾秒都無任何響應的現象或者最後ANR了。唯一的例外是美團App,點選圖示立馬就出現介面。唉要不就算啦?反正就一次。。。不行,這可是產品給使用者的第一印象啊太重要了,而且美團搞得定就說明這問題有解決方案。
ANR了是不是侷限1描述的現象??不過也不重要...因為Google只是告訴你說第二個dex太大了導致的。並沒有進一步解釋根本原因。怎麼辦?Google一發?搜尋點選圖示 然後ANR?怎麼可能有解決方案嘛。ANR就意味著UI執行緒被阻塞了,老老實實檢視log吧。
adb logcat -v time > log.txt
於是發現 是 install dex + dexopt 時間太長!
梳理一下流程:
安裝完app點選圖示之後,系統木有發現對應的process,於是從該apk抽取classes.dex(主dex) 載入,觸發 一次dexopt。 
App 的laucherActivity準備啟動 ,觸發Application啟動, 
Application的 onattach()方法呼叫,這時候MultiDex.install()呼叫,classes2.dex 被install,再次觸發dexopt。
然後Applicaition onCreate()執行。
然後 launcher Activity真的起來了。
這些必須在5s內完成不然就ANR給你看!
有點棘手。首先主dex是無論如何都繞不過載入和dexopt的。如果主dex比較小的話可以節省時間。主dex小就意味著後面的dex大啊,MultiDex.install()是在主執行緒裡做的,總時間又沒有實質性改變。install() 能不能放到執行緒裡做啊?貌似不行。。。如果非同步化,什麼時候install完成都不知道。這時候如果程序需要seconday.dex裡的classes資訊不就悲劇?主dex越小這個錯誤機率就越大。要悲劇啊總悟君。
淡定,這次Google搜尋MultiDex.install 。於是總悟君發現了美團多dex拆包方案。 讀完之後感覺看到勝利曙光。美團的主要思路是:精簡主dex+非同步載入secondary.dex 。對非同步化執行速度的不確定性,他們的解決方案是重寫Instrumentation execStartActivity 方法,hook跳轉Activity的總入口做判斷,如果當前secondary.dex 還沒有載入完成,就彈一個loading Activity等待載入完成,如果已經載入完成那最好不過了。不錯,RTFSC果然是王道。 可以試一試。
但是有幾個問題需要解決:
1.分析主dex需要的classes這個指令碼比較難寫。。。Google文件說過這個問題比較複雜, 而且buildTools 不是已經幫我們搞定了嗎?去瞄一下主dex的大小:8M 以及secondary.dex 3M 。 它是如何工作的?文件說dx的時候,先依據manifest裡註冊的元件生成一個 main-list,然後把這list裡的classes所依賴的classes找出來,把他們打成classes.dex就是主dex。剩下的classes都放clsses2.dex(如果使用引數限制dex大小的話可能會有classe3.ex 等等) 。主dex至少含有main-list 的classes + 直接依賴classes ,使用mini-main-list引數可以僅僅包含剛才說的classes。
關於寫分析指令碼的思路是:直接使用mini-main-list引數獲取build目錄下的main-list檔案,這樣manifest宣告的類和他們的直接依賴類搞定的了,那後者的直接依賴類怎麼解?這些在dvk runtime也是必須的classes。一個思路是解析class檔案獲得該class的依賴類。還一個思路是自己使用Dexclassloader 載入dex,然後hook getClass()方法,呼叫一次就記錄一個。都挺折騰的。
2.由於歷史原因,總悟君在維護的App的manifest註冊的元件的那些類,承載業務太多,依賴很多三方jar,導致直接依賴類非常多,而且短時間內無法梳理精簡,沒辦法mini化主dex。
3.Application的啟動入口太多。Appication初始化未必是由launcher Activity的啟動觸發,還有可能是因為Service ,Receiver ,ContentProvider 的啟動。 靠攔截重寫Instrumentation execStartActivity 解決不了問題。要為 Service ,Receiver ,ContentProvider 分別寫基類,然後在oncreate()裡判斷是否要非同步載入secondary.dex。如果需要,彈出Loading Acitvity?使用者看到這個會感覺比較怪異。
結合自身App的實際情況來看美團的拆包方案雖然很美好然但是不能照搬啊。果然不能愉快地回家看動漫了。

考慮到剛才說的2,3原因,先不要急著動手寫分析指令碼。總悟君期望找到更好的方案。問題到現在變成了:既希望在Application的attachContext()方法裡同步載入secondary.dex,又不希望卡住UI執行緒。如果思路限制線上程非同步化上,確實不可能實現。於是發現了微信開發團隊的這篇文章。該文章介紹了關於這一問題 FB/QQ/微信的解決方案。FB的解決思路特別贊,讓Launcher Activity在另外一個程序啟動!當然這個Launcher Activity就是用來load dex 的 ,load完成就啟動Main Activity。
微信這篇文章給出了一個非常重要的觀點:安裝完成之後第一次啟動時,是secondary.dex的dexopt花費了更多的時間。認識到這點非常重要,使得問題又轉化為:在不阻塞UI執行緒的前提下,完成dexopt,以後都不需要再次dexopt,所以可以在UI執行緒install dex 了!文章最後給了一個對FB方案的改進版。
仔細讀完感覺完全可行。
1.對現有程式碼改動量最小。
2.該方案不關注Application被哪個元件啟動。Activity ,Service ,Receiver ,ContentProvider 都滿足。(有個問題要說明:如細心網友指出的那樣,新安裝還未啟動但是收到Receiver的場景下,會導致Load介面出現。這個場景實際出現機率比較少,且僅出現一次。可以接受。) 
3.該方案不限制 Application ,Activity ,Service ,Receiver ,ContentProvider 繼續新增業務。
於是總悟君實現了這篇文章最後介紹的改進版的方法,稍微有一點點擴充。

流程圖如下

方案的流程圖上最終解決問題版的程式碼!
在Application裡面(這裡不要再繼承自MultiApplication了,我們要手動載入Dex):

  1. import java.util.Map;
  2. import java.util.jar.Attributes;
  3. import java.util.jar.JarFile;
  4. import java.util.jar.Manifest;
  5. publicclassAppextendsApplication{
  6. publicstaticfinalString KEY_DEX2_SHA1 ="dex2-SHA1-Digest";
  7. @Override
  8. protectedvoid attachBaseContext(Contextbase){
  9. super.attachBaseContext(base);
  10. LogUtils.d("loadDex","App attachBaseContext ");
  11. if(!quickStart()&&Build.VERSION.SDK_INT <Build.VERSION_CODES.L