抖音BoostMultiDex優化實踐:Android低版本上APP啓動時間減少80%(一)

我們知道,Android 低版本(4.X 及以下,SDK < 21)的設備,採用的 Java 運行環境是 Dalvik 虛擬機。它相比於高版本,最大的問題就是在安裝或者升級更新之後,首次冷啓動的耗時漫長。這常常需要花費幾十秒甚至幾分鐘,用戶不得不面對一片黑屏,熬過這段時間才能正常使用 APP。

這是非常影響用戶的使用體驗的。我們從線上數據也可以發現,Android 4.X 及以下機型,其新增用戶也佔了一定的比例,但留存用戶數相比新增則要少非常多。尤其在海外,像東南亞以及拉美等地區,還存有着很大量的低端機。4.X 以下低版本用戶雖然比較少,但對於抖音及 TikTok 這樣有着億級規模的用戶的 APP,即使佔比 10%,數目也有上千萬。因此如果想要打通下沉市場,這部分用戶的使用和升級體驗是絕對無法忽視的。

這個問題的根本原因就在於,安裝或者升級後首次 MultiDex 花費的時間過於漫長。

爲了解決這個問題,我們挖掘了 Dalvik 虛擬機的底層系統機制,對 DEX 相關處理邏輯進行了重新設計,最終推出了 BoostMultiDex 方案,它能夠減少 80% 以上的黑屏等待時間,挽救低版本 Android 用戶的升級安裝體驗。

我們先來簡單看一個安裝後首次冷啓動加載 DEX 時間的對比數據:

可以看到原始 MultiDex 方案竟然花了半分鐘以上才能完成 DEX 加載,而 BoostMultiDex 方案的時間僅需要 5 秒以內。優化效果極爲顯著!
接下來,我們就來詳細講解整個 BoostMultiDex 方案的研發過程與解決思路。

起因

我們先來看下導致這個問題的根本原因。這裏面是有多個原因共同引起的。

首先需要清楚的是,在 Java 裏面想要訪問一個類,必然是需要通過 ClassLoader 來加載它們才能訪問到。在 Android 上,APP 裏面的類都是由PathClassLoader負責加載的。而類都是依附於 DEX 文件而存在的,只有加載了相應的 DEX,才能對其中的類進行使用。

Android 早期對於 DEX 的指令格式設計並不完善,單個 DEX 文件中引用的 Java 方法總數不能超過 65536 個。

對於現在的 APP 而言,只要功能邏輯多一些,很容易就會觸達這個界限。

這樣,如果一個 APP 的 Java 代碼的方法數超過了 65536 個,這個 APP 的代碼就無法被一個 DEX 文件完全裝下,那麼,我們在編譯期間就不得不生成多個 DEX 文件。我們解開抖音的 APK 就可以看到,裏面確實包含了很多個 DEX 文件:

8035972  00-00-1980 00:00   classes.dex
8476188  00-00-1980 00:00   classes2.dex
7882916  00-00-1980 00:00   classes3.dex
9041240  00-00-1980 00:00   classes4.dex
8646596  00-00-1980 00:00   classes5.dex
8644640  00-00-1980 00:00   classes6.dex
5888368  00-00-1980 00:00   classes7.dex

Android 4.4 及以下采用的是 Dalvik 虛擬機,在通常情況下,Dalvik 虛擬機只能執行做過 OPT 優化的 DEX 文件,也就是我們常說的 ODEX 文件。

一個 APK 在安裝的時候,其中的classes.dex會自動做 ODEX 優化,並在啓動的時候由系統默認直接加載到 APP 的PathClassLoader裏面,因此classes.dex中的類肯定能直接訪問,不需要我們操心。

除它之外的 DEX 文件,也就是classes2.dexclasses3.dexclasses4.dex等 DEX 文件(這裏我們統稱爲 Secondary DEX 文件),這些文件都需要靠我們自己進行 ODEX 優化,並加載到 ClassLoader 裏,才能正常使用其中的類。否則在訪問這些類的時候,就會拋出ClassNotFound異常從而引起崩潰。

