咱們知道,Android 低版本(4.X 及如下,SDK < 21)的設備,採用的 Java 運行環境是 Dalvik 虛擬機。它相比於高版本,最大的問題就是在安裝或者升級更新以後,首次冷啓動的耗時漫長。這經常須要花費幾十秒甚至幾分鐘,用戶不得不面對一片黑屏,熬過這段時間才能正常使用 APP。java
這是很是影響用戶的使用體驗的。咱們從線上數據也能夠發現,Android 4.X 及如下機型,其新增用戶也佔了必定的比例,但留存用戶數相比新增則要少很是多。尤爲在海外,像東南亞以及拉美等地區,還存有着很大量的低端機。4.X 如下低版本用戶雖然比較少,但對於抖音及 TikTok 這樣有着億級規模的用戶的 APP,即便佔比 10%,數目也有上千萬。所以若是想要打通下沉市場,這部分用戶的使用和升級體驗是絕對沒法忽視的。android
這個問題的根本緣由就在於,安裝或者升級後首次 MultiDex 花費的時間過於漫長。爲了解決這個問題,咱們挖掘了 Dalvik 虛擬機的底層系統機制,對 DEX 相關處理邏輯進行了從新設計,最終推出了 BoostMultiDex 方案,它可以減小 80%以上的黑屏等待時間,挽救低版本 Android 用戶的升級安裝體驗。c++
咱們先來簡單看一個安裝後首次冷啓動加載 DEX 時間的對比數據:es6
Android 版本 | 廠商 | 機型 | 原始 MultiDex 耗時(s) | BoostMultiDex 耗時(s) |
---|---|---|---|---|
4.4.2 | LG | LGMS323 | 33.545 | 5.014 |
4.3.0 | Samsung | SGH-T999 | 30.331 | 3.791 |
4.2.1 | HUAWEI | G610-U00 | 36.465 | 4.981 |
4.1.2 | Samsung | I9100 | 30.962 | 5.345 |
能夠看到原始 MultiDex 方案居然花了半分鐘以上才能完成 DEX 加載,而 BoostMultiDex 方案的時間僅須要 5 秒之內。優化效果極爲顯著!數組
接下來,咱們就來詳細講解整個 BoostMultiDex 方案的研發過程與解決思路。bash
咱們先來看下致使這個問題的根本緣由。這裏面是有多個緣由共同引發的。cookie
首先須要清楚的是,在 Java 裏面想要訪問一個類,必然是須要經過 ClassLoader 來加載它們才能訪問到。在 Android 上,APP 裏面的類都是由PathClassLoader
負責加載的。而類都是依附於 DEX 文件而存在的,只有加載了相應的 DEX,才能對其中的類進行使用。多線程
Android 早期對於 DEX 的指令格式設計並不完善,單個 DEX 文件中引用的 Java 方法總數不能超過 65536 個。併發
對於如今的 APP 而言,只要功能邏輯多一些,很容易就會觸達這個界限。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.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
對象都添加到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 的方案,主要有如下步驟:
dvm_dalvik_system_DexFile
數組Dalvik_dalvik_system_DexFile_openDexFile_bytearray
函數DexFile
對象DexFile
對象都添加到 APP 的PathClassLoader
的 pathList 裏完成了上述幾步操做,咱們就能夠正常訪問到 Secondary DEX 裏面的類了
然而,正當咱們順利注入原始 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!
能夠點擊閱讀原文,進入 字節跳動招聘官網查詢抖音 Android 相關職位,也能夠聯繫 xiaolin.gan@bytedance.com 諮詢相關信息或者直接發送簡歷內推!
敬請期待,抖音BoostMultiDex優化實踐:Android低版本上APP首次啓動時間減小80%(二)。
歡迎關注字節跳動技術團隊