64K方法數(shù)Dex分包優(yōu)化方案

前言

最近開發(fā)中我們發(fā)現(xiàn),我們的產(chǎn)品在Android設(shè)備版本低于5.0以下第一次安裝啟動會出現(xiàn)黑屏、ANR等情況。而第二次,第三次,就不會出現(xiàn)這種情況。后來通過分析,我們確定了這是dex分包導(dǎo)致的。

首先要說的是,在我們項目中項目的方法數(shù)早已超過65535,也就是64k。我們已經(jīng)在利用官方的教程啟用分包并配置MultiDex。
本文暫不涉及LinearAlloc太小引起的 INSTALL_FAILED_DEXOPT 異常,因為。。我們的最低api為16,LinearAlloc都達(dá)到了8m或者16m。

本文是對dex分包的優(yōu)化方案。


分析

apk構(gòu)建流程

首先必須簡單了解Android Apk的構(gòu)建流程

apk構(gòu)建流程.png

如圖所示,編譯器將所有的.java文件編譯成字節(jié)碼最后打包成dex文件,然后和其他資源打包成apk,最后通過工具簽名,部署到設(shè)備上。

在你的最大方法數(shù)超過65535時,如果不進(jìn)行分包處理。那么在編譯的時候,就會報如下異常:
Conversion to Dalvik format failed:Unable to execute dex: method ID not in [0, 0xffff]: 65536
注意:不同的gradle版本可能報的異常文案不太一樣,但是大意都是說方法數(shù)超出了65536,一個dex文件無法放得下。

注意,當(dāng)你的方法數(shù)超過單個dex方法數(shù)限制的時候(默認(rèn)單個dex方法數(shù)為65535),gradle會構(gòu)建出多個dex文件。其中一個為主dex文件,其它的為子dex文件。命名為classesN.dex,N為dex的順序。

在我們新聞項目中,dex文件如下所示。


dex文件.png

classes.dex為主dex,其余的為子dex。

上面說了,單個dex文件的最大方法數(shù)就為65535。為什么是65535呢,因為android系統(tǒng)以一個short鏈表的數(shù)據(jù)結(jié)構(gòu)存儲著方法的索引,short為在android系統(tǒng)中大小為2個字節(jié),最大數(shù)也就是65535。(這其實是Google自己設(shè)計的坑。。)單個dex文件的最大方法數(shù)可以自己手動自定義,但是不能超過65535。

apk安裝流程

apk安裝流程.png

從圖中我們可以看到,一個apk中主要包含Dex FileResources & Native Code。其中后者是交給虛擬機(jī)Native執(zhí)行的,我們在這里不關(guān)心。我們只關(guān)心虛擬機(jī)如何處理加載Dex File
從圖中可以看到,虛擬機(jī)有DalvikART兩種實現(xiàn)。

ART

先說art,因為art比較簡單。在Android5.0(包含)以上版本的虛擬機(jī)實現(xiàn)中,Google用ART虛擬機(jī)替代了Dalvik。在應(yīng)用第一次安裝過程中,注意是第一次安裝過程中,并不是在應(yīng)用運行時。PackageManagerService會調(diào)用dex2oat函數(shù)將所有的.dex文件經(jīng)過一系列的處理,生成一個oat文件。而 oat 文件是 elf 文件,是可以在本地執(zhí)行的文件,而 Android Runtime 替換掉了虛擬機(jī)讀取的字節(jié)碼轉(zhuǎn)而用本地可執(zhí)行代碼,這就被叫做 AOT(ahead-of-time)。odex文件保存在/data/cache/dalvik_cache目錄下,而ART虛擬機(jī)實際執(zhí)行過程中,加載運行的就是.oat文件。所以在android5.0以上不需要擔(dān)心分包帶來的麻煩。

Dalvik

在Android系統(tǒng)版本低于5.0的設(shè)備上,虛擬機(jī)實現(xiàn)為Dalvik。在應(yīng)用第一次安裝啟動時,注意!!是第一次安裝啟動,Android系統(tǒng)的PackageManagerService調(diào)用dexopt函數(shù),對dex文件進(jìn)行優(yōu)化,將dex的依賴文件以及一些輔助數(shù)據(jù)打包成odex文件,即Optimised Dex,存放在/data/cache/dalvik_cache目錄下。保存格式為apk路徑 @ apk名 @ classes.dex。執(zhí)行 ODEX 文件的效率會比直接執(zhí)行 Dex 文件的效率要高很多。