因此,Android 官方推出了 MultiDex 方案。只需要在 APP 程序執行最早的入口,也就是Application.attachBaseContext裏面直接調MultiDex.install,它會解開 APK 包,對第二個以後的 DEX 文件做 ODEX 優化並加載。這樣,帶有多個 DEX 文件的 APK 就可以順利執行下去了。

這個操作會在 APP 安裝或者更新後首次冷啓動的時候發生,正是由於這個過程耗時漫長,才導致了我們最開始提到的耗時黑屏問題。

原始實現

瞭解了這個背景之後,我們再來看 MultiDex 的實現,邏輯就比較清晰了。

首先,APK 裏面的所有classes2.dexclasses3.dexclasses4.dex等 DEX 文件都會被解壓出來。

然後,對每個 dex 進行 ZIP 壓縮。生成 classesN.zip 文件。

接着,對每個 ZIP 文件做 ODEX 優化,生成 classesN.zip.odex 文件。

具體而言,我們可以看到 APP 的 code_cache 目錄下有這些文件:

com.bytedance.app.boost_multidex-1.apk.classes2.dex
com.bytedance.app.boost_multidex-1.apk.classes2.zip
com.bytedance.app.boost_multidex-1.apk.classes3.dex
com.bytedance.app.boost_multidex-1.apk.classes3.zip
com.bytedance.app.boost_multidex-1.apk.classes4.dex
com.bytedance.app.boost_multidex-1.apk.classes4.zip

這一步是通過DexFile.loadDex方法實現的,只需要指定原始 ZIP 文件和 ODEX 文件的路徑,就能夠根據 ZIP 中的 DEX 生成相應的 ODEX 產物,這個方法會最終返回一個DexFile對象。

最後,APP 把這些DexFile對象都添加到PathClassLoaderpathList裏面,就可以讓 APP 在運行期間,通過ClassLoader加載使用到這些 DEX 中的類。

在這整個過程中,生成 ZIP 和 ODEX 文件的過程都是比較耗時的,如果一個 APP 中有很多個 Secondary DEX 文件,就會加劇這一問題。尤其是生成 ODEX 的過程,Dalvik 虛擬機會把 DEX 格式的文件進行遍歷掃描和優化重寫處理,從而轉換爲 ODEX 文件,這就是其中最大的耗時瓶頸。

普遍採用的優化方式

目前業界已經有了一些對 MultiDex 進行優化的方法,我們先來看下大家通常是怎麼優化這一過程的。

異步化加載

把啓動階段要使用的類儘可能多地打包到主 Dex 裏面,儘量不依賴 Secondary DEX 來跑業務代碼。然後異步調用MultiDex.install,而在後續某個時間點需要用到 Secondary DEX 的時候,如果 MultiDex 還沒執行完,就停下來同步等待它完成再繼續執行後續的代碼。

這樣確實可以在 install 的同時往下執行部分代碼,而不至於被完全堵住。然而要做到這點,必須首先梳理好啓動邏輯的代碼,明確知道哪些是可以並行執行的。另外,由於主 Dex 能放的代碼本身就比較有限,業務在啓動階段如果有太多依賴,就不能完全放入主 Dex 裏面,因此就需要合理地剝離依賴。

因此現實情況下這個方案效果比較有限,如果啓動階段牽扯了太多業務邏輯,很可能並行執行不了太多代碼,就很快又被 install 堵住了。

模塊懶加載

這個方案最早見於美團的文章,可以說是前一個方案的升級版。

它也是做異步 DEX 加載,不過不同之處在於,在編譯期間就需要對 DEX 按模塊進行拆分。

一般是把一級界面的 Activity、Service、Receiver、Provider 涉及到的代碼都放到第一個 DEX 中,而把二級、三級頁面的 Activity 以及非高頻界面的代碼放到了 Secondary DEX 中。

當後面需要執行某個模塊的時候,先判斷這個模塊的 Class 是否已經加載完成,如果沒有完成,就等待 install 完成後再繼續執行。

可見,這個方案對業務的改造程度相當巨大,而且已經有了一些插件化框架的雛形。另外,想要做到能對模塊的 Class 的加載情況進行判斷,還得通過反射 ActivityThread 注入自己的 Instrumentation,在執行 Activity 之前插入自己的判斷邏輯。這也會相應地引入機型兼容性問題。

多線程加載

