Android低版本上APP首次啟動時間減少80%(二)

塗程發表於2020-10-10

作者:位元組跳動技術團隊

抖音BoostMultiDex優化實踐:Android低版本上APP首次啟動時間減少80%(二)

抖音自研的 BoostMultiDex 方案,可以大幅改善 Android 低版本(4.4 及其以下)手機更新或安裝後首次冷啟動時間。並且,不同於目前業界所有優化方案,我們是從 Android Dalvik 虛擬機器底層機制入手,從根本上解決了安裝後首次執行 MultiDex 耗時過長問題。

我們上一篇文章中已經介紹了 BoostMultiDex 的核心優化思路,即如何避免 ODEX,直接載入原始 DEX 完成啟動。然而用這個方法載入 DEX 檔案,相比於 ODEX 優化後的方式,其 Java 程式碼執行效能上還是有所損失的。我們也可以從前面方法的註釋裡面看出,虛擬機器對於直接載入原始 DEX 的情況只是做了些基本優化:

 The system will only perform "essential" optimizations on the given file.

所以,雖然第一次啟動我們是載入了原始 DEX 來執行的,但從長遠的角度考慮,後續的啟動,還是應該儘量採用 ODEX 的方式來執行。因此,我們還需要在第一次啟動完成後,在後臺適當的時候做好 ODEX 優化。

一開始我們是做法也比較簡單,在順利載入 DEX 位元組陣列,完成啟動之後,在後臺開闢單獨的執行緒執行DexFile.loadDex就可以了。這樣當後臺做完 ODEX 後,APP 第二次啟動時,就可以直接載入之前做好的 ODEX,得到較好的執行效能。這種做法線上下測試的時候也很正常,然而在上線之後,我們遇到了這樣一個問題……

SIGSTKFLT 問題

線上報上來一個 Native Crash,它的堆疊如下所示:

Signal 16(SIGSTKFLT), Code -6(SI_TKILL)
#00 pc 00016db4 /system/lib/libc.so (write+12) [armeabi-v7a]
#01 pc 000884a5 /system/lib/libdvm.so (sysWriteFully(int, void const*, unsigned int, char const*)+28) [armeabi-v7a]
#02 pc 00088587 /system/lib/libdvm.so (sysCopyFileToFile(int, int, unsigned int)+114) [armeabi-v7a]
#03 pc 00050d41 /system/lib/libdvm.so (dvmRawDexFileOpen(char const*, char const*, RawDexFile**, bool)+392) [armeabi-v7a]
#04 pc 00064a41 /system/lib/libdvm.so [armeabi-v7a]
#05 pc 000276e0 /system/lib/libdvm.so [armeabi-v7a]
#06 pc 0002b5c4 /system/lib/libdvm.so (dvmInterpret(Thread*, Method const*, JValue*)+184) [armeabi-v7a]
#07 pc 0005fc79 /system/lib/libdvm.so (dvmCallMethodV(Thread*, Method const*, Object*, bool, JValue*, std::__va_list)+272) [armeabi-v7a]
#08 pc 0005fca3 /system/lib/libdvm.so (dvmCallMethod(Thread*, Method const*, Object*, JValue*, ...)+20) [armeabi-v7a]
#09 pc 0005481f /system/lib/libdvm.so [armeabi-v7a]
#10 pc 0000e3e8 /system/lib/libc.so (__thread_entry+72) [armeabi-v7a]
#11 pc 0000dad4 /system/lib/libc.so (pthread_create+160) [armeabi-v7a]

APP 收到 SIGSTKFLT 訊號崩潰了,同時還輸出了這樣的日誌:

06-25 15:10:53.821 7449 7450 E dalvikvm: threadid=2: stuck on threadid=135, giving up
06-25 15:10:53.821 7449 7450 D dalvikvm: threadid=2: sending two SIGSTKFLTs to threadid=135 (tid=8021) to cause debuggerd dump

