android 编译之后黑屏_抖音BoostMultiDex:Android低版本上首次启动时间减少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.dexAndroid 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(""); // Needs to be free()able. addToDexFileTable(pDexOrJar); RETURN_PTR(pDexOrJar);}這個方法可以做到對原始 DEX 文件做加載,而不依賴 ODEX 文件,它其實就做了這么幾件事:
這樣,上層在取得所有 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->fnPtr); } func++; }捋清步驟
小結一下,繞過 ODEX 直接加載 DEX 的方案,主要有以下步驟:
完成了上述幾步操作,我們就可以正常訪問到 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.(TypeToken.java:62) at com.google.gson.Gson$1.(Gson.java:112) at com.google.gson.Gson.(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, "", "([B)V"), bytes));dexOrJar->pRawDexFile->pDvmDex->dex_object = dex_object;這樣設置進去之后,果然不再出現 getDex 異常了。
小結
至此,無需等待 ODEX 優化的直接 DEX 加載方案已經完全打通,APP 的首次啟動時間由此可以大幅減少。
我們距離最終的極致完整解決方案還有一小段路,然而,正是這一小段路,才最為艱險嚴峻。更大的挑戰還在后面,我們將在下一篇文章為大家細細分解,同時也會詳細展示最終方案帶來的收益情況。大家也可以先思考一下這里還有哪些問題沒有考慮到。
抖音/TikTok Android 基礎技術團隊是一個追求極致的深度技術團隊,目前上海、北京、深圳、杭州都有大量人才需要,歡迎各位同學前來與我們共同建設億級用戶全球化 APP!
可以點擊閱讀原文,進入字節跳動招聘官網查詢抖音 Android 相關職位「鏈接」,也可以聯系 xiaolin.gan@bytedance.com 咨詢相關信息或者直接發送簡歷內推!
敬請期待,抖音BoostMultiDex優化實踐:Android低版本上APP首次啟動時間減少80%(二)。
歡迎關注字節跳動技術團隊
總結
以上是生活随笔為你收集整理的android 编译之后黑屏_抖音BoostMultiDex:Android低版本上首次启动时间减少80%(一)...的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: 多表操作查询 一对一
- 下一篇: java类什么时候卸载_java –