百度APP Android包體積優化實踐(二)Dex行號優化

語言: CN / TW / HK

圖片

01 前言

在上一篇文章中,我們簡要介紹了 Android 包體積優化的基本思路以及各優化項。本文我們會重點講述 Dex 體積優化中的行號優化,優化目標是在可追溯原始除錯資訊的前提下,儘可能減少 DebugInfo 體積。

我們參考了業界已有的行號優化方案(如支付寶、R8),採用將行號集改為pc集的方式,做到最大程度複用 DebugInfo,同時解決了過載方法行號區間重疊問題,並提供完整的原始行號 retrace 方案。

如圖1-1所示,為兩個方法的 DebugInfo 視覺化對映過程,我們會將指令集與原始行號的對映關係匯出為 mapping 檔案,並上傳給服務端做後續的 retrace處理。可以發現,對映完成後兩個方法的 DebugInfo 資訊一致,即達到了可複用狀態。

圖片

圖1-1 兩個方法 DebugInfo 對映過程

接下來將詳細講述 DebugInfo 分析、現有方案對比、百度APP優化方案及收益 等內容。

02 解構DebugInfo

除錯資訊(DebugInfo)指的是應用於除錯場景的位元組碼資訊,主要包括原始檔名、行號、區域性變數、擴充套件除錯資訊等。行號優化就是去優化 DebugInfo 中包含的行號資訊,以減少 DebugInfo 區域大小,從而達到減少位元組碼檔案體積的目的。

**丨****2.1 Dex DebugInfo

如圖2-1所示,在Dex檔案格式[2]中,DebugInfo 處於 data 區域,由一系列debug_info_item 組成。

圖片

圖2-1 Dex檔案結構

通常情況下,debug_info_item 與類方法一一對應,其在 Dex 中的引用關係如下圖2-2所示。Dex 為塊狀結構,引用區域的位置均通過 x_off 偏移量確定。

圖片

圖2-2 class -> method -> debug\_info引用關係

debug_info_item結構如圖2-3所示,主要由兩部分構成:header 和一系列debug_event。

header 中包含方法起始行號、方法引數數量、方法引數名三部分資訊;除header 外的 debug_events 可以理解為一系列狀態暫存器,記錄pc指標與行號的偏移量。debug_info_item 本質上是一個狀態機。

圖片

圖2-3 debug\_info\_item 結構

常用的 debug_event 有以下幾類:

名字
value
引數
描述
DBG_END_SEQUENCE
0x00
debug_info_item狀態結束標識,不可修改
DBG_ADVANCE_PC
0x01
pcDelta
僅包含pc偏移值
DBG_ADVANCE_LINE
0x02
lineDelta
僅包含line偏移值
Special Opcodes
[0x0a,0xff]
可由value得到pc偏移值和line偏移值

Special Opcodes value 與 pcDelta & lineDelta 的換算公式如下:

DBG_FIRST_SPECIAL = 0x0a  // the smallest special opcodeDBG_LINE_BASE   = -4      // the smallest line number incrementDBG_LINE_RANGE = 15 // the number of line increments representedadjusted_opcode = opcode - DBG_FIRST_SPECIALline += DBG_LINE_BASE + (adjusted_opcode % DBG_LINE_RANGE)address += (adjusted_opcode / DBG_LINE_RANGE)

**丨****2.2 DebugInfo 使用場景

DebugInfo 常見的使用場景是斷點除錯及堆疊定位(包括崩潰、ANR、記憶體分析等所有可輸出方法堆疊的場景)。接下來以列印崩潰堆疊為例,系統如何通過解析DebugInfo 輸出異常定位。

Throwable 物件初始化時會首先呼叫 nativeFillInStackTrace() 方法獲取當前執行緒中 StackTrace,而 StackTrace 中儲存的是 ArtMethod(ART虛擬機器中方法物件)和對應pc值,沒有行號資訊;真正列印堆疊時,通過呼叫 nativeGetStackTrace 方法將StackTrace 轉化為 StackTraceElement[] ,StackTraceElement 會包含方法所屬原始檔與方法行號。如圖2-4所示,異常堆疊末尾會顯示方法原始檔與行號。

圖片

圖2-4 異常堆疊

