作者:洪凱 常強
1. 背景
應用安裝包的體積影響著用戶的下載時長、安裝時長、磁盤占用空間等諸多方面,因此減小安裝包的體積對于提升用戶體驗和下載轉化率都大有益處。Android 應用安裝包其實是一個 zip 文件,主要由 dex、assets、resource、so 等各類型文件壓縮而成。目前業內常見的包體積優化方案大體分為以下幾類:
針對 dex 的優化,例如 Proguard、dex 的 DebugItem 刪除、字節碼優化等;
針對 resource 的優化,例如 AndResGuard、webp 優化等;
針對 assets 的優化,例如壓縮、動態下發等;
針對 so 的優化,同 assets,另外還有移除調試符號等。
隨著動態化、端智能等技術的廣泛應用,在采用上述優化手段后, so 在安裝包體積中的比重依然很高,我們開始思索這部分體積是否能進一步優化。經過一段時間的調研、分析和驗證,我們逐漸摸索出一套可以將應用安裝包中 so 體積進一步減小 30%~60% 的方案。
該方案包含一系列純技術優化手段,對業務侵入性低,通過簡單的配置,可以快速部署生效,目前美團 App 已在線上部署使用。為讓大家能知其然,也能知其所以然,本文將先從 so 文件格式講起,結合文件格式分析哪些內容可以優化。
2. so 文件格式分析
so 即動態庫,本質上是 ELF(Executable and Linkable Format)文件??梢詮膬蓚€維度查看 so 文件的內部結構:鏈接視圖(Linking View)和執行視圖(Execution View)。鏈接視圖將 so 主體看作多個 section 的組合,該視圖體現的是 so 是如何組裝的,是編譯鏈接的視角。而執行視圖將 so 主體看作多個 segment 的組合,該視圖告訴動態鏈接器如何加載和執行該 so,是運行時的視角。鑒于對 so 優化更側重于編譯鏈接角度,并且通常一個 segment 包含多個 section(即鏈接視圖對 so 的分解粒度更小),因此我們這里只討論 so 的鏈接視圖。通過 readelf -S 命令可以查看一個 so 文件的所有 section 列表,參考 ELF 文件格式說明,這里簡要介紹一下本文涉及的 section:
- .text:存放的是編譯后的機器指令,C/C++代碼的大部分函數編譯后就存放在這里。這里只有機器指令,沒有字符串等信息。
- .data:存放的是初始值不為零的一些可讀寫變量。
- .bss:存放的是初始值為零或未初始化的一些可讀寫變量。該 section 僅指示運行時需要的內存大小,不會占用 so 文件的體積。
- .rodata:存放的是一些只讀常量。
- .dynsym:動態符號表,給出了該 so 對外提供的符號(導出符號)和依賴外部的符號(導入符號)的信息。
- .dynstr?:字符串池,不同字符串以 '\0' 分割,供 .dynsym 和其他部分使用。
- .gnu.hash? 和.hash?:兩種類型的哈希表,用于快速查找 .dynsym 中的導出符號或全部符號。
- .gnu.version、.gnu.version_d、.gnu.version_r?:這三個 section 用于指定動態符號表中每個符號的版本,其中.gnu.version? 是一個數組,其元素個數與動態符號表中符號的個數相同,即數組每個元素與動態符號表的每個符號是一一對應的關系。數組每個元素的類型為 Elfxx_Half?,其意義是索引,指示每個符號的版本。.gnu.version_d? 描述了該 so 定義的所有符號的版本,供.gnu.version? 索引。.gnu.version_r? 描述了該 so 依賴的所有符號的版本,也供 .gnu.version 索引。因為不同的符號可能具有相同的版本,所以采用這種索引結構,可以減小 so 文件的大小。
在進行優化之前,我們需要對這些 section 以及它們之間的關系有一個清晰的認識,下圖較直觀地展示了 so 中各個 section 之間的關系(這里只繪制了本文涉及的 section):
圖1 so文件結構示意圖
結合上圖,我們從另一個角度來理解 so 文件的結構:想象一下,我們把所有的函數實現體都放到.text 中,.text 中的指令會去讀取 .rodata 中的數據,讀取或修改 .data 和 .bss 中的數據??瓷先?so 中有這些內容也足夠了。但是這些函數怎樣執行呢?也就是說,只把這些函數和數據加載進內存是不夠的,這些函數只有真正去執行,才能發揮作用。
我們知道想要執行一個函數,只要跳轉到它的地址就行了。那外界調用者(該 so 之外的模塊)怎樣知道它想要調用函數的地址呢?這里就涉及一個函數 ID 的問題:外部調用者給出需要調用的函數的 ID,而動態鏈接器(Linker)根據該 ID 查找目標函數的地址并告知外部調用者。所以 so 文件還需要一個結構去存儲“ID-地址”的映射關系,這個結構就是動態符號表的所有導出符號。
具體到動態符號表的實現,ID 的類型是“字符串”,可以說動態符號表的所有導出符號構成了一個“字符串-地址“的映射表。調用者獲取目標函數的地址后,準備好參數跳轉到該地址就可以執行這個函數了。另一方面,當前 so 可能也需要調用其他 so 中的函數(例如 libc.so 中的 read、write 等),動態符號表的導入符號記錄了這些函數的信息,在 so 內函數執行之前動態鏈接器會將目標函數的地址填入到相應位置,供該 so 使用。
所以動態符號表是連接當前 so 與外部環境的“橋梁”:導出符號供外部使用,導入符號聲明了該 so 需要使用的外部符號(注:實際上.dynsym中的符號還可以代表變量等其他類型,與函數類型類似,這里就不再贅述)。結合 so 文件結構,接下來我們開始分析 so 中有哪些內容可以優化。
3. so 可優化內容分析
在討論 so 可優化內容之前,我們先了解一下 Android 構建工具(Android Gradle Plugin,下文簡稱 AGP)對 so 體積做的 strip 優化(移除調試信息和符號表)。
AGP 編譯 so 時,首先產生的是帶調試信息和符號表的 so(任務名為 externalNativeBuildRelease),之后對剛產生的帶調試信息和符號表的 so 進行 strip,就得到了最終打包到 apk 或 aar 中的 so(任務名為 stripReleaseDebugSymbols)。
strip 優化的作用就是刪除輸入 so 中的調試信息和符號表。這里說的符號表與上文中的“動態符號表”不同,符號表所在 section 名通常為 .symtab,它通常包含了動態符號表中的全部符號,并且額外還有很多符號。
調試信息顧名思義就是用于調試該 so 的信息,主要是各種名字以 .debug_ 開頭的 section,通過這些 section 可以建立 so 每條指令與源碼文件的映射關系(也就是能夠對 so 中每條指令找到其對應的源碼文件名、文件行號等信息)。之所以叫 strip 優化,是因為其實際調用的是 NDK 提供的的 strip 命令(所用參數為--strip-unneeded)。
注:為什么 AGP 要先編譯出帶調試信息和符號表的 so,而不直接編譯出最終的 so 呢(通過添加-s參數是可以做到直接編譯出沒有調試信息和符號表的 so 的)?原因就在于需要使用帶調試信息和符號表的 so 對崩潰調用棧進行還原。刪除了調試信息和符號表的 so 完全可以正常運行,但是當它發生崩潰時,只能保證獲取到崩潰調用棧的每個棧幀的相應指令在 so 中的位置,不一定能獲取到符號。但是排查崩潰問題時,我們希望得知 so 崩潰在源碼的哪個位置。帶調試信息和符號表的 so 可以將崩潰調用棧的每個棧幀還原成其對應的源碼文件名、文件行號、函數名等,大大方便了崩潰問題的排查。所以說,雖然帶調試信息和符號表的 so 不會打包到最終的 apk 中,但它對排查問題來說非常重要。
AGP 通過開啟 strip 優化,可以大幅縮減 so 的體積,甚至可以達到十倍以上。以一個測試 so 為例,其最終 so 大小為14 KB,但是對應的帶調試信息和符號表的 so 大小為 136 KB。不過在使用中,我們需要注意的是,如果 AGP 找不到對應的 strip 命令,就會把帶調試信息和符號表的 so 直接打包到 apk 或 aar 中,并不會打包失敗。例如缺少 armeabi 架構對應的 strip 命令時提示信息如下:
Unable to strip library 'XXX.so' due to missing strip tool for ABI 'ARMEABI'. Packaging it as is.
除了上述 Android 構建工具默認為 so 體積做的優化,我們還能做哪些優化呢?首先明確我們優化的原則:
- 對于必須保留的內容考慮進行縮減,減小體積占用;
- 對于無需保留的內容直接刪除。
基于以上原則,可以從以下三個方面對 so 繼續進行深入優化:
- 精簡動態符號表:上文已經提到,動態符號表是 so 與外部進行連接的“橋梁”,其中的導出表相當于是 so 對外暴露的接口。哪些接口是必須對外暴露的呢?在 Android 中,大部分 so 是用來實現 Java 的 native 方法的,對于這種 so,只要讓應用運行時能夠獲取到 Java native 方法對應的函數地址即可。要實現這個目標,有兩種方法:一種是使用 RegisterNatives 動態注冊 Java native 方法,一種是按照 JNI 規范定義 java_***? 樣式的函數并導出其符號。RegisterNatives 方式可以提前檢測到方法簽名不匹配的問題,并且可以減少導出符號的數量,這也是 Google 推薦的做法。所以在最優情況下只需導出 JNI_OnLoad?(在其中使用 RegisterNatives 對 Java native 方法進行動態注冊)和 JNI_OnUnload?(可以做一些清理工作)這兩個符號即可。如果不希望改寫項目代碼,也可以再導出 java_***? 樣式的符號。除了上述類型的 so,剩余的 so 通常是被應用的其他 so 動態依賴的,對于這類 so,需要確定所有動態依賴它的 so 依賴了它的哪些符號,僅保留這些被依賴的符號即可。另外,這里應區分符號表項與實現體,符號表項是動態符號表中相應的 Elfxx_Sym? 項(見上圖),實現體是其在 .text、.data?、 .bss、.rodata? 等或其他部分的實體。刪除了符號表項,實現體不一定要被刪除。結合上文 so 文件結構示意圖,可以預估出刪除一個符號表項后 so 減小的體積為:符號名字符串長度+ 1 + Elfxx_Sym? + Elfxx_Half? + Elfxx_Word 。
- 移除無用代碼:在實際的項目中,有一些代碼在 Release 版中永遠不會被使用到(例如歷史遺留代碼、用于測試的代碼等),這些代碼被稱為 DeadCode。而根據上文分析,只有動態符號表的導出符號直接或間接引用到的所有代碼才需要保留,其他剩余的所有代碼都是 DeadCode,都是可以刪除的(注:事實上.init_array等特殊 section 涉及的代碼也要保留)。刪除無用代碼的潛在收益較大。
- 優化指令長度:實現某個功能的指令并不是固定的,編譯器有可能能用更少的指令完成相同的功能,從而實現優化。由于指令是 so 的主要組成部分,因此優化這一部分的潛在收益也比較大。
so 可優化內容如下圖所示(可刪除部分用紅色背景標出,可優化部分是.text?),其中 funC、value2、value3、value6 由于分別被需保留部分使用,所以需要保留其實現體,只能刪除其符號表項。funD、value1、value4、value5 可刪除符號表項及其實現體(注:因為 value4 的實現體在.bss?中,而.bss?實際不占用 so 的體積,所以刪除 value4 的實現體不會減小 so 的體積)。
圖2 so可優化部分
在確定了 so 中可以優化的內容后,我們還需要考慮優化時機的問題:是直接修改 so 文件,還是控制其生成過程?考慮到直接修改 so 文件的風險與難度較大,控制 so 的生成過程顯然更穩妥。為了控制 so 的生成過程,我們先簡要介紹一下 so 的生成過程:
圖3 so文件的生成過程如上圖所示,so 的生成過程可以分為四個階段:
- 預處理:將 include 頭文件處擴展為實際文件內容并進行宏定義替換。
- 編譯:將預處理后的文件編譯成匯編代碼。
- 匯編:將匯編代碼匯編成目標文件,目標文件中包含機器指令(大部分情況下是機器指令,見下文 LTO 一節)和數據以及其他必要信息。
- 鏈接:將輸入的所有目標文件以及靜態庫(.a 文件)鏈接成 so 文件。
可以看出,預處理和匯編階段對特定輸入產生的輸出基本是固定的,優化空間較小。所以我們的優化方案主要是針對編譯和鏈接階段進行優化。
4. 優化方案介紹
我們對所有能控制最終 so 體積的方案都進行調研,并驗證了其效果,最后總結出較為通用的可行方案。
4.1 精簡動態符號表
使用 visibility 和 attribute 控制符號可見性
可以通過給編譯器傳遞 -fvisibility=VALUE 控制全局的符號可見性,VALUE 常取值為 default 和 hidden:
- default:除非對變量或函數特別指定符號可見性,所有符號都在動態符號表中,這也是不使用 -fvisibility 時的默認值。
- hidden:除非對變量或函數特別指定符號可見性,所有符號在動態符號表中都不可見。
CMake 項目的配置方式:
set(CMAKE_C_FLAGS "${CMAKE_C_FLAGS} -fvisibility=hidden")
set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -fvisibility=hidden")
ndk-build 項目的配置方式:
LOCAL_CFLAGS += -fvisibility=hidden
另一方面,針對單個變量或函數,可以通過 attribute 方式指定其符號可見性,示例如下:
__attribute__((visibility("hidden")))
int hiddenInt=3;
其常用值也是 default 和 hidden,與 visibility 方式意義類似,這里不再贅述。attribute 方式指定的符號可見性的優先級,高于 visibility 方式指定的可見性,相當于 visibility 是全局符號可見性開關,attribute 方式是針對單個符號的可見性開關。這兩種方式結合就能控制源碼中每個符號的可見性。需要注意的是上面這兩種方式,只能控制變量或函數是否存在于動態符號表中(即是否刪除其動態符號表項),而不會刪除其實現體。
使用 static 關鍵字控制符號可見性
在C/C++語言中,static 關鍵字在不同場景下有不同意義,當使用 static 表示“該函數或變量僅在本文件可見”時,那么這個函數或變量就不會出現在動態符號表中,但只會刪除其動態符號表項,而不會刪除其實現體。static 關鍵字相當于是增強的 hidden(因為 static 聲明的函數或變量編譯時只對當前文件可見,而 hidden 聲明的函數或變量只是在動態符號表中不存在,在編譯期間對其他文件還是可見的)。在項目開發中,使用 static 關鍵字聲明一個函數或變量“僅在本文件可見”是很好的習慣,但是不建議使用 static 關鍵字控制符號可見性:無法使用 static 關鍵字控制一個多文件可見的函數或變量的符號可見性。
使用 exclude libs 移除靜態庫中的符號
上述 visibility 方式、attribute 方式和 static 關鍵字,都是控制項目源碼中符號的可見性,而無法控制依賴的靜態庫中的符號在最終 so 中是否存在。exclude libs 就是用來控制依賴的靜態庫中的符號是否可見,它是傳遞給鏈接器的參數,可以使依賴的靜態庫的符號在動態符號表中不存在。同樣,也是只能刪除符號表項,實現體仍然會存在于產生的 so 文件中。CMake 項目的配置方式:
set(CMAKE_SHARED_LINKER_FLAGS "${CMAKE_SHARED_LINKER_FLAGS} -Wl,--exclude-libs,ALL")#使所有靜態庫中的符號都不被導出
set(CMAKE_SHARED_LINKER_FLAGS "${CMAKE_SHARED_LINKER_FLAGS} -Wl,--exclude-libs,libabc.a")#使 libabc.a 的符號都不被導出
ndk-build 項目的配置方式:
LOCAL_LDFLAGS += -Wl,--exclude-libs,ALL #使所有靜態庫中的符號都不被導出
LOCAL_LDFLAGS += -Wl,--exclude-libs,libabc.a #使 libabc.a 的符號都不被導出
使用 version script 控制符號可見性
version script 是傳遞給鏈接器的參數,用來指定動態庫導出哪些符號以及符號的版本。該參數會影響到上面“so 文件格式”一節中 .gnu.version 和 .gnu.version_d 的內容。我們現在只使用它的指定所有導出符號的功能(即符號版本名使用空字符串)。開啟 version script 需要先編寫一個文本文件,用來指定動態庫導出哪些符號。示例如下(只導出 usedFun 這一個函數):
{
global:usedFun;
local:*;
};
然后將上述文件的路徑傳遞給鏈接器即可(假定上述文件名為version_script.txt)。CMake 項目的配置方式:
set(CMAKE_SHARED_LINKER_FLAGS "${CMAKE_SHARED_LINKER_FLAGS} -Wl,--version-script=${CMAKE_CURRENT_SOURCE_DIR}/version_script.txt") #version_script.txt 與當前 CMakeLists.txt 同目錄
ndk-build 項目的配置方式:
LOCAL_LDFLAGS += -Wl,--version-script=${LOCAL_PATH}/version_script.txt #version_script.txt 與當前 Android.mk 同目錄
看上去,version script 是明確地指定需要保留的符號,如果通過 visibility 結合 attribute 的方式控制每個符號是否導出,也能達到 version script 的效果,但是 version script 方式有一些額外的好處:
- version script 方式可以控制編譯進 so 的靜態庫的符號是否導出,visibility 和 attribute 方式都無法做到這一點。
- visibility 結合 attribute 方式需要在源碼中標明每個需要導出的符號,對于導出符號較多的項目來說是很繁雜的。version script 把需要導出的符號統一地放到了一起,能夠直觀方便地查看和修改,對導出符號較多的項目也非常友好。
- version script 支持通配符,*? 代表0個或者多個字符,?? 代表單個字符。比如 my*; 就代表所有以 my 開頭的符號。有了通配符的支持,配置 version script 會更加方便。
- 還有非常特殊的一點,version script 方式可以刪除 __bss_start 這樣的一些符號(這是鏈接器默認加上的符號)。
綜上所述,version script 方式優于 visibility 結合 attribute 的方式。同時,使用了 version script 方式,就不需要使用 exclude libs 方式控制依賴的靜態庫中的符號是否導出了。
4.2 移除無用代碼
開啟 LTO
LTO 是 Link Time Optimization 的縮寫,即鏈接期優化。LTO 能夠在鏈接目標文件時檢測出 DeadCode 并刪除它們,從而減小編譯產物的體積。DeadCode 舉例:某個 if 條件永遠為假,那么 if 為真下的代碼塊就可以移除。進一步地,被移除代碼塊所調用的函數也可能因此而變為 DeadCode,它們又可以被移除。能夠在鏈接期做優化的原因是,在編譯期很多信息還不能確定,只有局部信息,無法執行一些優化。但是鏈接時大部分信息都確定了,相當于獲取了全局信息,所以可以進行一些優化。GCC 和 Clang 均支持 LTO。LTO 方式編譯的目標文件中存儲的不再是具體機器的指令,而是機器無關的中間表示(GCC 采用的是 GIMPLE 字節碼,Clang 采用的是 LLVM IR 比特碼)。CMake 項目的配置方式:
set(CMAKE_C_FLAGS "${CMAKE_C_FLAGS} -flto")
set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -flto")
set(CMAKE_SHARED_LINKER_FLAGS "${CMAKE_SHARED_LINKER_FLAGS} -O3 -flto")
ndk-build 項目的配置方式:
LOCAL_CFLAGS += -flto
LOCAL_LDFLAGS += -O3 -flto
使用 LTO 時需要注意幾點:
- 如果使用 Clang,編譯參數和鏈接參數中都要開啟 LTO,否則會出現無法識別文件格式的問題(NDK22 之前存在此問題)。使用 GCC 的話,只需要編譯參數中開啟 LTO 即可。
- 如果項目工程依賴了靜態庫,可以使用 LTO 方式重新編譯該靜態庫,那么編譯動態庫時,就能移除靜態庫中的 DeadCode,從而減小最終 so 的體積。
- 經過測試,如果使用 Clang,鏈接器需要開啟非 0 級別的優化,LTO 才能真正生效。經過實際測試(NDK 為 r16b),O1 優化效果較差,O2、O3 優化效果比較接近。
- 由于需要進行更多的分析計算,開啟 LTO 后,鏈接耗時會明顯增加。
開啟 GC sections
這是傳遞給鏈接器的參數,GC 即 Garbage Collection(垃圾回收),也就是對無用的 section 進行回收。注意,這里的 section 不是指最終 so 中的 section,而是作為鏈接器的輸入的目標文件中的 section。
簡要介紹一下目標文件,目標文件(擴展名 .o )也是 ELF 文件,所以也是由 section 組成的,只不過它只包含了相應源文件的內容:函數會放到 .text 樣式的 section 中,一些可讀寫變量會放到 .data 樣式的 section 中,等等。鏈接器會把所有輸入的目標文件的同類型的 section 進行合并,組裝出最終的 so 文件。
GC sections 參數通知鏈接器:僅保留動態符號(及 .init_array等)直接或者間接引用到的 section,移除其他無用 section。這樣就能減小最終 so 的體積。但開啟 GC sections 還需要考慮一個問題:編譯器默認會把所有函數放到同一個 section 中,把所有相同特點的數據放到同一個 section 中,如果同一個 section 中既有需要刪除的部分又有需要保留的部分,會使得整個 section 都要保留。
所以我們需要減小目標文件 section 的粒度,這需要借助另外兩個編譯參數 -fdata-sections 和 -ffunction-sections ,這兩個參數通知編譯器,將每個變量和函數分別放到各自獨立的 section 中,這樣就不會出現上述問題了。實際上 Android 編譯目標文件時會自動帶上 -fdata-sections 和 -ffunction-sections 參數,這里一并列出來,是為了突出它們的作用。CMake 項目的配置方式:
set(CMAKE_C_FLAGS "${CMAKE_C_FLAGS} -fdata-sections -ffunction-sections")
set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -fdata-sections -ffunction-sections")
set(CMAKE_SHARED_LINKER_FLAGS "${CMAKE_SHARED_LINKER_FLAGS} -Wl,--gc-sections")
ndk-build 項目的配置方式:
LOCAL_CFLAGS += -fdata-sections -ffunction-sections
LOCAL_LDFLAGS += -Wl,--gc-sections
4.3 優化指令長度
使用 Oz/Os 優化級別
編譯器根據輸入的 -Ox 參數決定編譯的優化級別,其中 O0 表示不開啟優化(這種情況主要是為了便于調試以及更快的編譯速度),從 O1 到 O3,優化程度越來越強。Clang 和 GCC 均提供了 Os 的優化級別,其與 O2 比較接近,但是優化了生成產物的體積。而 Clang 還提供了 Oz 優化級別,在 Os 的基礎上能進一步優化產物體積。綜上,編譯器是 Clang,可以開啟 Oz 優化。如果編譯器是 GCC,則只能開啟 Os 優化(注:NDK 從 r13 開始默認編譯器從 GCC 變為 Clang,r18 中正式移除了 GCC。GCC 不支持 Oz 是指 Android 最后使用的 GCC4.9 版本不支持 Oz 參數)。Oz/Os 優化相比于 O3 優化,優化了產物體積,性能上可能有一定損失,因此如果項目原本使用了 O3 優化,可根據實際測試結果以及對性能的要求,決定是否使用 Os/Oz 優化級別,如果項目原本未使用 O3 優化級別,可直接使用 Os/Oz 優化。CMake 項目的配置方式(如果使用 GCC,應將 Oz 改為 Os):
set(CMAKE_C_FLAGS "${CMAKE_C_FLAGS} -Oz")
set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -Oz")
ndk-build 項目的配置方式(如果使用 GCC,應將 Oz 改為 Os):
LOCAL_CFLAGS += -Oz
4.4 其他措施
禁用 C++ 的異常機制
如果項目中沒有使用 C++ 的異常機制(例如try...catch等),可以通過禁用 C++ 的異常機制,來減小 so 的體積。CMake 項目的配置方式:
set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -fno-exceptions")
ndk-build 默認會禁用 C++ 的異常機制,因此無需特意禁用(如果現有項目開啟了 C++ 的異常機制,說明確有需要,需仔細確認后才能禁用)。
禁用 C++ 的 RTTI 機制
如果項目中沒有使用 C++ 的 RTTI 機制(例如 typeid 和 dynamic_cast 等),可以通過禁用 C++ 的 RTTI ,來減小 so 的體積。CMake 項目的配置方式:
set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -fno-rtti")
ndk-build 默認會禁用 C++ 的 RTTI 機制,因此無需特意禁用(如果現有項目開啟了 C++ 的 RTTI 機制,說明確有需要,需仔細確認后才能禁用)。
合并 so
以上都是針對單個 so 的優化方案,對單個 so 進行優化后,還可以考慮對 so 進行合并,能夠進一步減小 so 的體積。具體來講,當安裝包內某些 so 僅被另外一個 so 動態依賴時,可以將這些 so 合并為一個 so。例如 liba.so 和 libb.so 僅被 libx.so 動態依賴,可以將這三個 so 合并為一個新的 libx.so。合并 so 有以下好處:
- 可以刪除部分動態符號表項,減小 so 總體積。具體來講,就是可以刪除 liba.so 和 libb.so 的動態符號表中的所有導出符號,以及 libx.so 的動態符號表中從 liba.so 和 libb.so 中導入的符號。
- 可以刪除部分 PLT 表項和 GOT 表項,減小 so 總體積。具體來講,就是可以刪除 libx.so 中與 liba.so、libb.so 相關的 PLT 表項和 GOT 表項。
- 可以減輕優化的工作量。如果沒有合并 so,對 liba.so 和 libb.so 做體積優化時需要確定 libx.so 依賴了它們的哪些符號,才能對它們進行優化,做了 so 合并后就不需要了。鏈接器會自動分析引用關系,保留使用到的所有符號的對應內容。
- 由于鏈接器對原 liba.so 和 libb.so 的導出符號擁有了更全的上下文信息,LTO 優化也能取得更好的效果。
可以在不修改項目源碼的情況下,在編譯層面實現 so 的合并。
提取多 so 共同依賴庫
上面“合并 so”是減小 so 總個數,而這里是增加 so 總個數。當多個 so 以靜態方式依賴了某個相同的庫時,可以考慮將此庫提取成一個單獨的 so,原來的幾個 so 改為動態依賴該 so。例如 liba.so 和 libb.so 都靜態依賴了 libx.a,可以優化為 liba.so 和 libb.so 均動態依賴 libx.so。提取多 so 共同依賴庫,可以對不同 so 內的相同代碼進行合并,從而減小總的 so 體積。這里典型的例子是 libc++ 庫:如果存在多個 so 都靜態依賴 libc++ 庫的情況,可以優化為這些 so 都動態依賴于 libc++_shared.so。
4.5 整合后的通用方案
通過上述分析,我們可以整合出普通項目均可使用的通用的優化方案,CMake 項目的配置方式(如果使用 GCC,應將 Oz 改為 Os):
set(CMAKE_C_FLAGS "${CMAKE_C_FLAGS} -Oz -flto -fdata-sections -ffunction-sections")
set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -Oz -flto -fdata-sections -ffunction-sections")
set(CMAKE_SHARED_LINKER_FLAGS "${CMAKE_SHARED_LINKER_FLAGS} -O3 -flto -Wl,--gc-sections -Wl,--version-script=${CMAKE_CURRENT_SOURCE_DIR}/version_script.txt") #version_script.txt 與當前 CMakeLists.txt 同目錄
ndk-build 項目的配置方式(如果使用 GCC,應將 Oz 改為 Os):
LOCAL_CFLAGS += -Oz -flto -fdata-sections -ffunction-sections
LOCAL_LDFLAGS += -O3 -flto -Wl,--gc-sections -Wl,--version-script=${LOCAL_PATH}/version_script.txt #version_script.txt 與當前 Android.mk 同目錄
其中 version_script.txt 較為通用的配置如下,可根據實際情況添加需要保留的導出符號:
{
global:JNI_OnLoad;JNI_OnUnload;Java_*;
local:*;
};
說明:version script 方式指定所有需要導出的符號,不再需要 visibility 方式、attribute 方式、static 關鍵字和 exclude libs 方式控制導出符號。是否禁用 C++ 的異常機制和 RTTI 機制、合并 so 以及提取多 so 共同依賴庫取決于具體項目,不具有通用性。至此,我們總結出一套可行的 so 體積優化方案。但在工程實踐中,還有一些問題要解決。
5. 工程實踐
支持多種構建工具
美團有眾多業務使用了 so,所使用的構建工具也不盡相同,除了上述常見的 CMake 和 ndk-build,也有項目在使用 Make、Automake、Ninja、GYP 和 GN 等各種構建工具。不同構建工具應用 so 優化方案的方式也不相同,尤其對大型工程而言,配置復雜性較高?;谝陨显?,每個業務自行配置 so 優化方案會消耗較多的人力成本,并且有配置無效的可能。為了降低配置成本、加快優化方案的推進速度、保證配置的有效性和正確性,我們在構建平臺上統一支持了 so 的優化(支持使用任意構建工具的項目)。業務只需進行簡單的配置即可開啟 so 的體積優化。
配置導出符號的注意事項
注意事項有以下兩點:
- 如果一個 so 的某些符號,被其他 so 通過 dlsym 方式使用,那么這些符號也應該保留在該 so 的導出符號中(否則會導致運行時異常)。
- 編寫 version_script.txt? 時需要注意 C++ 等語言對符號的修飾,不能直接把函數名填寫進去。符號修飾就是把一個函數的命名空間(如果有)、類名(如果有)、參數類型等都添加到最終的符號中,這也是 C++ 語言實現重載的基礎。有兩種方式可以把 C++ 的函數添加到導出符號中:第一種是查看未優化 so 的導出符號表,找到目標函數被修飾后的符號,然后填寫到 version_script.txt 中。例如有一個 MyClass 類:
class MyClass{
void start(int arg);
void stop();
};
要確定 start 函數真正的符號可以對未優化的 libexample.so 執行以下命令。因為 C++ 對符號修飾后,函數名是符號的一部分,所以可以通過 grep 加快查找:
圖4 查找 start 函數真正符號可以看到 start 函數真正的符號是 _ZN7MyClass5startEi。如果想導出該函數,version_script.txt 相應位置填入 _ZN7MyClass5startEi 即可。第二種方式是在 version_script.txt 中使用 extern 語法,如下所示:
{
global:
extern "C++" {
MyClass::start*;
"MyClass::stop()";
};
local:*;
};
上述配置可以導出 MyClass 的 start 和 stop 函數。其原理是,鏈接時鏈接器對每個符號進行 demangle(解構,即把修飾后的符號還原為可讀的表示),然后與 extern "C++" 中的條目進行匹配,如果能與任一條目匹配成功就保留該符號。
匹配的規則是:有雙引號的條目不能使用通配符,需要全字符串完全匹配才可以(例如 stop 條目,如果括號之間多一個空格就會匹配失敗)。對于沒有雙引號的條目能夠使用通配符(例如 start 條目)。
查看優化后 so 的導出符號
業務對 so 進行優化之后,需要查看最終的 so 文件中保留了哪些導出符號,驗證優化效果是否符合預期。在 Mac 和 Linux 下均可使用下述命令查看 so 保留了哪些導出符號:
nm -D --defined-only xxx.so
例如:
圖5 nm命令查看so文件的導出符號可以看出,libexample.so 的導出符號有兩個:JNI_OnLoad 和 Java_com_example_MainActivity_stringFromJNI。
解析崩潰堆棧
本文的優化方案會移除非必要導出的動態符號,那 so 如果發生崩潰的話是不是就無法解析崩潰堆棧了呢?答案是完全不會影響崩潰堆棧的解析結果。“so 可優化內容分析”一節已經提過,使用帶調試信息和符號表的 so 解析線上崩潰,是分析 so 崩潰的標準方式(這也是 Google 解析 so 崩潰的方式)。本文的優化方案并未修改調試信息和符號表,所以可以使用帶調試信息和符號表的 so 對崩潰堆棧進行完整的還原,解析出崩潰堆棧每個棧幀對應的源碼文件、行號和函數名等信息。業務編譯出 release 版的 so 后將相應的帶調試信息和符號表的 so 上傳到 crash 平臺即可。
6. 方案收益
優化 so 對安裝包體積和安裝后占用的本地存儲空間有直接收益,收益大小取決于原 so 冗余代碼數量和導出符號數量等具體情況,下面是部分 so 優化前后占用安裝包體積的對比:
下面是上述 so 優化前后占用本地存儲空間的對比:
7. 總結與規劃
對 so 體積進行優化不僅能夠減小安裝包體積,而且能獲得以下收益:
- 刪除了大量的非必要導出符號從而提升了 so 的安全性。
- 因為 .data.bss.text 等運行時占用內存的 section 減小了,所以也能減小應用運行時的內存占用。
- 如果優化過程中減少了 so 對外依賴的符號,還可以加快 so 的加載速度。
我們對后續工作做了如下的規劃:
- 提升編譯速度。因為使用 LTO、gc sections 等會增加編譯耗時,計劃調研 ThinLTO 等方案對編譯速度進行優化。
- 詳細展示保留各個函數/數據的原因。
- 進一步完善平臺優化 so 的能力。?