[轉貼] Android DEX 自動拆包及動態加載簡介

概述

作為一個 Android 開發者,在開發應用時,隨著業務規模發展到一定程度,不斷地加入新功能、添加新的 Library,代碼在急劇的膨脹,相應的 Apk 大小也急劇增加, 那麼終有一天,你會不幸遇到這個錯誤:

  1. 生成的 apk 在android 2.3或之前的機器上無法安裝,提示INSTALL_FAILED_DEXOPT
  2. 方法數量過多,編譯時出錯,提示:
    Conversion to Dalvik format failed:Unable to execute dex: method ID not in [0, 0xffff]: 65536

而問題產生的具體原因如下:

  1. 無法安裝(Android 2.3 INSTALL_FAILED_DEXOPT)問題,是由 dexopt 的 LinearAlloc 限制引起的,在 Android 版本不同分別經歷了 4M/5M/8M/16M 限制,目前主流 4.2.x 系統上可能都已到 16M,在 Gingerbread 或者以下系統 LinearAllocHdr 分配空間只有5M大小的,高於 Gingerbread 的系統提升到了 8M。Dalvik linearAlloc 是一個固定大小的緩衝區。在應用的安裝過程中,系統會運行一個名為 dexopt 的程序為該應用在當前機型中運行做準備。dexopt 使用 LinearAlloc 來存儲應用的方法信息。Android 2.2 和 2.3 的緩衝區只有 5MB,Android 4.x 提高到了 8MB 或 16MB。當方法數量過多導致超出緩衝區大小時,會造成 dexopt 崩潰。

  2. 超過最大方法數限制的問題,是由於 DEX 文件格式限制,一個 DEX 文件中 method 個數採用使用原生類型 short 來索引文件中的方法,也就是 4 個字節共計最多表達 65536 個 method,field/class 的個數也均有此限制。對於 DEX 文件,則是將工程所需全部 class 文件合並且壓縮到一個 DEX 文件期間,也就是 Android 打包的 DEX 過程中, 單個 DEX 文件可被引用的方法總數(自己開發的代碼以及所引用的Android框架、Library 的代碼)被限制為 65536;

插件化? MultiDex?

解決這個問題,一般有下面幾種方案,一種方案是加大 Proguard 的力度來減小 DEX 的大小和方法數,但這是治標不治本的方案,隨著業務代碼的添加,方法數終究會到達這個限制,一種比較流行的方案是插件化方案,另外一種是採用 Google 提供的 MultiDex 方案,以及 Google 在推出 MultiDex 之前 Android Developers 部落格介紹的通過自定義類加載過程, 再就是Facebook推出的為 Android 應用開發的 Dalvik補丁, 但 Facebook 部落格裡寫的不是很詳細;我們在插件化方案上也做了探索和嘗試,發現部署插件化方案,首先需要梳理和修改各個業務線的代碼,使之解耦,改動的面和量比較巨大,通過一定的探討和分析,我們認為對我們目前來說採用 MultiDex 方案更靠譜一些,這樣我們可以快速和簡潔的對代碼進行拆分,同時代碼改動也在可以接受的範圍內; 這樣我們採用了 Google 提供的 MultiDex 方式進行了開發。

插件化方案在業內有不同的實現原理,這裡不再一一列舉,這裡只列舉下 Google 為構建超過 65K 方法數的應用提供官方支持的方案:MultiDex

首先使用 Android SDK Manager 升級到最新的 Android SDK Build Tools 和 Android Support Library。然後進行以下兩步操作:

  1. 修改 Gradle 配置文件,啟用 MultiDex 並包含 MultiDex 支持:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    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'
    }
  2. 讓應用支持多 DEX 文件。在官方文檔中描述了三種可選方法:
    在 AndroidManifest.xml 的 application 中聲明 android.support.MultiDex.MultiDexApplication;
    如果你已經有自己的 Application 類,讓其繼承 MultiDexApplication;
    如果你的 Application 類已經繼承自其它類,你不想/能修改它,那麼可以重寫 attachBaseContext() 方法:

    1
    2
    3
    4
    5
    @Override 
    protected void attachBaseContext(Context base) {
    super.attachBaseContext(base);
    MultiDex.install(this);
    }

並在Manifest中添加以下聲明:

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

...
</application>
</manifest>

如果已經有自己的 Application,則讓其繼承 MultiDexApplication 即可。

Dex 自動拆包及動態加載 MultiDex 帶來的問題