原生的 MultiDex 是順序依次對每個 DEX 文件做 ODEX 優化的。而多線程的思路是,把每個 DEX 分別用各自線程做 OPT。

這麼乍看起來,似乎是能夠並行地做 ODEX 來起到優化效果。然而我們項目中一共有 6 個 Secondary DEX 文件,實測發現,這種方式幾乎沒有優化效果。原因可能是 ODEX 本身其實是重度 I/O 類型的操作,對於併發而言,多個線程同時進行 I/O 操作並不能帶來明顯收益,並且多線程切換本身也會帶來一定損耗。

後臺進程加載

這個方案主要是防止主進程做 ODEX 太久導致 ANR。當點擊 APP 的時候,先單獨啓動了一個非主進程來先做 ODEX,等非主進程做完 ODEX 後再叫起主進程,這樣主進程起來直接取得做好的 ODEX 就可以直接執行。

不過,這只是規避了主進程 ANR 的問題,第一次啓動的整體等待時間並沒有減少。

一個更徹底的優化方案

上述幾個方案,在各個層面都嘗試做了優化,然而仔細分析便會發現,它們都沒有觸及這個問題中根本,也就是就MultiDex.install操作本身。

MultiDex.install生成 ODEX 文件的過程,調用的方法是DexFile.loadDex,它會啓動一個 dexopt 進程對輸入的 DEX 文件進行 ODEX 轉化。

那麼,這個 ODEX 優化的時間是否可以避免呢?

我們的 BoostMultiDex 方案,正是從這一點入手,從本質上優化 install 的耗時。

我們的做法是,在第一次啓動的時候,直接加載沒有經過 OPT 優化的原始 DEX,先使得 APP 能夠正常啓動。然後在後臺啓動一個單獨進程,慢慢地做完 DEX 的 OPT 工作,儘可能避免影響到前臺 APP 的正常使用。

突破口

這裏的難點,自然是:如何做到可以直接加載原始 DEX,避免 ODEX 優化帶來的耗時阻塞。

如果要避免 ODEX 優化,又想要 APP 能夠正常運行,就意味着 Dalvik 虛擬機需要直接執行沒有做過 OPT 的、原始的 DEX 文件。

虛擬機是否支持直接執行 DEX 文件呢?

畢竟 Dalvik 虛擬機是可以直接執行原始 DEX 字節碼的,ODEX 相比 DEX 只是做了一些額外的分析優化。因此即使 DEX 不通過優化,理論上應該是可以正常執行的。

功夫不負有心人,經過我們的一番挖掘,在系統的 dalvik 源碼裏面果然找到了這一隱藏入口:

/*
 * private static int openDexFile(byte[] fileContents) throws IOException
 *
 * Open a DEX file represented in a byte[], returning a pointer to our
 * internal data structure.
 *
 * The system will only perform "essential" optimizations on the given file.
 *
 */
static void Dalvik_dalvik_system_DexFile_openDexFile_bytearray(const u4* args,
    JValue* pResult)
{
    ArrayObject* fileContentsObj = (ArrayObject*) args[0];
    u4 length;
    u1* pBytes;
    RawDexFile* pRawDexFile;
    DexOrJar* pDexOrJar = NULL;

    if (fileContentsObj == NULL) {
        dvmThrowNullPointerException("fileContents == null");
        RETURN_VOID();
    }

    /* TODO: Avoid making a copy of the array. (note array *is* modified) */
    length = fileContentsObj->length;
    pBytes = (u1*) malloc(length);

    if (pBytes == NULL) {
        dvmThrowRuntimeException("unable to allocate DEX memory");
        RETURN_VOID();
    }

    memcpy(pBytes, fileContentsObj->contents, length);

    if (dvmRawDexFileOpenArray(pBytes, length, &pRawDexFile) != 0) {
        ALOGV("Unable to open in-memory DEX file");
        free(pBytes);
        dvmThrowRuntimeException("unable to open in-memory DEX file");
        RETURN_VOID();
    }

    ALOGV("Opening in-memory DEX");
    pDexOrJar = (DexOrJar*) malloc(sizeof(DexOrJar));
    pDexOrJar->isDex = true;
    pDexOrJar->pRawDexFile = pRawDexFile;
    pDexOrJar->pDexMemory = pBytes;
    pDexOrJar->fileName = strdup("<memory>"); // Needs to be free()able.
    addToDexFileTable(pDexOrJar);

    RETURN_PTR(pDexOrJar);
}

