Android對so體積優化的探索與實踐

美團技術團隊發表於2022-06-06
減小應用安裝包的體積,對提升使用者體驗和下載轉化率都大有益處。本文將結合美團平臺的實踐經驗,分享 so 體積優化的思路、收益,以及工程實踐中的注意事項。

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 方式有一些額外的好處:

  1. version script 方式可以控制編譯進 so 的靜態庫的符號是否匯出,visibility 和 attribute 方式都無法做到這一點。
  2. visibility 結合 attribute 方式需要在原始碼中標明每個需要匯出的符號,對於匯出符號較多的專案來說是很繁雜的。version script 把需要匯出的符號統一地放到了一起,能夠直觀方便地檢視和修改,對匯出符號較多的專案也非常友好。
  3. version script 支援萬用字元,* 代表0個或者多個字元,? 代表單個字元。比如 my*; 就代表所有以 my 開頭的符號。有了萬用字元的支援,配置 version script 會更加方便。
  4. 還有非常特殊的一點,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 時需要注意幾點:

  1. 如果使用 Clang,編譯引數和連結引數中都要開啟 LTO,否則會出現無法識別檔案格式的問題(NDK22 之前存在此問題)。使用 GCC 的話,只需要編譯引數中開啟 LTO 即可。
  2. 如果專案工程依賴了靜態庫,可以使用 LTO 方式重新編譯該靜態庫,那麼編譯動態庫時,就能移除靜態庫中的 DeadCode,從而減小最終 so 的體積。
  3. 經過測試,如果使用 Clang,連結器需要開啟非 0 級別的優化,LTO 才能真正生效。經過實際測試(NDK 為 r16b),O1 優化效果較差,O2、O3 優化效果比較接近。
  4. 由於需要進行更多的分析計算,開啟 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 有以下好處:

  1. 可以刪除部分動態符號表項,減小 so 總體積。具體來講,就是可以刪除 liba.so 和 libb.so 的動態符號表中的所有匯出符號,以及 libx.so 的動態符號表中從 liba.so 和 libb.so 中匯入的符號。
  2. 可以刪除部分 PLT 表項和 GOT 表項,減小 so 總體積。具體來講,就是可以刪除 libx.so 中與 liba.so、libb.so 相關的 PLT 表項和 GOT 表項。
  3. 可以減輕優化的工作量。如果沒有合併 so,對 liba.so 和 libb.so 做體積優化時需要確定 libx.so 依賴了它們的哪些符號,才能對它們進行優化,做了 so 合併後就不需要了。連結器會自動分析引用關係,保留使用到的所有符號的對應內容。
  4. 由於連結器對原 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 的體積優化。

配置匯出符號的注意事項

注意事項有以下兩點:

  1. 如果一個 so 的某些符號,被其他 so 通過 dlsym 方式使用,那麼這些符號也應該保留在該 so 的匯出符號中(否則會導致執行時異常)。
  2. 編寫 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_OnLoadJava_com_example_MainActivity_stringFromJNI

解析崩潰堆疊

本文的優化方案會移除非必要匯出的動態符號,那 so 如果發生崩潰的話是不是就無法解析崩潰堆疊了呢?答案是完全不會影響崩潰堆疊的解析結果。

“so 可優化內容分析”一節已經提過,使用帶除錯資訊和符號表的 so 解析線上崩潰,是分析 so 崩潰的標準方式(這也是 Google 解析 so 崩潰的方式)。本文的優化方案並未修改除錯資訊和符號表,所以可以使用帶除錯資訊和符號表的 so 對崩潰堆疊進行完整的還原,解析出崩潰堆疊每個棧幀對應的原始碼檔案、行號和函式名等資訊。業務編譯出 release 版的 so 後將相應的帶除錯資訊和符號表的 so 上傳到 crash 平臺即可。

6. 方案收益

優化 so 對安裝包體積和安裝後佔用的本地儲存空間有直接收益,收益大小取決於原 so 冗餘程式碼數量和匯出符號數量等具體情況,下面是部分 so 優化前後佔用安裝包體積的對比:

so優化前大小優化後大小優化百分比
A 庫4.49 MB3.28 MB27.02%
B 庫995.82 KB728.38 KB26.86%
C 庫312.05 KB153.81 KB50.71%
D 庫505.57 KB321.75 KB36.36%
E 庫309.89 KB157.08 KB49.31%
F 庫88.59 KB62.93 KB28.97%

下面是上述 so 優化前後佔用本地儲存空間的對比:

so優化前大小優化後大小優化百分比
A 庫10.67 MB7.04 MB34.05%
B 庫2.35 MB1.61 MB31.46%
C 庫898.14 KB386.31 KB56.99%
D 庫1.30 MB771.47 KB41.88%
E 庫890.13 KB398.30 KB55.25%
F 庫230.30 KB146.06 KB36.58%

7. 總結與後續計劃

對 so 體積進行優化不僅能夠減小安裝包體積,而且能獲得以下收益:

  • 刪除了大量的非必要匯出符號從而提升了 so 的安全性。
  • 因為 .data .bss .text 等執行時佔用記憶體的 section 減小了,所以也能減小應用執行時的記憶體佔用。
  • 如果優化過程中減少了 so 對外依賴的符號,還可以加快 so 的載入速度。

我們對後續工作做了如下的規劃:

  • 提升編譯速度。因為使用 LTO、gc sections 等會增加編譯耗時,計劃調研 ThinLTO 等方案對編譯速度進行優化。
  • 詳細展示保留各個函式/資料的原因。
  • 進一步完善平臺優化 so 的能力。

8. 參考資料

  1. https://www.cs.cmu.edu/afs/cs/academic/class/15213-f00/docs/elf.pdf
  2. https://llvm.org/docs/LinkTimeOptimization.html
  3. https://gcc.gnu.org/onlinedocs/gccint/LTO-Overview.html
  4. https://sourceware.org/binutils/docs/ld/VERSION.html
  5. https://clang.llvm.org/docs
  6. https://gcc.gnu.org/onlinedocs/gcc

9. 本文作者

洪凱、常強,來自美團平臺/App技術部。

閱讀美團技術團隊更多技術文章合集

前端 | 演算法 | 後端 | 資料 | 安全 | 運維 | iOS | Android | 測試

| 在公眾號選單欄對話方塊回覆【2021年貨】、【2020年貨】、【2019年貨】、【2018年貨】、【2017年貨】等關鍵詞,可檢視美團技術團隊歷年技術文章合集。

| 本文系美團技術團隊出品,著作權歸屬美團。歡迎出於分享和交流等非商業目的轉載或使用本文內容,敬請註明“內容轉載自美團技術團隊”。本文未經許可,不得進行商業性轉載或者使用。任何商用行為,請傳送郵件至tech@meituan.com申請授權。

相關文章