我們知道,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.dex、classes3.dex、classes4.dex等 DEX 檔案(這裡我們統稱為 Secondary DEX 檔案),這些檔案都需要靠我們自己進行 ODEX 優化,並載入到 ClassLoader 裡,才能正常使用其中的類。否則在訪問這些類的時候,就會丟擲ClassNotFound異常從而引起崩潰。
因此,Android 官方推出了 MultiDex 方案。只需要在 APP 程式執行最早的入口,也就是Application.attachBaseContext裡面直接調MultiDex.install,它會解開 APK 包,對第二個以後的 DEX 檔案做 ODEX 優化並載入。這樣,帶有多個 DEX 檔案的 APK 就可以順利執行下去了。
這個操作會在 APP 安裝或者更新後首次冷啟動的時候發生,正是由於這個過程耗時漫長,才導致了我們最開始提到的耗時黑屏問題。
原始實現了解了這個背景之後,我們再來看 MultiDex 的實現,邏輯就比較清晰了。
首先,APK 裡面的所有classes2.dex、classes3.dex、classes4.dex等 DEX 檔案都會被解壓出來。
然後,對每個 dex 進行 ZIP 壓縮。生成 classesN.zip 檔案。
接著,對每個 ZIP 檔案做 ODEX 優化,生成 classesN.zip.odex 檔案。
具體而言,我們可以看到 APP 的 code_cache 目錄下有這些檔案:
com.bytedance.app.boost_multidex-1.apk.classes2.dexcom.bytedance.app.boost_multidex-1.apk.classes2.zipcom.bytedance.app.boost_multidex-1.apk.classes3.dexcom.bytedance.app.boost_multidex-1.apk.classes3.zipcom.bytedance.app.boost_multidex-1.apk.classes4.dexcom.bytedance.app.boost_multidex-1.apk.classes4.zip
這一步是通過DexFile.loadDex方法實現的,只需要指定原始 ZIP 檔案和 ODEX 檔案的路徑,就能夠根據 ZIP 中的 DEX 生成相應的 ODEX 產物,這個方法會最終返回一個DexFile物件。
最後,APP 把這些DexFile物件都新增到PathClassLoader的pathList裡面,就可以讓 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 檔案,它其實就做了這麼幾件事:
接受一個byte[]引數,也就是原始 DEX 檔案的位元組碼。呼叫dvmRawDexFileOpenArray函式來處理byte[],生成RawDexFile物件由RawDexFile物件生成一個DexOrJar,通過addToDexFileTable新增到虛擬機器內部,這樣後續就可以正常使用它了返回這個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 的方案,主要有以下步驟:
從 APK 中解壓獲取原始 Secondary DEX 檔案的位元組碼通過 dlsym 獲取dvm_dalvik_system_DexFile陣列在陣列中查詢得到Dalvik_dalvik_system_DexFile_openDexFile_bytearray函式呼叫該函式,逐個傳入之前從 APK 獲取的 DEX 位元組碼,完成 DEX 載入,得到合法的DexFile物件把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 的首次啟動時間由此可以大幅減少。
我們距離最終的極致完整解決方案還有一小段路,然而,正是這一小段路,才最為艱險嚴峻。更大的挑戰還在後面,我們將在下一篇文章為大家細細分解,同時也會詳細展示最終方案帶來的收益情況。大家也可以先思考一下這裡還有哪些問題沒有考慮到。
抖音/TikTok Android 基礎技術團隊是一個追求極致的深度技術團隊,目前上海、北京、深圳、杭州都有大量人才需要,歡迎各位同學前來與我們共同建設億級使用者全球化 APP!
敬請期待,抖音BoostMultiDex優化實踐:Android低版本上APP首次啟動時間減少80%(二)。