這個方法可以做到對原始 DEX 文件做加載,而不依賴 ODEX 文件,它其實就做了這麼幾件事:

  1. 接受一個byte[]參數,也就是原始 DEX 文件的字節碼;

  2. 調用dvmRawDexFileOpenArray函數來處理byte[],生成RawDexFile對象;

  3. RawDexFile對象生成一個DexOrJar,通過addToDexFileTable添加到虛擬機內部,這樣後續就可以正常使用它了;

  4. 返回這個DexOrJar的地址給上層,讓上層用它作爲 cookie 來構造一個合法的DexFile對象;

這樣,上層在取得所有 Seconary DEX 的DexFile對象後,調用 makeDexElements 插入到 ClassLoader 裏面,就完成了 install 操作。如此一來,我們就能完美地避過 ODEX 優化,讓 APP 正常執行下去了。

尋找入口

看起來似乎很順利,然而在我們卻遇到了一個意外狀況。

我們從Dalvik_dalvik_system_DexFile_openDexFile_bytearray這個函數的名字可以明顯看出,這是一個 JNI 方法,從 4.0 到 4.3 版本都能找到它的 Java 原型:

/*
 * Open a DEX file based on a {@code byte[]}. The value returned
 * is a magic VM cookie. On failure, a RuntimeException is thrown.
 */
native private static int openDexFile(byte[] fileContents);

然而我們在 4.4 版本上,Java 層它並沒有對應的 native 方法。這樣我們便無法直接在上層調用了。

當然,我們很容易想到,可以用 dlsym 來直接搜尋這個函數的符號來調用。但是可惜的是,Dalvik_dalvik_system_DexFile_openDexFile_bytearray這個方法是static的,因此它並沒有被導出。我們實際去解析libdvm.so的時候,也確實沒有找到Dalvik_dalvik_system_DexFile_openDexFile_bytearray這個符號。

不過,由於它是 JNI 函數,也是通過正常方式註冊到虛擬機裏面的。因此,我們可以找到它對應的函數註冊表:

const DalvikNativeMethod dvm_dalvik_system_DexFile[] = {
    { "openDexFileNative",  "(Ljava/lang/String;Ljava/lang/String;I)I",
        Dalvik_dalvik_system_DexFile_openDexFileNative },
    { "openDexFile",        "([B)I",
        Dalvik_dalvik_system_DexFile_openDexFile_bytearray },
    { "closeDexFile",       "(I)V",
        Dalvik_dalvik_system_DexFile_closeDexFile },
    { "defineClassNative",  "(Ljava/lang/String;Ljava/lang/ClassLoader;I)Ljava/lang/Class;",
        Dalvik_dalvik_system_DexFile_defineClassNative },
    { "getClassNameList",   "(I)[Ljava/lang/String;",
        Dalvik_dalvik_system_DexFile_getClassNameList },
    { "isDexOptNeeded",     "(Ljava/lang/String;)Z",
        Dalvik_dalvik_system_DexFile_isDexOptNeeded },
    { NULL, NULL, NULL },
};

dvm_dalvik_system_DexFile這個數組,需要被虛擬機在運行時動態地註冊進去,因此,這個符號是一定會被導出的。

這麼一來,我們也就可以通過 dlsym 取得這個數組,按照逐個元素字符串匹配的方式來搜尋openDexFile對應的Dalvik_dalvik_system_DexFile_openDexFile_bytearray方法了。

具體代碼實現如下:

const char *name = "openDexFile";
JNINativeMethod* func = (JNINativeMethod*) dlsym(handler, "dvm_dalvik_system_DexFile");;
size_t len_name = strlen(name);
while (func->name != nullptr) {
    if ((strncmp(name, func->name, len_name) == 0)
        && (strncmp("([B)I", func->signature, len_name) == 0)) {
        return reinterpret_cast<func_openDexFileBytes>(func->fnPtr);
    }
    func++;
}

捋清步驟