在第一版本採用 MultiDex 方案上線後,在 Dalvik 下 MultiDex 帶來了下列幾個問題:

  1. 在冷啟動時因為需要安裝 DEX 文件,如果 DEX 文件過大時,處理時間過長,很容易引發 ANR(Application Not Responding);

  2. 採用 MultiDex 方案的應用可能不能在低於 Android 4.0 (API level 14) 機器上啟動,這個主要是因為 Dalvik linearAlloc 的一個 bug (Issue 22586);

  3. 採用 MultiDex 方案的應用因為需要申請一個很大的內存,在運行時可能導致程序的崩潰,這個主要是因為 Dalvik linearAlloc 的一個限制(Issue 78035). 這個限制在 Android 4.0 (API level 14)已經增加了, 應用也有可能在低於 Android 5.0 (API level 21)版本的機器上觸發這個限制;

而在 ART 下 MultiDex 是不存在這個問題的,這主要是因為 ART 下採用 Ahead-of-time (AOT) compilation 技術,系統在 APK 的安裝過程中會使用自帶的 dex2oat 工具對 APK 中可用的 DEX 文件進行編譯並生成一個可在本地機器上運行的文件,這樣能提高應用的啟動速度,因為是在安裝過程中進行了處理這樣會影響應用的安裝速度,對 ART 感興趣的可以參考一下 ART 和 Dalvik 的區別.

MultiDex 的基本原理是把通過 DexFile 來加載 Secondary DEX,並存放在 BaseDexClassLoader 的 DexPathList 中。

下面代碼片段是 BaseDexClassLoader findClass 的過程:

1
2
3
4
5
6
7
8
9
10
11
12
protected Class<?> findClass(String name) throws ClassNotFoundException {
List<Throwable> suppressedExceptions = new ArrayList<Throwable>();
Class c = pathList.findClass(name, suppressedExceptions);
if (c == null) {
ClassNotFoundException cnfe = new ClassNotFoundException("Didn't find class \"" + name + "\" on path: " + pathList);
for (Throwable t : suppressedExceptions) {
cnfe.addSuppressed(t);
}
throw cnfe;
}
return c;
}

下面代碼片段為怎麼通過 DexFile 來加載 Secondary DEX 並放到 BaseDexClassLoader 的 DexPathList 中:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
private static void install(ClassLoader loader, List<File> additionalClassPathEntries,
File optimizedDirectory)

throws IllegalArgumentException, IllegalAccessException,
NoSuchFieldException, InvocationTargetException, NoSuchMethodException {

/* The patched class loader is expected to be a descendant of
* dalvik.system.BaseDexClassLoader. We modify its
* dalvik.system.DexPathList pathList field to append additional DEX
* file entries.
*/

Field pathListField = findField(loader, "pathList");
Object dexPathList = pathListField.get(loader);
ArrayList<IOException> suppressedExceptions = new ArrayList<IOException>();
expandFieldArray(dexPathList, "dexElements", makeDexElements(dexPathList,
new ArrayList<File>(additionalClassPathEntries), optimizedDirectory,
suppressedExceptions));
try {
if (suppressedExceptions.size() > 0) {
for (IOException e : suppressedExceptions) {
//Log.w(TAG, "Exception in makeDexElement", e);
}
Field suppressedExceptionsField =
findField(loader, "dexElementsSuppressedExceptions");
IOException[] dexElementsSuppressedExceptions =
(IOException[]) suppressedExceptionsField.get(loader);

if (dexElementsSuppressedExceptions == null) {
dexElementsSuppressedExceptions =
suppressedExceptions.toArray(
new IOException[suppressedExceptions.size()]);
} else {
IOException[] combined =
new IOException[suppressedExceptions.size() +
dexElementsSuppressedExceptions.length];
suppressedExceptions.toArray(combined);
System.arraycopy(dexElementsSuppressedExceptions, 0, combined,
suppressedExceptions.size(), dexElementsSuppressedExceptions.length);
dexElementsSuppressedExceptions = combined;
}

suppressedExceptionsField.set(loader, dexElementsSuppressedExceptions);
}
} catch(Exception e) {
}
}

Dex自動拆包及動態加載方案簡介