StackTrace 轉化為 StackTraceElement[] 的程式碼呼叫路徑如下所示,即虛擬機器將當前執行緒方法棧內容轉化為圖2-4中可讀的堆疊資訊的過程。

// art/runtime/native/java_lang_Throwable.ccstatic jobjectArray Throwable_nativeGetStackTrace(JNIEnv* env, jclass, jobject javaStackState) { ... ScopedFastNativeObjectAccess soa(env); return Thread::InternalStackTraceToStackTraceElementArray(soa, javaStackState); // 將StackTrace轉化為StackTraceElement[]}// art/runtime/thread.ccjobjectArray Thread::InternalStackTraceToStackTraceElementArray( const ScopedObjectAccessAlreadyRunnable& soa, jobject internal, jobjectArray output_array, int* stack_depth) { ... // 遍歷StackTrace for (uint32_t i = 0; i < static_cast<uint32_t>(depth); ++i) { ObjPtr<mirror::ObjectArray<mirror::Object>> decoded_traces = soa.Decode<mirror::Object>(internal)->AsObjectArray<mirror::Object>(); const ObjPtr<mirror::PointerArray> method_trace = ObjPtr<mirror::PointerArray>::DownCast(decoded_traces->Get(0)); // 從StackTrace中獲取 ArtMethod與對應pc ArtMethod* method = method_trace->GetElementPtrSize<ArtMethod*>(i, kRuntimePointerSize); uint32_t dex_pc = method_trace->GetElementPtrSize<uint32_t>(i + static_cast<uint32_t>(method_trace->GetLength()) / 2, kRuntimePointerSize); // 根據 ArtMethod與對應pc 建立 StackTraceElement物件 const ObjPtr<mirror::StackTraceElement> obj = CreateStackTraceElement(soa, method, dex_pc); soa.Decode<mirror::ObjectArray<mirror::StackTraceElement>>(result)->Set<false>(static_cast<int32_t>(i), obj); } return result;}static ObjPtr<mirror::StackTraceElement> CreateStackTraceElement( const ScopedObjectAccessAlreadyRunnable& soa, ArtMethod* method, uint32_t dex_pc) REQUIRES_SHARED(Locks::mutator_lock_) { ... // 獲取pc對應的程式碼行號 int32_t line_number; line_number = method->GetLineNumFromDexPC(dex_pc); ...}// ... art_method.h -> code_item_accessors.h// 當遍歷debugInfo過程中,pc滿足條件(大於等於StackTrace記錄的pc)時,返回對應的行號inline bool CodeItemDebugInfoAccessor::GetLineNumForPc(const uint32_t address, uint32_t* line_num) const { return DecodeDebugPositionInfo([&](const DexFile::PositionInfo& entry) { if (entry.address_ > address) { return true; } *line_num = entry.line_; return entry.address_ == address; });}// code_item_accessors.h -> dex_file.h// 遍歷dex中對應的debugInfobool DexFile::DecodeDebugPositionInfo(const uint8_t* stream, const IndexToStringData& index_to_string_data, const DexDebugNewPosition& position_functor) { PositionInfo entry; entry.line_ = DecodeDebugInfoParameterNames(&stream, VoidFunctor()); for (;;) { uint8_t opcode = *stream++; switch (opcode) { case DBG_END_SEQUENCE: return true; // end of stream. case DBG_ADVANCE_PC: entry.address_ += DecodeUnsignedLeb128(&stream); break; case DBG_ADVANCE_LINE: entry.line_ += DecodeSignedLeb128(&stream); break; ... // 其他event型別處理,與區域性變數、原始檔相關 ... default: { int adjopcode = opcode - DBG_FIRST_SPECIAL; entry.address_ += adjopcode / DBG_LINE_RANGE; entry.line_ += DBG_LINE_BASE + (adjopcode % DBG_LINE_RANGE); break; } } }}

從上面的程式碼中 GetLineNumForPc 方法可以看出,虛擬機器通過指標尋找原始行號時會遍歷對應的 debugInfo。由於我們的方案中將 pcDelta 全部統一為1,遍歷長度會比原先長,但由於遍歷中的處理極為簡單,所以幾乎不會查詢效能造成影響。

03 現有優化方案