SIGSTKFLT 是 Dalvik 虛擬機器特有的一個訊號。當虛擬機器發生了 ANR 或者需要做 GC 的時候,就需要掛起所有 RUNNING 狀態的執行緒,如果此時 Dalvik 虛擬機器等待了足夠長時間,執行緒仍舊無法被掛起,就會呼叫dvmNukeThread函式傳送 SIGSTKFLT 訊號給相應執行緒,從而殺死 APP。

具體程式碼如下:

static void waitForThreadSuspend(Thread* self, Thread* thread)
{
    constint kMaxRetries = 10;
... ...

    while (thread->status == THREAD_RUNNING) {
... ...
            if (retryCount++ == kMaxRetries) {
                ALOGE("Fatal spin-on-suspend, dumping threads");
                dvmDumpAllThreads(false);

                /* log this after -- long traces will scroll off log */
=>              ALOGE("threadid=%d: stuck on threadid=%d, giving up",
                    self->threadId, thread->threadId);

                /* try to get a debuggerd dump from the spinning thread */
=>              dvmNukeThread(thread);
                /* abort the VM */
                dvmAbort();
... ...
}

而從堆疊我們看出,殺死程式的時候,我們正呼叫DexFile.loadDex,這個方法最後會呼叫到dvmRawDexFileOpen裡面,執行 write 操作。而這個 write 涉及 I/O 操作,是比較耗時的。所以,當執行緒在做 dexopt,長時間無法響應虛擬機器的掛起請求時,就會觸發這個問題。

一般來說,虛擬機器在執行 Java 程式碼的時候,都會是 RUNNING 狀態。而只要呼叫了 JNI 方法,在執行到 C/C++ 程式碼的時候,就會切換為 NATIVE 狀態。而虛擬機器只會在 RUNNING 狀態下會掛起執行緒,如果是在 NATIVE 狀態下,虛擬機器是不會要求執行緒必須掛起的。

不過,這裡有一個特殊之處。雖然DexFile.loadDex方法最終也走到了 JNI 裡面呼叫dvmRawDexFileOpen函式,但由於DexFile類是虛擬機器的內部類,Dalvik 虛擬機器不會在內部類執行 JNI 方法的時候將執行緒切換為 NATIVE 狀態,仍然會保持原來的 RUNNING 狀態。於是,在 RUNNING 狀態下,做 OPT 的執行緒就會被要求掛起。而此時由於正在執行耗時的 write 操作,無法響應掛起請求,便出現瞭如上的崩潰。

當然,可能有人會想到在 Native 程式碼中,用CallStaticObjectMethod來觸發DexFile.loadDex,不過這種方式是不可行的。因為CallStaticObjectMethod呼叫 Java 方法DexFile.loadDex時,會使得狀態再次切換為 RUNNING。

具體來看下 CallStatciXXXMethod 方法的定義處:

static _ctype CallStatic##_jname##Method(JNIEnv* env, jclass jclazz,    \
    jmethodID methodID, ...)                                            \
{                                                                       \
    UNUSED_PARAMETER(jclazz);                                           \
    ScopedJniThreadState ts(env);                                       \
    JValue result;                                                      \
    va_list args;                                                       \
    va_start(args, methodID);                                           \
    dvmCallMethodV(ts.self(), (Method*)methodID, NULL, true, &result, args);\
    va_end(args);                                                       \
    if (_isref && !dvmCheckException(ts.self()))                        \
        result.l = (Object*)addLocalReference(ts.self(), result.l);           \
    return _retok;                                                      \
}

關鍵在於 ScopedJniThreadState:

explicit ScopedJniThreadState(JNIEnv* env) {
    mSelf = ((JNIEnvExt*) env)->self;
... ...
    CHECK_STACK_SUM(mSelf);
    dvmChangeStatus(mSelf, THREAD_RUNNING);
}

~ScopedJniThreadState() {
    dvmChangeStatus(mSelf, THREAD_NATIVE);
    COMPUTE_STACK_SUM(mSelf);
}

在使用dvmCallMethodV呼叫 Java 方法前,會先切換狀態為THREAD_RUNNING,執行完畢後,ScopedJniThreadState析構,再切換回THREAD_NATIVE。這樣,JNI 執行DexFile.loadDex就和直接執行 Java 程式碼一樣,狀態會有問題。不只是CallStaticXXXMethod,所有使用CallXXXMethod函式在 Native 下呼叫 Java 方法的情況都是如此。

好在,我們想到了另一個辦法:既然 Dalvik 不會對內部類的 JNI 呼叫做切換,我們就自己寫一個 JNI 呼叫,使其走到 Native 程式碼中,這樣執行緒就會變為 Native 狀態,然後 直接呼叫虛擬機器內部函式 做 dexopt 即可。這樣在做 dexopt 的時候,始終會處於 NATIVE 的狀態,不會切為 RUNNING,也不會被要求掛起,也就能避免這個問題。

這個虛擬機器內部函式就是dvmRawDexFileOpen,我們先來看下它的程式碼說明:

/*
 * Open a raw ".dex" file, optimize it, and load it.
 *
 * On success, returns 0 and sets "*ppDexFile" to a newly-allocated DexFile.
 * On failure, returns a meaningful error code [currently just -1].
 */
int dvmRawDexFileOpen(const char* fileName, const char* odexOutputName,
    RawDexFile** ppDexFile, bool isBootstrap);

這個函式可以用來開啟原始 DEX 檔案,並且對它做優化和載入。對應到 libdvm.so 中的符號是_Z17dvmRawDexFileOpenPKcS0_PP10RawDexFileb,我們只需要用 dlsym 在 libdvm.so 裡面找到它,就可以直接呼叫了,完整程式碼如下:

using func = int (*)(constchar* fileName, constchar* odexOutputName, void* ppRawDexFile, bool isBootstrap);

void* handler = dlopen("libdvm.so", RTLD_NOW);
dvmRawDexFileOpen = (func) dlsym(handler, "_Z17dvmRawDexFileOpenPKcS0_PP10RawDexFileb");
dvmRawDexFileOpen(file_path, opt_file_path, &arg, false);

這樣,我們自己寫一個 JNI 呼叫,在 Native 狀態下執行上述程式碼,就能達到完成 ODEX 的目的,從而根本上杜絕這個異常了。

另外,我們把 dexopt 操作放到了單獨程式執行,由此可以避免 ODEX 操作對主程式造成其他效能影響。此外,由於裝置情況多種多樣,執行環境十分複雜,還可能會有一些廠商魔改,導致的 dlsym 找不到_Z17dvmRawDexFileOpenPKcS0_PP10RawDexFileb符號,雖然這種情況極為罕見,但理論上仍有可能發生。單獨程式裡面由於環境比較純粹,基本很少發生 ANR 和 GC 事件,掛起的情況就很少,也能最大程度規避這個問題。

多級載入

我們發現,相比於官方 MultiDex 載入 ZIP 形態的 DEX 檔案,非 ZIP 方式的 DEX(也就是直接對 DEX 檔案做 ODEX,而不用先把 DEX 壓縮排 ZIP 裡面)對於整體時間也有一定程度的優化,因為這種非 ZIP 方式避免了原先的兩個耗時:

  1. 把原始 DEX 壓縮為 ZIP 格式的時間;
  2. ODEX 優化的時候從 ZIP 中解壓出原始 DEX 的時間。

非 ZIP 的方式相比於 ZIP 方式,整體耗時會減少 40% 左右,但是 DEX 檔案磁碟佔用空間比原先 ZIP 檔案的方式增加一倍多。因此我們可以只在磁碟空間充裕的時候,優先使用非 ZIP 方式載入。

而我們openDexFile_bytearray載入 DEX 的方式,需要的只是原始 DEX 檔案的位元組陣列(byte[])。這個位元組陣列我們在首次冷啟動的時候是直接從 APK 裡面解壓提取得到的。我們可以在這次啟動提取完成後,先把這些位元組陣列落地為 DEX 檔案。這樣如果再次啟動 APP 的時候,ODEX 沒做完,就可以直接使用前面儲存的 DEX 檔案來得到位元組陣列了,從而避免了從 APK 解壓的時間。

總體來看,我們整套方案中一共存在四種形態的 DEX:

  1. 從 APK 檔案裡面解壓得到的 DEX 位元組陣列;
  2. 從落地的 DEX 檔案裡面得到的 DEX 位元組陣列;
  3. 從 DEX 檔案優化得到的 ODEX 檔案;
  4. 從 ZIP 檔案優化得到的 ODEX 檔案。

生成各個產物的時序圖如下所示:

我們依次說明每一步:

  • A. 從 APK 裡面直接解壓得到 DEX 位元組陣列;
  • B. 將 DEX 陣列儲存為檔案;
  • C. 用 DEX 檔案生成 ODEX 檔案;
  • D. 用 DEX 陣列生成 ZIP 檔案以及它對應的 ODEX 檔案。

正常情況下,我們會依次按 A -> B -> C 的時序依次產生各個檔案,如果中間有中斷的情況,我們下次啟動後會繼續按照當前已有產物做對應操作。我們僅在磁碟空間不夠,且所在系統不支援直接載入位元組陣列的情況下才會走 ZIP&ODEX 方式的 D 路徑。這裡不支援的情況主要是一些特殊機型,比如 4.4 卻採用了 ART 虛擬機器的機型、阿里 Yun OS 機型等。

接下來我們繼續看下載入流程圖:

  • 當 APP 首次啟動的時候,如果會從 APK 裡面解壓 DEX 陣列,因此會按照 a -> b 的路徑執行;
  • 當 APP 發現只有 DEX 檔案,沒有 ODEX 檔案時,會把從 DEX 檔案中取得 DEX 陣列,按照 c -> b 路徑執行;
  • 當 APP 發現 DEX 檔案和 ODEX 檔案都存在的時候,會按照 ODEX 方式載入,按照 d 路徑執行;
  • 當 APP 發現有 ZIP 檔案以及它所對應的 ODEX 的時候,會按照 e 路徑執行。

這麼一來,APP 就可以根據當前情況,選擇最合適的方式執行載入 DEX 了。從而保證了任意時刻的最優效能。

程式鎖優化

前面提到,OPT 優化是在單獨的程式裡面執行的。單獨程式除了可以減少前面的 SIGSTKFLT 問題,還能在做完 OPT 後及時終止後臺程式,避免過多的資源佔用。

然而,在單獨程式處理 OPT 和其他程式執行 install 的時候,都涉及到 DEX 和 ODEX 檔案的訪問和生成,因此在這些程式之間涉及到檔案訪問和 OPT 時,都是加檔案鎖互斥執行的。這樣可以避免載入的同時,另一個程式在操作 DEX 和 ODEX 檔案導致的檔案損壞。在官方的 MultiDex 中也是採用這種檔案鎖的方式來進行互斥訪問的。

但這帶來了另一個問題,如果 OPT 程式在長時間做 dexopt,而此時主程式(或者其他後臺程式)需要再次啟動,便會因為 OPT 程式持有互斥檔案鎖,而導致這些程式被阻塞住無法繼續啟動。可以看流程圖來理解這一過程:

image

正如圖中描繪的場景,使用者第一次開啟了 APP,然後執行一會之後因為一些情況殺死了 APP,這時,後臺程式已經啟動並正在做 OPT。如果此時使用者想要再次開啟,就會由於 OPT 程式互斥鎖導致阻塞而黑屏。這顯然是不可接受的。

因此,我們就需要採取更好的策略,使得在主程式能夠正常地繼續往下執行,而不至於被阻塞住。

這個問題的關鍵在於,主程式需要依賴 OPT 程式的產物,才能繼續往下執行,而 OPT 程式此時正在操作 DEX 檔案,這個過程中的產物必定無法被主程式直接使用。

所以,如果想要主程式不再因 OPT 操作阻塞,我們很容易想到可以無視 OPT 程式,不使用 DEX 檔案,只從 APK 裡面獲取記憶體形式的 DEX 位元組碼就可以了。不過這種方式的主要問題在於,如果 OPT 時間非常長,在這段時間內就不得不一直使用記憶體方式的 DEX 啟動 APP,這樣效能就會處於比較差的水平。

因此我們採用的是另一種方案。在主程式退出而再次啟動的時候,先中止 OPT 程式,直接取得現有 DEX 產物進行載入,然後再喚起 OPT 程式。

如下圖所示:

image

這裡關鍵點在於如何中止程式。當然,我們可以直接在主程式發訊號殺死 OPT 程式,不過這種方式過於粗暴,很可能導致 DEX 檔案損壞。而且 kill 訊號的方式沒有回撥,我們無法得知是否程式確實地退出了。

因此,我們採取的方式是用兩個檔案鎖來做同步,保證程式啟動和退出的資訊可以在多個程式之間傳達。

第一個檔案鎖就是單純用來作為互斥鎖,保證處理 DEX 和載入 DEX 的過程是互斥發生的。第二個檔案鎖用來表示程式即將獲取互斥鎖,我們稱之為準備鎖,它可以用來通知 OPT 程式:此時有其他程式正需要載入 DEX 產物。

對於 OPT 程式而言,獲取檔案鎖的步驟如下:

  1. 獲取互斥鎖;
  2. 執行 OPT;
  3. 非阻塞地嘗試獲取準備鎖;
  4. 如果沒有獲取到準備鎖,表示此時有其他程式已經持有準備鎖,則釋放互斥鎖,並退出 OPT 程式;
  5. 如果獲取到了準備鎖,表示此時沒有其他程式正常持有準備鎖,則再次執行第 2 步,做下個檔案的 OPT;
  6. 完成所有 DEX 檔案的 OPT 操作,釋放互斥鎖,退出。

對於主程式(或其他非 OPT 程式)而言,獲取檔案鎖的步驟如下:

  1. 阻塞等待獲取準備鎖;
  2. 阻塞等待獲取互斥鎖;
  3. 釋放準備鎖;
  4. 完成 DEX 載入;
  5. 釋放互斥鎖;
  6. 繼續往下執行業務程式碼。

具體情形見下圖:

首先,OPT 程式開始執行,會獲取到互斥鎖,然後做 DEX 處理。OPT 程式在處理完第一個 DEX 檔案後,由於沒有其他程式持有準備鎖,因此 OPT 程式獲取準備鎖成功,然後釋放準備鎖,繼續做下一個 DEX 優化。

這時候,主程式(或其他非 OPT 程式)啟動,先成功地獲取準備鎖。然後繼續阻塞地獲取互斥鎖,此時由於 OPT 程式已經在前一步獲取到了互斥鎖,因此只能等待其釋放。

OPT 程式在處理完第二個 DEX 後,檢測到準備鎖已經被其他程式持有了,因此獲取失敗,從而停止繼續做 OPT,釋放互斥鎖並退出。

此時主程式就可以成功地獲取到互斥鎖,並且立即釋放準備鎖,以便其他程式可以獲取。接著,在完成 DEX 載入後,釋放互斥鎖,繼續執行後續業務流程。最後再喚起 OPT 程式接著做完原先的 DEX 處理。

總體看來,在這種模式下,OPT 程式可以主動發現有其他程式需要載入 DEX,從而中斷 DEX 處理,並釋放互斥鎖。主程式便不需要等待整個 DEX 處理完成,只需要等 OPT 程式完成最近一個 DEX 檔案的處理就可以繼續執行了。

實測資料

我們本地選取了幾臺 4.4 及以下的裝置,對它們首次啟動的 DEX 載入時間進行了對比:

以上是在抖音上測得的實際資料,APK 中共有 6 個 Secondary DEX,顯而易見,BoostMultiDex 方案相比官方 MultiDex 方案,其耗時有著本質上的優化,基本都只到原先的 11%~17% 之間。也就是說 BoostMultiDex 減少了原先過程 80% 以上的耗時。 另外我們看到,其中有一個機型,在官方 MultiDex 下是直接崩潰,無法啟動的。使用 BoostMultiDex 也將使得這些機型可以煥發新生。另外,我們線上上採取了對半分的方式,也就是 BoostMultiDex 和原始 MultiDex 隨機各自選取一半線上裝置,對比二者的耗時。

我們先以裝置維度來看,這裡隨機選取了 15 分鐘的線上資料,圖中橫軸為每個 Android 版本 4.4 及以下的裝置,縱軸為首次啟動載入 DEX 的耗時,按耗時升序排列,單位為納秒。

BoostMultiDex 下的裝置耗時:

MultiDex 下的裝置耗時:

兩張圖最大的區別在於縱軸的時間刻度。可以看到,絕大多數裝置的 BoostMultiDex 耗時在 5s 左右,最多耗時也不會超過 35s。而反觀 MultiDex,大多數都需要耗時 30 多 s,最長的耗時甚至達到了將近 200s。

上面的圖可能差別不夠明顯,我們選取一段時間,每半小時取所有裝置耗時的中位數,可以得到下面的對比曲線:

其中,下方橙色線為 BoostMultiDex,上方藍色線為原始 MultiDex,可以明顯看出,耗時下降的幅度非常巨大。

耗時的大幅減少會帶來怎樣的效果呢?我們統計了 4.4 及以下機型中,兩者進入到抖音播放頁的裝置數佔比,時間範圍為一週,其中右邊橙色為 BoostMultiDex,左邊藍色為原始 MultiDex。

由於我們所有裝置對於兩種方案的選取是對半開的,所以理論上二者的裝置數應該接近於 1 比 1,不過從圖中我們可以看到,BoostMultiDex 的裝置數已經大幅超過 MultiDex 的裝置數,兩者比例接近於 2 比 1。

從中可以看出,MultiDex 耗時的減少對於裝置活躍數的提升,效果十分顯著!

總結

最後,我們再梳理一下整個方案的實現要點:

  1. 採用openDexFile_bytearray函式,可以直接載入原始 DEX 位元組碼;
  2. 提前注入dex_object物件,以解決 4.4 機型上載入原始 DEX 位元組碼時,getDex的崩潰問題;
  3. 採用dvmRawDexFileOpen函式做 ODEX,以解決 SIGSTKFLT 問題;
  4. 多級載入,在 DEX 位元組碼、DEX 檔案、ODEX 檔案中選取最合適的產物啟動 APP;
  5. 單獨程式做 OPT,並實現合理的中斷及恢復機制。

對於國內偏遠地區,尤其對於海外許多發展中國家,Android 低版本機型仍然佔比較高。目前 BoostMultiDex 方案在抖音和 TikTok 已經全量上線,這會使得這部分低版本 Android 使用者直接受益,極大優化升級和安裝啟動體驗。

我們後續將開源 BoostMultiDex 方案,以協助其他 APP 在低版本 Android 手機上改進效能體驗。

今後,各家對下沉市場有需要的 APP,都能直接使用 BoostMultiDex 方案,立即獲得飛一般的升級安裝體驗!

喜歡本文的話,不妨順手給我點個小贊、評論區留言或者轉發支援一下唄???~
點選【GitHub】還有Android進階資料領取哦!!!

相關文章