通過查看 MultiDex 的源碼,我們發現 MultiDex 在冷啟動時容易導致 ANR 的瓶頸, 在 2.1 版本之前的 Dalvik 的 VM版本中, MultiDex 的安裝大概分為幾步,第一步打開 apk 這個 zip 包,第二步把 MultiDex 的 dex 解壓出來(除去Classes.dex 之外的其他 DEX,例如:classes2.dex, classes3.dex 等等),因為 android 系統在啟動 app 時只加載了第一個 Classes.dex,其他的 DEX 需要我們人工進行安裝,第三步通過反射進行安裝,這三步其實都比較耗時, 為瞭解決這個問題我們考慮是否可以把 DEX 的加載放到一個異步線程中,這樣冷啟動速度能提高不少,同時能夠減少冷啟動過程中的 ANR,對於 Dalvik linearAlloc 的一個缺陷(Issue 22586)和限制(Issue 78035),我們考慮是否可以人工對 DEX 的拆分進行干預,使每個 DEX 的大小在一定的合理範圍內,這樣就減少觸發 Dalvik linearAlloc 的缺陷和限制; 為了實現這幾個目的,我們需要解決下面三個問題:

  1. 在打包過程中如何產生多個的 DEX 包?
  2. 如果做到動態加載,怎麼決定哪些DEX動態加載呢?
  3. 如果啟動後在工作線程中做動態加載,如果沒有加載完而用戶進行頁面操作需要使用到動態加載 DEX 中的 class 怎麼辦?

我們首先來分析如何解決第一個問題,在使用 MultiDex 方案時,我們知道 BuildTool 會自動把代碼進行拆成多個 DEX 包,並且可以通過配置文件來控制哪些代碼放到第一個 DEX 包中, 下圖是 Android 的打包流程示意圖:
android_packaging.png

為了實現產生多個 DEX 包,我們可以在生成 DEX 文件的這一步中, 在 Ant 或 gradle 中自定義一個 Task 來干預 DEX 產生的過程,從而產生多個 DEX,下圖是在 gradle 中干預產生 DEX 的自定 task 的程式碼:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
tasks.whenTaskAdded { task ->
if (task.name.startsWith('proguard') && (task.name.endsWith('Debug') || task.name.endsWith('Release'))) {
task.doLast {
makeDexFileAfterProguardJar();
}
task.doFirst {
delete "${project.buildDir}/intermediates/classes-proguard";

String flavor = task.name.substring('proguard'.length(), task.name.lastIndexOf(task.name.endsWith('Debug') ? "Debug" : "Release"));
generateMainIndexKeepList(flavor.toLowerCase());
}
} else if (task.name.startsWith('zipalign') && (task.name.endsWith('Debug') || task.name.endsWith('Release'))) {
task.doFirst {
ensureMultiDexInApk();
}
}
}

上一步解決了如何打包出多個 DEX 的問題了,那我們該怎麼該根據什麼來決定哪些 class 放到 Main DEX,哪些放到 Secondary DEX 呢(這裡的 Main DEX 是指在 2.1 版本的 Dalvik VM 之前由 android 系統在啟動 apk 時自己主動加載的 Classes.dex,而S econdary DEX 是指需要我們自己安裝進去的 DEX,例如:Classes2.dex, Classes3.dex等), 這個需要分析出放到 Main DEX 中的 class 依賴,需要確保把 Main DEX 中 class 所有的依賴都要放進來,否則在啟動時會發生 ClassNotFoundException,這裡我們的方案是把 Service、Receiver、Provider 涉及到的代碼都放到 Main DEX 中,而把 Activity 涉及到的代碼進行了一定的拆分,把首頁 Activity、Laucher Activity、歡迎頁的 Activity、城市列表頁 Activity 等所依賴的 class 放到了 Main DEX 中,把二級、三級頁面的 Activity 以及業務頻道的代碼放到了 Secondary DEX 中,為了減少人工分析 class 的依賴所帶了的不可維護性和高風險性,我們編寫了一個能夠自動分析 Class依賴的腳本, 從而能夠保證 Main DEX 包含 class 以及他們所依賴的所有 class 都在其內,這樣這個腳本就會在打包之前自動分析出啟動到 Main DEX 所涉及的所有代碼,保證 Main DEX 運行正常。