**丨****3.1 極限優化方案

DebugInfo 作為執行無關資訊是可以全部移除的。問題在於如果直接移除 DebugInfo 的話,除錯堆疊會無法提供準確的行號資訊,圖2-4 堆疊行號均會顯示-1。如果應用穩定性高、定位難度低,可以選擇全部移除 DebugInfo。

Java編譯器、程式碼縮減混淆工具都提供了相應的選項用於不生成或者移除class位元組碼中的 DebugInfo。如圖3-1所示,在 Class 位元組碼檔案[3]中,DebugInfo對應attributes區域中 SourceFile、SourceDebugExtension、LineNumberTable、LocalVariableTable 四項資訊。

圖片

圖3-1 Class檔案結構

編譯選項

``` -g:lines // 生成LineNumberTable-g:vars // 生成LineVariableTable-g:source // 生成SourceFile-g:none // 不生成任何debugInfo

```

Proguard規則[4]

-keepattributes SourceFile // 保留SourceFile-keepattributes LineNumberTable // 保留LineNumberTable

除此之外,也可以在 transform 階段利用位元組碼操作工具移除 DebugInfo。位元組已開源的 ByteX 位元組碼工具[5]中即使用了這種方案。

**丨****3.2 對映優化方案

在實際情況中,應用會進行頻繁地業務迭代與技術升級,高穩定性是需要持續維護的,所以我們不會直接移除 DebugInfo,因為那會使問題的定位成本變得十分高。

對映優化方案的基本邏輯是保留 debugInfo 區域,但讓 Dex 中 method 與 debug_info_item 的1對1關係變為N對1的複用關係,debug_info_item 數量減少了,體積自然會減少。同時匯出 debug_info_item 複用前後的對映檔案,可據此還原崩潰堆疊。下文中提到的支付寶、R8 和百度APP 的行號優化均使用了對映優化方案。

要做到 debug_info_item 複用,我們首先需要確認 debug_info_item 的 equals 判斷邏輯。若兩個 debug_info_item 的組成部分均相同,則認為兩者相等,即可複用。debug_info_item 的組成部分包括方法起始行號、方法引數、一系列 debug_events。由於我們關心的堆疊資訊中不包含方法引數,那麼需要統一的就只有起始行號和 debug_events。

// debug_info_item 相等判斷邏輯(虛擬碼)public boolean equals(DebugInfoItem debugInfoItem) { return this.startLine == debugInfoItem.startLine && this.parameters.equals(debugInfoItem.parameters) && this.events.equals(debugInfoItem.events);}

startLine 只是一個int值,賦值相同即可。

debug_events 的 equals 邏輯也與其內容相關,即 events 數量以及每個 event 的型別與值。

// debug_event 相等邏輯判斷(虛擬碼)public boolean equals(DebugEvent event) { return this.type == event.type && this.value == event.value;}

從上述的分析可以發現,想要達成 debug_info_item 複用,需要控制以下變數,使之儘可能保持相同:startLine、debug_events 數量、debug_event 型別、lineDelta、pcDelta (opcode不算在內,因為可以由lineDelta & pcDelta計算得到)。

過載方法行號區間重疊問題

除了 startLine 外,其餘四個變數取值是同步決定的,下文中會做詳細介紹。startLine 作為方法起始行號,是 lineDelta 的累加基數,看似可以固定賦值,例如全部方法都以1作為起始對映行號。

但遇到過載方法時,如果兩個方法的對映後行號區間有重疊,我們會無法確定對映後的行號應該還原至哪個方法。原因在於虛擬機器解析出的堆疊中僅使用方法名作為方法唯一標識,而非我們通常認識的方法過載中 [方法名,引數型別,引數個數] 三者結合作為方法唯一標識。

舉例如下:

// 方法行號對映為:com.example.myapplication.MethodOverloadSample.test(): 1->21 2->22com.example.myapplication.MethodOverloadSample.test(String msg): 1->34 2->35...// 收集對映後方法堆疊:...at com.example.myapplication.MethodOverloadSample.test(MethodOverloadSample.java:2)...

由於堆疊中僅包含方法名,我們無法確定應該對映到行號22還是行號35。

**丨****3.3 支付寶行號優化方案