小結一下,繞過 ODEX 直接加載 DEX 的方案,主要有以下步驟:

  1. 從 APK 中解壓獲取原始 Secondary DEX 文件的字節碼;

  2. 通過 dlsym 獲取dvm_dalvik_system_DexFile數組;

  3. 在數組中查詢得到Dalvik_dalvik_system_DexFile_openDexFile_bytearray函數;

  4. 調用該函數,逐個傳入之前從 APK 獲取的 DEX 字節碼,完成 DEX 加載,得到合法的DexFile對象;

  5. DexFile對象都添加到 APP 的PathClassLoader的 pathList 裏;

完成了上述幾步操作,我們就可以正常訪問到 Secondary DEX 裏面的類了。

getDex 問題

然而,正當我們順利注入原始 DEX 往下執行的時候,卻在 4.4 的機型上馬上遇到了一個必現的崩潰:

JNI WARNING: JNI function NewGlobalRef called with exception pending
             in Ljava/lang/Class;.getDex:()Lcom/android/dex/Dex; (NewGlobalRef)
Pending exception is:
java.lang.IndexOutOfBoundsException: index=0, limit=0
 at java.nio.Buffer.checkIndex(Buffer.java:156)
 at java.nio.DirectByteBuffer.get(DirectByteBuffer.java:157)
 at com.android.dex.Dex.create(Dex.java:129)
 at java.lang.Class.getDex(Native Method)
 at libcore.reflect.AnnotationAccess.getSignature(AnnotationAccess.java:447)
 at java.lang.Class.getGenericSuperclass(Class.java:824)
 at com.google.gson.reflect.TypeToken.getSuperclassTypeParameter(TypeToken.java:82)
 at com.google.gson.reflect.TypeToken.<init>(TypeToken.java:62)
 at com.google.gson.Gson$1.<init>(Gson.java:112)
 at com.google.gson.Gson.<clinit>(Gson.java:112)
... ...

可以看到,Gson 裏面使用到了Class.getGenericSuperclass方法,而它最終調用了Class.getDex,它是一個 native 方法,對應實現如下:

JNIEXPORT jobject JNICALL Java_java_lang_Class_getDex(JNIEnv* env, jclass javaClass) {
    Thread* self = dvmThreadSelf();
    ClassObject* c = (ClassObject*) dvmDecodeIndirectRef(self, javaClass);

    DvmDex* dvm_dex = c->pDvmDex;
    if (dvm_dex == NULL) {
        return NULL;
    }
    // Already cached?
    if (dvm_dex->dex_object != NULL) {
        return dvm_dex->dex_object;
    }
    jobject byte_buffer = env->NewDirectByteBuffer(dvm_dex->memMap.addr, dvm_dex->memMap.length);
    if (byte_buffer == NULL) {
        return NULL;
    }

    jclass com_android_dex_Dex = env->FindClass("com/android/dex/Dex");
    if (com_android_dex_Dex == NULL) {
        return NULL;
    }

    jmethodID com_android_dex_Dex_create =
            env->GetStaticMethodID(com_android_dex_Dex,
                                   "create", "(Ljava/nio/ByteBuffer;)Lcom/android/dex/Dex;");
    if (com_android_dex_Dex_create == NULL) {
        return NULL;
    }

    jvalue args[1];
    args[0].l = byte_buffer;
    jobject local_ref = env->CallStaticObjectMethodA(com_android_dex_Dex,
                                                     com_android_dex_Dex_create,
                                                     args);
    if (local_ref == NULL) {
        return NULL;
    }

    // Check another thread didn't cache an object, if we've won install the object.
    ScopedPthreadMutexLock lock(&dvm_dex->modLock);

    if (dvm_dex->dex_object == NULL) {
        dvm_dex->dex_object = env->NewGlobalRef(local_ref);
    }
    return dvm_dex->dex_object;
}

結合堆棧和代碼來看,崩潰的點是在 JNI 裏面執行com.android.dex.Dex.create的時候:

jobject local_ref = env->CallStaticObjectMethodA(com_android_dex_Dex,
                                                 com_android_dex_Dex_create,
                                                 args);

由於是 JNI 方法,這個調用發生異常後,如果沒有 check,在後續執行到env->NewGlobalRef調用的時候,會檢查到前面發生了異常,從而拋出。