odex文件.png

好了,坑來了。
上面我們說到了,android系統(tǒng)通過dexopt將我們的dex編譯成了odex,但是!!android系統(tǒng)它只會將主dex編譯成odex,不能將子dex也變成odex加載進(jìn)內(nèi)存中。所以,當(dāng)你的在構(gòu)建之后生成多個dex文件之后,你可以通過這種方式,在應(yīng)用啟動的回調(diào)方法中,將其他的子dex文件手動解壓、編譯、加載進(jìn)內(nèi)存中。這也是官方文檔的做法。

@Override
protected void attachBaseContext(Context base) {
    super.attachBaseContext(base);
    if (PreloadProcessHelper.getInstance(this).attachBaseContext()) {
        return;
    }
}

而通過MultiDex源碼可以看到,MultiDex的安裝大概分為幾步,第一步打開apk這個zip包,第二步把MultiDex的dex解壓出來(除去Classes.dex之外的其他DEX,例如:classes2.dex, classes3.dex等等),因為android系統(tǒng)在啟動app時只加載了第一個Classes.dex,其他的DEX需要我們?nèi)斯みM(jìn)行安裝,第三步通過反射進(jìn)行安裝。
這三步都是比較耗時,也比較容易引起ANR,甚至長時間的黑屏,影響用戶體驗。

好了。知道了原因。如何解決?

解決方案

在參考了眾多的網(wǎng)上資料后,目前主流大概有這么幾種方案解決。

微信:

首次加載在地球中頁中, 并用線程去加載(但是 5.0 之前加載 dex 時還是會掛起主線程一段時間(不是全程都掛起))。

dex 形式

微信是將包放在assets 目錄下的,在加載 dex 的代碼時,實際上傳進(jìn)去的是zip,在加載前需要驗證MD5,確保所加載的 dex文件 沒有被篡改。

dex 類分包規(guī)則

分包規(guī)則即將所有ApplicationContentProvider以及所有exportActivityServiceReceiver的間接依賴集都必須放在主 dex。

加載 dex 的方式

加載邏輯這邊主要判斷是否已經(jīng) dexopt,若已經(jīng) dexopt,即放在attachBaseContext 加載,反之放于地球中用線程加載。怎么判斷?因為在微信中,若判斷 revision 改變,即將 dex 以及dexopt 目錄清空。只需簡單判斷兩個目錄 dex 名稱、數(shù)量是否與配置文件的一致。

總的來說,這種方案用戶體驗較好,缺點在于太過復(fù)雜,每次都需重新掃描依賴集,而且使用的是比較大的間接依賴集。

Facebook:

Facebook的思路是將 MultiDex.install() 操作放在另外一個經(jīng)常進(jìn)行的。

dex 形式

與微信相同。

dex 類分包規(guī)則

Facebook 將加載 dex 的邏輯單獨放于一個單獨的 nodex 進(jìn)程中。

<activity 
android:exported="false"
android:process=":nodex"
 android:name="com.facebook.nodex.startup.splashscreen.NodexSplashActivity">

所有的依賴集為 Application、NodexSplashActivity 的間接依賴集即可。

加載 dex 的方式

因為NodexSplashActivityintent-filter 指定為 Main 和 LAUNCHER ,所以一打開 App 首先拉起 nodex 進(jìn)程,然后打開NodexSplashActivity 進(jìn)行 MultiDex.install() 。如果已經(jīng)進(jìn)行了 dexpot 操作的話就直接跳轉(zhuǎn)主界面,沒有的話就等待 dexpot 操作完成再跳轉(zhuǎn)主界面。

這種方式好處在于依賴集非常簡單,同時首次加載 dex 時也不會卡死。但是它的缺點也很明顯,即每次啟動主進(jìn)程時,都需先啟動 nodex 進(jìn)程。盡管 nodex 進(jìn)程邏輯非常簡單,這也需100ms以上。

美團(tuán)加載方案:

dex 形式

在 gradle 生成 dex 文件的這步中,自定義一個 task 來干預(yù) dex 的生產(chǎn)過程,從而產(chǎn)生多個 dex 。

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 類分包規(guī)則

