dex分包變形記

騰訊Bugly特約做者:李金濤android

1、背景

就在項目灰度測試前不久,爆出了在 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 文件的效率要高不少。app

可是在早期的 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 系統作兼容。ide

回頭說項目。因爲項目新版本新增功能點和代碼較多,在方法數減無可減的時候,仍然不能解決INSTALL FAILED DEXOPT的問題。因此,最終咱們採用了 dex 分包的方案,來避開了 Android 3.0如下平臺的方法數和 LinearAlloc 限制。工具

簡單的說,分包就是在打包時將應用的代碼分紅多個 dex,使得主 dex 的方法數和所需的 LinearAlloc 不超過系統限制。在應用啓動或運行過程當中,首先是主 dex 啓動運行後,再加載從 dex,這樣就繞開了這兩個限制。性能

這樣,咱們的分包方案就要解決兩個問題:一是如何對 dex 進行拆分,二是如何加載從 dex。


2、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'
}

那麼,是否是按 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' 

       } 

    } 

}

好了,這樣咱們就能夠改造咱們的 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 平臺。


3、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。


4、性能影響

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。


5、後續

分包方案落地後,咱們又解決了覆蓋安裝和 MD5 校驗的問題。不事後續還有很多可優化的點以下:

(1) 應用啓動性能的優化。如添加啓動頁、提早作 DexOpt 等;

(2) 編譯腳本性能優化。因爲分包是一個比較複雜和耗時的過程,開始時分包腳本的性能並不理想,後來通過咱們兩次優化,將打包過程當中的分包時間從7分多鐘優化到10秒之內;

(3) 研究將來可能的按需加載或異步加載從 dex 的問題。


騰訊Bugly簡介

Bugly是騰訊內部產品質量監控平臺的外發版本,支持iOS和Android兩大主流平臺,其主要功能是App發佈之後,對用戶側發生的crash以及卡頓現象進行監控並上報,讓開發同窗能夠第一時間瞭解到app的質量狀況,及時修改。目前騰訊內部全部的產品,均在使用其進行線上產品的崩潰監控。

騰訊內部團隊4年打磨,目前騰訊內部全部的產品都在使用,基本覆蓋了中國市場的移動設備以及網絡環境,可靠性有保證。使用Bugly,你就使用了和手機QQ、QQ空間、手機管家相同的質量保障手段,Bugly會持續對產品進行優化打磨,在服務好內部團隊的同時,幫助更多的開發者。

相關文章
相關標籤/搜索