com.android.dex.Dex.create之所以會執行失敗,主要原因是入參有問題,這裏的參數是dvm_dex->memMap取到的一塊 map 內存。dvm_dex 是從這個 Class 裏面取得的。虛擬機代碼裏面,每個 Class 對應是結構是ClassObject中,其中有這個字段:

struct ClassObject : Object {
... ...
    /* DexFile from which we came; needed to resolve constant pool entries */
    /* (will be NULL for VM-generated, e.g. arrays and primitive classes) */
    DvmDex*         pDvmDex;
... ...

這裏的pDvmDex是在這裏加載類的過程中賦值的:

static void Dalvik_dalvik_system_DexFile_defineClassNative(const u4* args,
    JValue* pResult)
{
... ...

    if (pDexOrJar->isDex)
        pDvmDex = dvmGetRawDexFileDex(pDexOrJar->pRawDexFile);
    else
        pDvmDex = dvmGetJarFileDex(pDexOrJar->pJarFile);

... ...

pDvmDex是從dvmGetRawDexFileDex方法裏面取得的,而這裏的參數pDexOrJar->pRawDexFile正是我們前面openDexFile_bytearray裏面創建的,pDexOrJar是之前返回給上層的 cookie。

再根據dvmGetRawDexFileDex

INLINE DvmDex* dvmGetRawDexFileDex(RawDexFile* pRawDexFile) {
    return pRawDexFile->pDvmDex;
}

可以最終推得,dvm_dex->memMap對應的正是openDexFile_bytearray時拿到的pDexOrJar->pRawDexFile->pDvmDex->memMap。我們在當初加載 DEX 字節數組的時候,是否遺漏了對memMap進行賦值呢?

我們通過分析代碼,發現的確如此,memMap這個字段只在 ODEX 的情況下才會賦值:

/*
 * Given an open optimized DEX file, map it into read-only shared memory and
 * parse the contents.
 *
 * Returns nonzero on error.
 */
int dvmDexFileOpenFromFd(int fd, DvmDex** ppDvmDex)
{
... ...

    // 構造memMap
    if (sysMapFileInShmemWritableReadOnly(fd, &memMap) != 0) {
        ALOGE("Unable to map file");
        goto bail;
    }

... ...

    // 賦值memMap
    /* tuck this into the DexFile so it gets released later */
    sysCopyMap(&pDvmDex->memMap, &memMap);

... ...
}

而只加載 DEX 字節數組的情況下,並不會走這個方法,因此也就沒法對 memMap 進行賦值了。看來,Android 官方從一開始對openDexFile_bytearray就沒支持好,系統代碼裏面也沒有任何使用的地方,所以當我們強制使用這個方法的時候就會暴露出這個問題。

雖然這個是官方的坑,但我們既然需要使用,就得想辦法填上。

再次分析Java_java_lang_Class_getDex方法,我們注意到了這段:

if (dvm_dex->dex_object != NULL) {
    return dvm_dex->dex_object;
}

dvm_dex->dex_object如果非空,就會直接返回,不會再往下執行到取 memMap 的地方,因此就不會引發異常。這樣,解決思路就很清晰了,我們在加載完 DEX 數組之後,立即自己生成一個dex_object對象,並注入pDvmDex裏面。

詳細代碼如下:

jclass clazz = env->FindClass("com/android/dex/Dex");
jobject dex_object = env->NewGlobalRef(
        env->NewObject(clazz),
        env->GetMethodID(clazz, "<init>", "([B)V"),
        bytes));
dexOrJar->pRawDexFile->pDvmDex->dex_object = dex_object;

這樣設置進去之後,果然不再出現 getDex 異常了。

小結

至此,無需等待 ODEX 優化的直接 DEX 加載方案已經完全打通,APP 的首次啓動時間由此可以大幅減少。

我們距離最終的極致完整解決方案還有一小段路,然而,正是這一小段路,才最爲艱險嚴峻。更大的挑戰還在後面,我們將在下一篇文章爲大家細細分解,同時也會詳細展示最終方案帶來的收益情況。大家也可以先思考一下這裏還有哪些問題沒有考慮到。

本文對你有幫助嗎?留言、轉發、點好看是最大的支持,謝謝!

- END -


公衆號後臺回覆成長『成長』,將會得到我準備的學習資料。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章