支付寶介紹了兩種行號優化方案。

方案一

(1)編譯時將 debugInfo 全部摘出來作為 debugInfo.dex,APK中不再包含 debugInfo。

(2)異常發生時,通過 hook Throwable,從其持有的 StackTrace 物件中解析得到的指令集行號並上傳。

(3)效能平臺結合步驟1中的 debugInfo.dex,將指令集行號轉化為原始行號。

該方案原理是離線還原章節2.2中的流程,問題在於僅使用 Throwable 場景,且由於不同版本 JVM 的 StackTrace 物件結構不同,適配成本比較高。

方案二

保留 N 個 debug_info_item,同時將其修改為方法指令集,即通過 debug_info_item 獲取到的 lineNumber 實質上是指令行號,而非程式碼行號。

這種方案下變數 lineDelta == pcDelta,取值始終為1,由此 debug_event 也就確定是 specail opcodes 型別;每個 debug_info_item 中 debug_event 的數量也可以根據實際情況人為設定,能夠覆蓋應用方法的指令數量即可。至此所有的變數都有了固定賦值,即 debug_info_item 做到了方法複用。

百度APP 與支付寶APP 的行號優化方案在整體的行號複用策略上是類似的,都是通過讓更多的方法複用同一個 debug_info_item 來達到節省包體積的效果。百度App 行號優化方案在實現過載方法、R8 行號優化等行號完全可還原方面進行了更細化的考慮和設計。

**丨*3.4 R8行號優化方案*[6]

聲明瞭 -keepattributes LineNumberTable 後,R8不會移除行號資訊,轉而啟用行號優化。其對 debug_info_item 的修改包括兩處:

startLine:startLine 預設為1。當遇到同名方法時,後一個方法的 startLine 為前一個方法優化後 endLine+1。原因如章節3.2 中提到的同名方法行號 retrace 問題。

lineDelta:lineDelta 預設為1。

這樣修改後,一部分 debug_info_item 可複用,但由於 debug_events 數量以及 pcDelta 仍不可控,複用程度十分有限。

R8 的行號優化對映結果如圖3-2所示,其中一個方法可能對應一個或多個行號區間對映,其原因在於 lineDelta 強制為1,所以對映前後的行號區間 Delta 必須保持一致。

圖片

圖3-2 R8 行號對映

除此之外,R8還利用 SourceDebugExtension 還原了 kotlin inline 方法的實際位置,如圖3-3所示

圖片

圖3-3 R8還原 kotlin inline 行號對映

04 百度APP Dex行號優化方案

百度APP的行號優化對 startLine、pcDelta、lineDelta、debug_event 數量均進行了控制,最終 debug_info_item 複用比例得到了極大提升。同時百度APP 聯合內部效能平臺,對線上收集到的崩潰、ANR 堆疊進行行號還原。流程如圖4-1所示:

圖片

圖4-1 百度APP端到端的行號優化流程

**丨****4.1客戶端行號優化

debug_info_item 變數控制

(1)startLine

預設值為100000。與R8預設值為1不同,選這麼大的初始值是為了避免熱修復、外掛中存在同名方法時出現行號重疊,造成行號還原失敗。

理想的行號區間分佈如下圖所示。每成功分配一個行號區間後,我們會立即初始化下一個行號區間的 next_startLine = ((this_startLine + this_inst_size) / default_gap + 1) * default_gap。

當出現同名方法時,我們會就現有的行號區間進行比對,next_startLine 是否符合要求,如果不符合還需要在疊加 default_gap(預設值為5000)。

圖片

圖4-2 理想行號區間

(2)debug_event

除了表示起始結束的 debug_event 外,剩餘全部都是 pcDelta=lineDelta=1的 special opcodes 型別。其中 debug_event 數量根據方法指令數量而定,取值為所屬指令分割槽間的上限值。

圖片

圖4-3 指令數量區間與 debug\_event 數量對映

圖片

圖4-4 對映後的 debug\_events

(3)pcDelta

首個 special opcodes 為0,其餘為1。

(4)lineDelta

預設與 pcDelta 一致。即通過 debugInfo 獲取程式碼行號,實際拿到的是對映後的指令行號。

行號對映