把 Service、Receiver、Provider 涉及到的代碼都放到主 dex 中,而把 Activity 涉及到的代碼進(jìn)行了一定的拆分,把首頁 Activity、Laucher Activity 、歡迎頁的 Activity 、城市列表頁 Activity 等所依賴的 class 放到了主 dex 中,把二級、三級頁面的 Activity 以及業(yè)務(wù)頻道的代碼放到了第二個 dex 中,為了減少人工分析 class 的依賴所帶了的不可維護(hù)性和高風(fēng)險性,美團(tuán)編寫了一個能夠自動分析 class 依賴的腳本, 從而能夠保證主 dex 包含 class 以及他們所依賴的所有 class 都在其內(nèi),這樣這個腳本就會在打包之前自動分析出啟動到主 dex 所涉及的所有代碼,保證主 dex 運行正常。

加載 dex 的方式

通過分析 Activity 的啟動過程,發(fā)現(xiàn) Activity 是由 ActivityThread 通過 Instrumentation 來啟動的,那么是否可以在 Instrumentation 中做一定的手腳呢?通過分析代碼 ActivityThread 和 Instrumentation 發(fā)現(xiàn),Instrumentation 有關(guān) Activity 啟動相關(guān)的方法大概有:execStartActivity、 newActivity 等等,這樣就可以在這些方法中添加代碼邏輯進(jìn)行判斷這個 class 是否加載了,如果加載則直接啟動這個 Activity,如果沒有加載完成則啟動一個等待的 Activity 顯示給用戶,然后在這個 Activity 中等待后臺第二個 dex 加載完成,完成后自動跳轉(zhuǎn)到用戶實際要跳轉(zhuǎn)的 Activity;這樣在代碼充分解耦合,以及每個業(yè)務(wù)代碼能夠做到顆粒化的前提下,就做到第二個 dex 的按需加載了。

美團(tuán)的這種方式對主 dex 的要求非常高,因為第二個 dex 是等到需要的時候再去加載。重寫Instrumentation 的 execStartActivity 方法,hook 跳轉(zhuǎn) Activity 的總?cè)肟谧雠袛啵绻?dāng)前第二個 dex 還沒有加載完成,就彈一個 loading Activity等待加載完成。

綜合

微信的方案需要將 dex 放于 assets 目錄下,在打包的時候太過負(fù)責(zé);美團(tuán)的方案確實很 hack,但是對于項目已經(jīng)很龐大,耦合度又比較高的情況下并不適合。

最后,我們采用了Facebook的解決方案!

Talk is cheap. Show me the code. --- Linus Torvalds

哈哈哈哈我就直接上部分關(guān)鍵代碼了。
我們在應(yīng)用啟動的時候,默認(rèn)在一個子進(jìn)程中啟動一個Activity。這里我們稱為preload進(jìn)程。

<activity
    android:name=".main.PreloadActivity"
    android:process=":preload"
    android:screenOrientation="portrait"
    android:theme="@style/PreloadTheme">
    <intent-filter>
        <action android:name="android.intent.action.MAIN" />
        <category android:name="android.intent.category.LAUNCHER" />
    </intent-filter>
</activity>

PreloadActivity代碼如下。

public class PreloadActivity extends Activity {

    @Override
    @SuppressWarnings("all")
    protected void onCreate(@Nullable Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);

        if (!PreloadProcessHelper.getInstance(getApplication()).isAppFirstInstall()) {
            //不是第一次安裝啟動應(yīng)用
            startSplashActivity();
            releaseAndFinish();
            return;
        }

        //啟動加載dex分包任務(wù)
        new LoadDexTask().execute();
    }
  ...
}

我們在PreloadActivity中,我們先判斷是否第一次安裝啟動應(yīng)用,當(dāng)應(yīng)用不是第一次安裝啟動時,我們直接啟動閃屏頁,并且結(jié)束掉子進(jìn)程。

 /**
     * 啟動SplashActivity
     */
    private void startSplashActivity() {
        Intent intent = new Intent(this, SplashActivity.class);
        startActivity(intent);
        overridePendingTransition(0, 0);
    }
@SuppressWarnings("all")
class LoadDexTask extends PreloadAsyncTask {

