Android Dex分包變形記
一、背景
就在項目灰度測試前不久,爆出了在 Android 3.0以下手機上安裝時出現 INSTALL _ FAILED_DEXOPT,導致安裝失敗。這一問題意味著項目將不能在 Android 3.0以下的手機上安裝使用,對項目的發布有比較大的影響,所以必須盡快解決。
INSTALL _ FAILED_DEXOPT導致無法安裝的問題,從根本上來說,可能是兩個原因造成的:
(1) 單個 dex 文件方法總數65K 的限制。
(2) Dexopt 的 LinearAlloc 限制。
當 Android 系統安裝一個應用的時候,有一步是對 Dex 進行優化,這個過程有一個專門的工具來處理,叫 DexOpt。DexOpt 是在第一次加載 Dex 文件的時候執行的。這個過程會生成一個 ODEX 文件,即 Optimised Dex。執行 ODEX 的效率會比直接執行 Dex 文件的效率要高很多。
但是在早期的 Android 系統中,DexOpt 有兩個問題。(一):DexOpt 會把每一個類的方法 id 檢索起來,存在一個鏈表結構里面,但是這個鏈表的長度是用一個 short 類型來保存的,導致了方法 id 的數目不能夠超過65536個。當一個項目足夠大的時候,顯然這個方法數的上限是不夠的。(二):Dexopt 使用 LinearAlloc 來存儲應用的方法信息。Dalvik LinearAlloc 是一個固定大小的緩沖區。在Android 版本的歷史上,LinearAlloc 分別經歷了4M/5M/8M/16M限制。Android 2.2和2.3的緩沖區只有5MB,Android 4.x提高到了8MB 或16MB。當方法數量過多導致超出緩沖區大小時,也會造成dexopt崩潰。
盡管在新版本的 Android 系統中,DexOpt 修復了方法數65K的限制問題,并且擴大了 LinearAlloc 限制,但是我們仍然需要對低版本的 Android 系統做兼容。
回頭說項目。由于項目新版本新增功能點和代碼較多,在方法數減無可減的時候,仍然不能解決INSTALL _ FAILED _ DEXOPT的問題。所以,最終我們采用了 dex 分包的方案,來避開了 Android 3.0以下平臺的方法數和 LinearAlloc 限制。
簡單的說,分包就是在打包時將應用的代碼分成多個 dex,使得主 dex 的方法數和所需的 LinearAlloc 不超過系統限制。在應用啟動或運行過程中,首先是主 dex 啟動運行后,再加載從 dex,這樣就繞開了這兩個限制。
這樣,我們的分包方案就要解決兩個問題:一是如何對 dex 進行拆分,二是如何加載從 dex。
二、Google 官方方案
1.Dex 拆分
首先,我們需要解決如何對dex進行拆分?
通過學習資料,我們知道,對于方法數超過65K 的問題,Google 官方從 Android Build tools 21.1就開始著手解決了。
先看官方網站提供的配置。Google MultiDex 官方文檔是針對 Gradle 進行配置的,如下:
android {
compileSdkVersion 21
buildToolsVersion "21.1.0"
defaultConfig {
...
minSdkVersion 14
targetSdkVersion 21
...
// Enabling multidex support.
multiDexEnabled true
}
...
}
dependencies {
compile 'com.android.support:multidex:1.0.0'
}</code></pre>
那么,是不是按 Google 官方文檔配置一下就 OK 了呢?不管怎樣,這是官方提供的方案,而且是最直接的做法,所以我們應該先試一試。
因為我們項目的 RDM 構建環境采用的是 ant 腳本編譯,所以首先要想辦法把 Google 官方編譯配置改造成 ant 腳本。
官方文檔上只提供了如何使用 MultiDex,沒有說明構建時如何打包出多個 dex。其實是因為如果用了這種 Gradle來構建,當應用構建時,構建工具會自動分析哪些類必須放在第一個 DEX 文件(主 dex),哪些類可以放在附加的 DEX 文件(從 dex)中,并將分析結果輸出到 dx 進行后續打包。當它創建了主 dex 文件(classes.dex)后,如果有必要會繼續創建從 DEX 文件,如 classes2.dex, classes3.dex。這種方法優點是配置比較簡單,但是最大的缺點是不能指定哪些類必須包含在主 dex 中,容易導致應用啟動時某些類找不到,出現 Class Not Found Exception。
我們把上述 Gradle 的配置改成 ant 腳本時,就不能簡單套用了。通過查看 dx 工具的用法:

參數說明:
–multi-dex:多 dex 打包的開關。
–main-dex-list=<file>:參數是一個類列表的文件,在該文件中的類會被打包在第一個 dex 中。
–minimal-main-dex:只有在–main-dex-list 文件中指定的類被打包在第一個 dex,其余的都在第二個 dex 文件中。
因為后兩個參數是 optional 參數,所以理論上只需給 dx 加上“–multi-dex”參數即可生成出 classes.dex、classes2.dex、classes3.dex、…。
在 Gradle 中可以做如下的配置:
afterEvaluate {
tasks.matching {
it.name.startsWith('dex')
}.each { dx ->
if (dx.additionalParameters == null) {
dx.additionalParameters = ['--multi-dex']
} else {
dx.additionalParameters += '--multi-dex'
}
}
}</code></pre>
好了,這樣我們就可以改造我們的 ant 腳本了。
改造的方法是在項目打包的 ant 腳本中引入 Android build Tools 21.1.2,并把用 dx 生成 dex 的部分改造成下面的樣子:

編譯、打包,并沒有像預期那樣生成多個 dex,而是只生成了一個 classes.dex:

生成的 apk 包跟 dex 分包前一樣。為什么會這樣?
再看 dx 的參數,main-dex-list 和 minimal-main-dex 只會影響到主 dex 中包含的文件,不會影響到從 dex 是否生成,所以應該是其他原因造成的。
查不到資料,分析源代碼就是解決問題的不二法門。于是我把 dx.jar 反編譯了一下,通過分析,找到了下面的幾行關鍵代碼:


顯然,dx 進行多 dex 打包時,默認每個 dex 中方法數最大為65536。而查看當前應用 dex 的方法數,一共只有51392(方法數沒超標,主要是 LinearAlloc 超標),沒有達到65536,所以打包的時候只有一個 dex。
再繼續分析代碼,發現下面一段關鍵代碼:

這說明 dx 有一個隱藏的參數:–set-max-idx-number,這個參數可以用于設置 dx 打包時每個 dex 最大的方法數,但是此參數并未在 dx 的 Usage 中列出(坑爹啊!)。
我們在 ant 腳本中把這個參數設置上,暫時設置每個 dex 的方法數最大為48000:

重新打包,結果如下:

果然,第二個 dex 出現了!
可是,觀察一下 res 目錄,這里出現了一個新的問題,drawable 密度后綴的資源目錄都多了一個 v4:

為什么這幾個目錄會帶 v4后綴呢?原來這是 R6以上的 Android SDK Tools 自動打包工具新加的一個處理,即為這些在 Android 1.0 時不存在的密度后綴命名的資源路徑名稱后面自動添加一個適合的版本后綴,以確保老版本不使用這些資源(只有 API level 4以及更高版本支持后綴),v4 就表示使用在 Android 1.6 或更高版本。
上述的 Dex 拆分過程采用的就是 Google 官方的方案。Dex 拆分已經完成,如何加載呢?
2.Dex加載
因為 Android 系統在啟動應用時只加載了主 dex(Classes.dex),其他的 dex 需要我們在應用啟動后進行動態加載安裝。
Google 官方方案是如何加載的呢?
Google 官方支持 Multidex 的 jar 包是 android-support-multidex.jar,該 jar 包從 build tools 21.1 開始支持。這個 jar 加載 apk 中的從 dex 流程如下:

此處主要的工作就是從 apk 中提取出所有的從 dex(classes2.dex,classes3.dex,…),然后通過反射依次安裝加載從 dex 并合并 DexPathList 的 Element 數組。
如果引用這個 jar 包,MultiDexApplication 的 Java Doc 提供了三種方式來加載從 dex:
1)在 AndroidManifest.xml 中,把 application 定義為 android.support.multidex.MultiDexApplication。
2)用自定義的 Application 類繼承 android.support.multidex.MultiDexApplication,再配置 application 為自定義的類。
3)如果之前自定義的 Application 類已經繼承了其他 Application 類,而且不想改變,那么可以重寫自定義 Application 類的 attachBaseContext() 或者 onCreate() 方法,并添加語句 MultiDex.install(this)。
為了使改動最小,我們采用上述3)中的調用方式:

到此為止,用 Google 官方方案進行 dex 拆分和加載就已經完成了。安裝運行一下試試!
3.安裝運行
我們把分包后的 apk 在 Android 4.3的手機上進行安裝。沒有問題,順利安裝上了!
沒想到的是,啟動時沒出現任何頁面,直接 crash。Crash 的 log 如下:

從 log 上看,項目在啟動閃屏頁面時無法實例化 com.example.AppService.AstApp,因為找不到 com.example.AppService.AstApp 這個類。既然 Application 類都找不到,那么我們在 Application 中加載從 dex 更加沒有執行到了。
反編譯一下 classes.dex 和 classes2.dex,果然 com.example.AppService.AstApp 是在classes2.dex,所以剛啟動時在主 dex(classes.dex) 中找不到 com.example.AppService.AstApp(Application 類)。

理論上,啟動必需的代碼應該放在主 dex 中,這些代碼包括 Application、BaseActivity 等代碼以及繼承自它們的代碼的一個依賴集。但是我們看到,單純依賴于構建工具自動進行 dex 拆分時,我們無法決定或干預哪些類應該放在主 dex,哪些類應該放在從 dex,這就可能導致啟動時往往會有類庫找不到。
接下來,我們就得想辦法來自主定制主、從 dex 包含的文件,使它們完全可控。
4.Google 官方方案的小結
采用 Google 官方的拆包方案走到現在,我們需要再梳理一下思路了。
到現在為止,已經解決的問題是:
1)能正常打出多個 dex;
2)可以指定每個 dex 的大小;
3)可以加載多個 dex。
尚未解決的問題是:如何指定哪些類應該放到主 dex,哪些類應該放到從 dex?
關于這個問題,從前面 dx 工具的用法中可得知,我們可以在 dx 的參數中加入–main-dex-list,指定哪些類應該放在主 dex 中(也可同時配合使用參數–minimal-main-dex,指定主 dex 中只包含在–main-dex-list 文件中指定的類)。
可是問題又來了,怎么得到 main-dex-list 文件?在大的工程開發中,手動添加文件列表顯然不現實。
同時,在前面研究和驗證 Google 官方方案的過程中,也有幾個不得不提的問題:
1)需要高版本的 build Tools、SDK Tools 編譯打包;
2)編譯打包 apk 后生成的 drawable 密度后綴目錄被添加了 v4 后綴;
3)Google 的 MultiDex 方案在運行中需要比較大的 LinearAlloc,但是由于 Android 4.0 (API level 14) 以下的機器上 Dalvik LinearAlloc 的一個缺陷 (Issue 22586) 和限制 (Issue 78035),可能導致運行時無法滿足 LinearAlloc 的需求而造成 DexOpt 失敗或者 Dalvik 虛擬機崩潰;
4)從 dex 不能太大,否則在運行時安裝加載從 dex 的過程比較復雜和耗時,可能會導致應用程序無響應 (ANR) 的錯誤。
由于項目是首次做分包,安裝包改動已經比較大了,如果再將一直使用且沒有問題的 build Tools、SDK Tools 冒然升級以及 drawable 密度后綴目錄改變,那么無論怎樣,它們所帶來的風險和挑戰都是比較大的,也會帶來后期測試和維護的工作量。所以,我們的方案一定要做到盡量減少這些改變。而對于后面兩點,我們就應該考慮對 dex 的拆分進行干預,使每個 dex 的大小在一定的合理范圍內,消除或減少觸發 Dalvik LinearAlloc 缺陷和限制的概率以及分包引起的 ANR。
綜合以上幾點,我們就需要在對官方方案透徹研究的基礎上,自己實現工具腳本來進行 dex 的自主拆分、加載,便于靈活的適應低版本 Android SDK tools 以及 Android 平臺。
三、DEX 自動拆包和動態加載方案
1.Dex 拆分
根據前面對官方方案的研究總結,我們可以很快梳理出下面幾個dex拆分步驟:
1)自動掃描整個工程代碼得到 main-dex-list;
2)根據 main-dex-list 對整個工程編譯后的所有 class 進行拆分,將主、從 dex 的 class 文件分開;
3)用 dx 工具對主、從 dex 的 class 文件分別打包成 .dex 文件,并放在 apk 的合適目錄。
怎么自動生成 main-dex-list?
Android SDK 從 build tools 21 開始提供了 mainDexClasses 腳本來生成主 dex 的文件列表。查看這個腳本的源碼,可以看到它主要做了下面兩件事情:
1)調用 proguard 的 shrink 操作來生成一個臨時 jar 包;
2)將生成的臨時 jar 包和輸入的文件集合作為參數,然后調用com.android.multidex.MainDexListBuilder 來生成主 dex 文件列表。
Proguard的官網執行步驟如下:

在 shrink 這一步,proguard 會根據 keep 規則保留需要的類和類成員,并丟棄不需要的類和類成員。也就是說,上面 shrink 步驟生成的臨時 jar 包里面保留了符合 keep 規則的類,這些類是需要放在主 dex 中的入口類。
但是僅有這些入口類放在主 dex 還不夠,還要找出入口類引用的其他類,不然仍然會在啟動時出現 NoClassDefFoundError。而找出這些引用類,就是調用的 com.android.multidex.MainDexListBuilder,它的部分核心代碼如下:

在調用 com.android.multidex.MainDexListBuilder 之后,符合 keep 規則的主 dex 文件列表就生成了。
既然 Android SDK 已經提供了這樣一種比較方便的工具,我們就不再重復發明輪子了。所以我們首先把 mainDexClasses 腳本進行了一些適當的改造,然后移植到 RDM 構建環境下,然后根據項目代碼的實際情況將主要的基礎類、common 類、wakeup 類做為補充規則加入掃描規則中,再加上基本規則 Application、Activity、Service、Provider、Receiver 等類,就組成了項目的主 dex 掃描規則。
這時,新的問題是,由于項目編譯打包時有代碼混淆的步驟,那我們掃描主 dex 文件列表時到底是在代碼混淆之前還是之后?理論上,混淆前后都可以掃描,但是混淆之后掃描時主要的問題是:在制定 keep 規則時,最合理的方式是采用包路徑來制定規則,而混淆后的代碼中大部分包路徑被混淆了,我們無法根據混淆后的包路徑來制定 keep 規則,也就無法完全指定哪些文件應該放在主 dex 中。所以,結論就是,我們必須在代碼混淆之前掃描生成主 dex 文件列表。
再往下做時,問題又出現了,我們是在掃描生成主 dex 文件列表后就立刻將主、從 dex 的 class 文件拆分到不同目錄,然后各自進行代碼混淆呢還是統一混淆后再進行 class 文件的拆分呢?答案是,我們需要統一混淆后再做拆分。因為如果拆分后各自混淆,則必然會造成混淆后主、從 dex 引用類名的不一致,從而導致應用無法正常運行。
但是,這樣又有了新的問題,我們是在代碼混淆之前掃描生成的主 dex 文件列表,當代碼混淆之后,大部分類名稱和路徑都改變了,我們又如何根據主 dex 文件列表做拆分呢?答案是,因為 proguard 做代碼混淆時生成了一個混淆前后代碼之間的 mapping 關系文件,我們只需要根據這個 mapping 文件進行映射,即可得到混淆后的主 dex 文件列表。
到此為止,思路已經梳理得比較清楚了。
按照這個思路,很快就實現了工具腳本,完成了對主、從 dex 的拆分。這樣就實現了主、從 dex 的靈活的生成和定制,不僅解決了前面 Google 官方方案存在的問題,而且也為將來從 dex 的異步加載、按需加載提供了比較好的基礎。
最后,項目的從 dex 是打成 jar 包放在 assets 目錄,如下圖所示:

2.Dex加載
Google 官方提供的 android-support-multidex.jar 可以用來加載官方方案打包的 dex,也完全可以用于加載我們自己的方案打包的 dex,但是這種方式有下面幾個不利的地方:
1)靈活性不夠,需要所有的從 dex 跟主 dex 在同一級目錄,即都在 apk 的根目錄,而且從 dex 的命名要符合 classes2.dex、classes3.dex、…、classes(N).dex。
2)該 jar 包提供的是同步加載方式,而且是啟動時一次性加載所有的從 dex,但是從項目分包的需求以及其他產品的經驗來看,加載接口提供異步加載和按需加載的能力是很有必要的。
因此,我們的加載方案需要有比較好的靈活性以及提供同步加載、異步加載、按需加載的能力。根據這些要求,我們研究了網上一些開源的代碼(也包括 Google 官方 android-support-multidex.jar 的代碼),然后經過改造和驗證,實現了一種比較靈活的加載方案。
跟 Google 官方加載方案一樣,這個方案采用的也是運行時動態加載的方式,利用了 Dalvik 虛擬機的類加載器。
我們知道,在 Java 虛擬機里動態加載用的是 ClassLoader。但是在 Dalvik 虛擬機里,卻不是 ClassLoader,Android 為我們從 ClassLoader 派生出了兩個類:DexClassLoader 和 PathClassLoader。這兩者的區別就是 PathClassLoader 不能主動從 zip 包中釋放出 dex,因此只支持直接操作 dex 格式文件,或者已經安裝的 apk(因為已經安裝的 apk 在 cache 中存在緩存的 dex 文件);而 DexClassLoader 可以支持 .apk、.jar 和 .dex文件,并且會在指定的 outpath 路徑釋放出 dex 文件。
由于前面說了,在安裝包里有多個 dex 時,應用安裝時不會主動釋放從 dex,所以我們需要用 DexClassLoader 來釋放加載從 dex。當需要加載從 dex 時,加載邏輯會先從 apk 相應的目錄釋放出所需加載的從 dex,然后執行加載。
加載過程的部分核心代碼如下:

上述代碼是通過反射獲取 PathClassLoader 中的 DexPathList 中的 Element 數組(加載主 dex 后的 Element 數組)和 DexClassLoader 中的 DexPathList 中的 Element 數組(加載從 dex 后的 Element 數組),然后將兩個 Element 數組合并之后,再將其賦值給 PathClassLoader 的 Element 數組。這樣就將主、從 dex 中類的訪問方式進行了統一,所以也稱為 dex 的注入。
那么什么時候加載從 dex 呢?這個問題也就是從 dex 的加載時機。
如果是啟動時同步加載,一般可以在 Application 的 onCreate 或 attachBaseContext 中執行加載,兩者區別不大。不過,由于 Application 的 onCreate 調用是在 ContentProvider 的 OnCreate 調用之后,而 attachBaseContext 的調用是在 ContentProvider 的 OnCreate 調用之前,所以當 app 有注冊 ContentProvider 的時候,就必須在 attachBaseContext 中加載從 dex。
如果是按需加載,則在代碼充分解耦后,只要在從 dex 中的代碼調用之前執行加載,都是可以的。
3.安裝運行
Dex 拆分腳本和加載代碼都完成了,打一個包,然后在 Android 2.3 系統的手機上安裝運行試試吧。一切順利,終于出現了久違的閃屏頁!
4.小結
上面就是項目 dex 分包方案的研究經過,主要是把 Google 的方案研究清楚以后,又參考了網上的一些開源代碼,從而實現了自己的 DEX 自動拆包和動態加載方案。在我們的方案中,可以通過腳本工具來完全定制拆分過程和主、從 dex 文件內容,在運行時也能比較自由、靈活的動態加載從 dex。
四、性能影響
Dex 分包后,如果是啟動時同步加載,對應用的啟動速度會有一定的影響,但是主要影響的是安裝后首次啟動。這是因為安裝后首次啟動時,Android 系統會對加載的從 dex 做 Dexopt 并生成 ODEX,而 Dexopt 是比較耗時的操作,所以對安裝后首次啟動速度影響較大。在非安裝后首次啟動時,應用只需加載 ODEX,這個過程速度很快,對啟動速度影響不大。同時,從 dex 的大小也直接影響啟動速度,即從dex 越小則啟動越快。
目前項目的從 dex 的原始大小在 1M 左右。經過測試,安裝后首次啟動時,在 GT-I8160(Android 2.3) 上加載耗時大約 1200ms,在 N i9250(Android 4.3) 上加載耗時大約 1000ms;非安裝后首次啟動時,在這兩臺測試手機上的加載速度分別為約 10ms 和 4ms。
五、后續
分包方案落地后,我們又解決了覆蓋安裝和 MD5 校驗的問題。不過后續還有不少可優化的點如下:
(1) 應用啟動性能的優化。如添加啟動頁、提前做 DexOpt 等;
(2) 編譯腳本性能優化。由于分包是一個比較復雜和耗時的過程,開始時分包腳本的性能并不理想,后來經過我們兩次優化,將打包過程中的分包時間從7分多鐘優化到10秒以內;
(3) 研究未來可能的按需加載或異步加載從 dex 的問題。
騰訊Bugly簡介
Bugly是騰訊內部產品質量監控平臺的外發版本,支持iOS和Android兩大主流平臺,其主要功能是App發布以后,對用戶側發生的crash以及卡頓現象進行監控并上報,讓開發同學可以第一時間了解到app的質量情況,及時修改。目前騰訊內部所有的產品,均在使用其進行線上產品的崩潰監控。
騰訊內部團隊4年打磨,目前騰訊內部所有的產品都在使用,基本覆蓋了中國市場的移動設備以及網絡環境,可靠性有保證。使用Bugly,你就使用了和手機QQ、QQ空間、手機管家相同的質量保障手段,Bugly會持續對產品進行優化打磨,在服務好內部團隊的同時,幫助更多的開發者。
來自:http://dev.qq.com/topic/5913db5c29d8be2a14b64da8