生成的行號對映表格式如下所示:

類名1: 方法描述符1: 對映後行號閉區間1 -> 原行號1 對映後行號閉區間2 -> 原行號2 方法描述符1: 對映後行號閉區間1 -> 原行號1 對映後行號閉區間2 -> 原行號2 類名2: ...

行號對映表示例如下:

com.baidu.searchbox.Application: void onCreate(android.os.Bundle): [1000-1050] -> 20 [1051-2000] -> 22 void onCreate(): [3000-3020] -> 30 [3021-3033] -> 31 void onStop(): [1000-1050] -> 50 [1051-2000] -> 55com.baidu.searchbox.MainActivity:    void onResume():        [1000-1050] -> 100

相容 R8 行號優化

R8 對行號資訊的處理有三種情況:移除、優化、保留。處理條件如圖4-5 所示。

圖片

圖4-5 R8 處理行號邏輯

其中 debug mode 引數由 AGP 控制傳入,目前關聯引數是 buildType.isDebuggable。不過編譯線上 release 包時是不會開啟 isDebuggable 的,所以工程在啟用了 R8 的情況下只有行號移除與優化兩種結果。

此時我們的行號優化工具處理的物件就是R8已經對映過一次的行號了。這裡的相容做法有兩種:

(1)hook R8 任務,對R8 行號保留做自定義修改。這種方法工作量會比較大。

(2)針對R8 的對映做 retrace。流程可以是 [R8對映->百度APP行號優化對映](客戶端) -> [百度APP行號retrace -> R8行號retrace](服務端),也可以是_[R8對映-> R8行號retrace ->百度APP行號優化對映](客戶端) -> [百度APP行號retrace](服務端)_。我們目前採用的是後者。

R8 行號對映內容與混淆一同輸出在 mapping.txt 中,具體參考3.4章節。

工具使用

最終行號優化工具以 gradle 外掛形式接入工程,行號優化任務依託於 packageApplication 任務之前執行,處理物件為 minify 任務輸出的 Dex 檔案,並將優化後的 Dex 檔案作為 packageApplication 任務輸入。

體積優化效果

百度APP 上線行號優化前,APK體積為 123.58M,其中dex體積為 37.42M;啟用行號優化後,APK體積減小至120.54M,優化3.04M,佔dex體積~8%。為了滿足多個渠道包共用一個行號對映檔案的需求,我們希望類內對映行號儘可能保持不變,所以選擇了類級別的行號區間分配。如果在 Dex 級別進行行號區間分配,可優化更多體積,實驗表明可進一步優化400K。

**丨****4.2 效能平臺行號對映還原

百度APP 上線行號優化後,端上報的異常資訊中不再攜帶真正的行號,攜帶的行號為虛擬行號,虛擬行號並不能真正對映到異常發生時實際程式碼所在行,給業務方排查線上問題帶來了很大麻煩。因此效能平臺需要將虛擬行號進行對映解析。將端上上報的崩潰、卡頓等異常資訊中的虛擬行號通過一定的解析演算法 + APP發版時傳入效能平臺的行號對映表,最終對映成真實的行號,使的該行號能夠真正對映到異常發生時實際程式碼所在行,最終提升業務方在效能平臺上分析問題的能力。

在APP應用中,儘管發生崩潰、卡頓等異常場景的概率很低,但是在日活過億的使用者級別下,產生的異常資訊也是千萬、億級別的,如何對全量異常資訊進行實時行號對映解析是效能平臺面臨的首要問題。

效能平臺整體架構圖

效能平臺採取如下架構對全量使用者產生的異常資訊的行號進行對映解析,設計主要分位三個部分:流式計算處理服務、多級快取系統、對映檔案解析服務。整體的架構圖如下所示:

圖片

圖4-6 效能平臺服務端整體架構圖

對映檔案解析服務

在進行行號對映解析的過程中,需要原始異常資訊 + 行對映解析檔案 + 解析演算法 ->真正行號。因此,在APP發版時,需要採用手動(效能平臺上傳)或者自動(發版流水線配置)的方式將行對映解析檔案上傳到效能平臺的解析伺服器中,通過對映解析伺服器將資料寫入到多級快取系統中,供流式計算引擎使用。例如,原始的對映檔案如圖4-7,其中包含了包名、類名、方法名、對映行號閉區間、真實行號等資訊。