隨著第二個問題的迎刃而解,我們來到了比較棘手的第三問題,如果我們在後台加載 Secondary DEX 過程中,用戶點擊界面將要跳轉到使用了在 Secondary DEX 中 class 的界面, 那此時必然發生 ClassNotFoundException,那怎麼解決這個問題呢,在所有的 Activity 跳轉代碼處添加判斷 Secondary DEX 是否加載完成?這個方法可行,但工作量非常大; 那有沒有更好的解決方案呢?我們通過分析 Activity 的啟動過程,發現 Activity 是由 ActivityThread 通過 Instrumentation 來啟動的,我們是否可以在 Instrumentation 中做一定的手腳呢?通過分析代碼 ActivityThread 和 Instrumentation 發現, Instrumentation 有關 Activity 啟動相關的方法大概有:execStartActivity、newActivity等等,這樣我們就可以在這些方法中添加代碼邏輯進行判斷這個 Class 是否加載了,如果加載則直接啟動這個 Activity,如果沒有加載完成則啟動一個等待的 Activity 顯示給用戶,然後在這個 Activity 中等待後台 Secondary DEX 加載完成,完成後自動跳轉到用戶實際要跳轉的 Activity;這樣在代碼充分解耦合,以及每個業務代碼能夠做到顆粒化的前提下,我們就做到 Secondary DEX 的按需加載了, 下面是 Instrumentation 添加的部分關鍵代碼:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
public ActivityResult execStartActivity(Context who, IBinder contextThread, IBinder token, Activity target,
Intent intent, int requestCode)
{

ActivityResult activityResult = null;
String className;
if (intent.getComponent() != null) {
className = intent.getComponent().getClassName();
} else {
ResolveInfo resolveActivity = who.getPackageManager().resolveActivity(intent, 0);

if (resolveActivity != null && resolveActivity.activityInfo != null) {
className = resolveActivity.activityInfo.name;
} else {
className = null;
}
}

if (!TextUtils.isEmpty(className)) {
boolean shouldInterrupted = !MeituanApplication.isDexAvailable();
if (MeituanApplication.sIsDexAvailable.get() || mByPassActivityClassNameList.contains(className)) {
shouldInterrupted = false;
}
if (shouldInterrupted) {
Intent interruptedIntent = new Intent(mContext, WaitingActivity.class);

activityResult = execStartActivity(who, contextThread, token, target, interruptedIntent, requestCode);
} else {
activityResult = execStartActivity(who, contextThread, token, target, intent, requestCode);
}
} else {
activityResult = execStartActivity(who, contextThread, token, target, intent, requestCode);
}

return activityResult;
}

public Activity newActivity(Class<?> clazz, Context context, IBinder token,
Application application, Intent intent, ActivityInfo info,
CharSequence title, Activity parent, String id, Object lastNonConfigurationInstance)

throws InstantiationException, IllegalAccessException {


String className = "";
Activity newActivity = null;
if (intent.getComponent() != null) {
className = intent.getComponent().getClassName();
}

boolean shouldInterrupted = !MeituanApplication.isDexAvailable();
if (MeituanApplication.sIsDexAvailable.get() || mByPassActivityClassNameList.contains(className)) {
shouldInterrupted = false;
}
if (shouldInterrupted) {
intent = new Intent(mContext, WaitingActivity.class);
newActivity = mBase.newActivity(clazz, context, token,
application, intent, info, title, parent, id,
lastNonConfigurationInstance);
} else {
newActivity = mBase.newActivity(clazz, context, token,
application, intent, info, title, parent, id,
lastNonConfigurationInstance);
}
return newActivity;
}

實際應用中我們還遇到另外一個比較棘手的問題, 就是 Field 的過多的問題,Field 過多是由我們目前採用的代碼組織結構引入的,我們為了方便多業務線、多團隊並發協作的情況下開發,我們採用的 aar 的方式進行開發,並同時在 aar 依賴鏈的最底層引入了一個通用業務 aar,而這個通用業務 aar 中包含了很多資源,而 ADT14 以及更高的版本中對 Library 資源處理時,Library 的 R 資源不再是static final的了,詳情請查看 google 官方說明,這樣在最終打包時 Library 中的 R 沒法做到內聯,這樣帶來了 R field 過多的情況,導致需要拆分多個 Secondary DEX,為瞭解決這個問題我們採用的是在打包過程中利用腳本把 Libray 中 R field(例如 ID、Layout、Drawable 等)的引用替換成常量,然後刪去 Library 中 R.class 中的相應 Field。

總結

上面就是我們在使用 MultiDex 過程中進化而來的DEX自動化拆包的方案,這樣我們就可以通過腳本控制來進行自動化的拆分 DEX,然後在運行時自由的加載 Secondary DEX,既能保證冷啟動速度,又能減少運行時的內存佔用。

原文轉貼至美团技术团队博客