    @Override
    protected Void doInBackground(Void... voids) {
        try {
            MultiDex.install(getApplication());
        } catch (Exception e) {
            e.printStackTrace();
        }
        return null;
    }

    @Override
    protected void onPostExecute(Void aVoid) {
        try {
            //將ipc數(shù)據(jù)序列化后寫入文件
            PreloadProcessHelper.writePreloadProcessTempFile(this);
        } catch (IOException e) {
            e.printStackTrace();
        }

        startSplashActivity();
        listenFinishEvent();
    }
}

我們可以看到,在分包結(jié)束之后,創(chuàng)建了一個新的文件,這是一個空文件。目的是用來做主進(jìn)程和子進(jìn)程IPC通訊用的。這里我們稱這個文件為A文件。我執(zhí)行了一個listenFinishEvent方法。這是一個用來監(jiān)聽剛剛所創(chuàng)建A文件是否被刪除。

 /**
 * 監(jiān)聽結(jié)束Activity以及此進(jìn)程事件
 */
private void listenFinishEvent() {
    try {
        while (true) {
            if (PreloadProcessHelper.getInstance(getApplication()).isExistPreloadProcessTempFile()) {
                try {
                    Thread.sleep(50);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            } else {
                releaseAndFinish();
                break;
            }
        }
    } catch (Exception e) {
        e.printStackTrace();
    }
}

如果監(jiān)聽到文件被刪除。直接結(jié)束掉PreloadActivity以及子進(jìn)程。
然后。我們的SplashActivity會在主進(jìn)程打開,我們在SplashActivity中的onResume中檢測是否存在A文件。如果存在文件,那么說明此次子進(jìn)程還在繼續(xù)運行,我們刪除A文件。

/**
 * 刪除 預(yù)加載進(jìn)程中的數(shù)據(jù)保存文件
 * 文件用來IPC通知preload進(jìn)程結(jié)束
 *
 * @throws IOException io
 */
public void deletePreloadProcessTempFile() throws IOException {
    File file = getPreloadProcessTempFile(mApplication);
    if (file.exists()) {
        file.delete();
    }
}

此時。子進(jìn)程就會監(jiān)聽到A文件被刪除。直接結(jié)束掉PreloadActivity以及子進(jìn)程。

可能有很多讀者就有疑問了。為什么在分包結(jié)束之后,不直接結(jié)束掉PreloadActivity以及子進(jìn)程?
主要是為了防止在一些低端設(shè)備上可能會出現(xiàn)短暫的黑屏。因為在跨進(jìn)程啟動SplashActivity的時候,系統(tǒng)需要做一些額外的工作。包括重新加載dex包以及SplashActivity的初始化工作等。所以我們在SplashActivityonResume回調(diào)中通知子進(jìn)程結(jié)束。注意:PreloadActivity必須取消window動畫。

注意點

  • 因為涉及到多進(jìn)程,所以會初始化兩次Application,需要在各個方法中進(jìn)行判斷。否則可能會造成你不想要的結(jié)果~
@Override
protected void attachBaseContext(Context base) {
    super.attachBaseContext(base);
    if (PreloadProcessHelper.getInstance(this).attachBaseContext()) {
        return;
    }
}

@Override
public void onCreate() {
    super.onCreate();
    if (PreloadProcessHelper.getInstance(this).onCreate()) {
        return;
    }
...
}

我這里對代碼實行了封裝。主要做的就是,判斷當(dāng)前進(jìn)程是否在主進(jìn)程,如果不在主進(jìn)程直接return。判斷是否在主進(jìn)程代碼如下:

public static String getCurrentProcessName(Context context) {
    int pid = Process.myPid();
    ActivityManager mActivityManager = (ActivityManager)context.getSystemService("activity");
    Iterator var3 = mActivityManager.getRunningAppProcesses().iterator();
    RunningAppProcessInfo appProcess;
    do {
        if(!var3.hasNext()) {
            return null;
        }

        appProcess = (RunningAppProcessInfo)var3.next();
    } while(appProcess.pid != pid);
    return appProcess.processName;
}
/**
 * 是否處于主線程環(huán)境
 *
 * @return
 */
private boolean isInMainProcess() {
    return ProcessUtils.getCurrentProcessName(mApplication).equals(mApplication.getPackageName());
}
  • 在較新的gradle task中,默認(rèn)只將四大組件以及相對應(yīng)的直接引用類放在主dex,注意,是直接引用類。
    如果你在PreloadActivity中還并行做了其他的操作,那么你要保證這些操作所引用到的所有的類要包含在主dex中。
    你需要手動將引用類的全路徑配置到響應(yīng)文件中。如果不手動聲明在主dex文件中的類,那么有可能造成NoClassDefFoundError 或者 ClassNotFoundException 錯誤。
    有兩種配置方式。
  1. multiDexKeepFile方式
android {
    buildTypes {
        release {
            multiDexKeepFile file 'multidex-config.txt'
            ...
        }
    }
}

在model同級目錄下創(chuàng)建 multidex-config.txt文件。格式如下:

com/example/MyClass.class
com/example/MyOtherClass.class
  1. multiDexKeepProguard方式
android {
    buildTypes {
        release {
            multiDexKeepProguard 'multidex-config.pro'
            ...
        }
    }
}

在model同級目錄下創(chuàng)建 multidex-config.pro文件。格式如下:

-keep class com.example.MyClass
-keep class com.example.MyClassToo

multiDexKeepProguard 文件使用與 Proguard 相同的格式,并且支持整個 Proguard 語法。

  • 你必須在主進(jìn)程也執(zhí)行一次MultiDex.install(mApplication);方法
    可能很多人會問了。我不是在子進(jìn)程執(zhí)行分包了嗎。什么在主進(jìn)程還需要執(zhí)行。注意。這是兩個進(jìn)程,MultiDex.install(mApplication);的主要任務(wù)是把所有的子dex通過dexopt進(jìn)行解壓,編譯后生成的odex文件保存在本地,這些步驟是非常耗時,主進(jìn)程只需要將生成后的odex文件加載到內(nèi)存中就可以。這個步驟不到10ms。是非常快了。就像子進(jìn)程已經(jīng)把所有的食材都處理加工好了,你只需要放下鍋就好了。好了。我餓了。

  • 繼續(xù)優(yōu)化
    其實。還可以繼續(xù)優(yōu)化。很多應(yīng)用在第一次打開需要初始化很多組件。比如,你可能需要從某個第三方服務(wù)商api接口中獲取數(shù)據(jù),然后保存在本地。或者,你可能某個組件初始化的優(yōu)先級很高。
    這時候,你可以把這些工作放到子進(jìn)程中來,并行運行。
    開啟多個子線程,一個執(zhí)行分包任務(wù),也就是MultiDex.install(mApplication);其他的線程執(zhí)行額外的任務(wù)。需要注意的是。你必須把這些額外的任務(wù)說應(yīng)用的類手動配置到主dex中。
    如果你在子進(jìn)程中要進(jìn)行持久化數(shù)據(jù)的保存,不能使用SharedPreference,因為SharedPreference內(nèi)存機(jī)制原因,無法實現(xiàn)同步。你可以將數(shù)據(jù)進(jìn)行序列化后寫入A文件,對!!就是那個在進(jìn)程自己創(chuàng)建的臨時文件,然后在主進(jìn)程中把數(shù)據(jù)取出來再進(jìn)行持久化保存。

  • 寫了一早上,點個贊唄~

?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務(wù)。

推薦閱讀更多精彩內(nèi)容

  • std::unique_ptr 特點如下: 大小和原生指針相同 獨占資源,所以不能有兩個指向相同資源的std::u...
    Axl_Rose閱讀 598評論 0 0
  • 在乎了 就會有心靈感應(yīng) 我始終覺得自己好多時候,有種無法抗拒的,自然而生的心靈感應(yīng),這些感應(yīng)總會給我?guī)眢@喜和解脫...
    明華老師閱讀 618評論 0 0
  • 適度的心情 適度的工作 適度的旅行 適度的嘗試 適度的戀愛 適度的親情 適度的盆友 適度的學(xué)習(xí) 適度的閱讀 適度的...
    有心相思閱讀 333評論 0 0
  • 你們以前買過基金嗎?你們是根據(jù)什么原則來挑選基金的呢?你們買的基金掙到錢了嗎? 對于大多數(shù)普通人來說,...
    弟子明陽閱讀 443評論 1 8