圖片

圖4-7 對映檔案示例

效能平臺在將這些資訊寫入快取系統時的結構(key-value)HashMap為:

APP_版本_com.baidu.searchbox.Application.onCreate: [1000-1050] -> 20 [1051-2000] -> 22 [3000-3020] -> 30 [3021-3033] -> 31 流式計算處理服務

該部分的流程為,端上採集異常資訊 -> 上報到日誌中臺 -> 效能平臺數據彙總Bigpipe -> 效能平臺按照業務分流 -> 各個子業務的Bigpipe -> 流式計算引擎進行行號解析等處理 -> 資料儲存 -> 效能平臺進行展示。

流式計算引擎進行行號解析時,會將訪問頻率最熱的對映檔案行號的Map結構載入到運算元記憶體中。若記憶體中無法命中,則去多級快取中去查詢再載入到運算元的記憶體中。

多級快取系統

對於查詢的響應速度,資料在流式計算運算元的記憶體中的讀寫速度 > Redis 等記憶體儲存系統>列式儲存系統 Table。多級快取系統的由運算元記憶體、Redis、Table等構建。最上層是實時流運算元記憶體,響應速度最快,但容量受到限制,用來快取訪問頻率最高的對映檔案索引,中間層是 Redis,主要儲存線上的對映檔案,最底層則為 Table,儲存的是線上和線下場景的對映檔案。對於我們整個系統來說,流式引擎運算元記憶體中的快取命中率高是我們提升行對映解析時效性重要保證。因此我們設計瞭如下的快取替換策略:

(1)快取具備高併發能力,能夠並行的互不干擾的讀寫;

(2)快取具備老化能力,當一個數據版本N天未被命中時,快取將其老化清除;

(3)資料具備W-TinyLFU的替換策略,使得記憶體中的快取為最近最頻繁訪問的Key值。

設計和實現中關鍵問題的解決

(1)  資料的冪等性

在分散式的流式處理系統中,實時處理系統往往也會面臨崩潰,重啟的情況,因此要求系統對資料的處理具有冪等性,即精確消費一次資料的語義。在系統中,我們通過實時計算引擎中的Checkpoint機制,保證資料的消費至少一次消費。然後在儲存中,通過對資料的日誌ID作為資料的唯一標識,即一條異常資訊資料即使多次消費也只會儲存一次。保證了整個系統的冪等性要求。

(2)  資料的流量壓力控制

在整體的設計中,資料的處理和資料的採集通過了中介軟體訊息佇列進行了解耦和削峰,當資料處於高峰期時,此時未能消費完的資料會儲存在訊息中介軟體的磁碟上。流量高峰的時間段都是較短的,待流量高峰期結束,資料處理模組又能將中介軟體中累積的資料處理完從而做到較好的壓力控制。

(3)  資料處理的低延時

採用多級快取系統的設計,保證了每條資料的行解析對映在ms級別,使的系統的異常端上上報產生->效能平臺展示解析結果的整個流程保證在了分鐘級級別。

05 總結

本文主要介紹了 DebugInfo 的定位以及優化方案,其中重點講述了目前百度APP所使用的Dex行號優化與復原方案。感謝各位閱讀至此,如有問題請不吝指正。

————————END————————

參考資料:

[1] 支付寶行號優化https://juejin.cn/post/6844903712201277448

[2] Dex結構 https://source.android.com/devices/tech/dalvik/dex-format

[3] Class結構

https://docs.oracle.com/javase/specs/jvms/se7/html/jvms-2.html#jvms-2.1

[4] ProGuard規則

https://www.guardsquare.com/manual/configuration/attributes

[5] ByteX https://github.com/bytedance/ByteX

[6] R8 https://r8.googlesource.com/r8

推薦閱讀:

百度APP Android包體積優化實踐(一)總覽

百度APP iOS端記憶體優化實踐-大塊記憶體監控方案

百家號基於AE的視訊渲染技術探索

百度工程師教你玩轉設計模式(觀察者模式)

Linux透明大頁機制在雲上大規模叢集實踐介紹

超高效!Swagger-Yapi的祕密