成人免费xxxxx在线视频软件_久久精品久久久_亚洲国产精品久久久_天天色天天色_亚洲人成一区_欧美一级欧美三级在线观看

Android對so體積優(yōu)化的探索與實(shí)踐

原創(chuàng) 精選
移動(dòng)開發(fā) 新聞
本文將先從 so 文件格式講起,結(jié)合文件格式分析哪些內(nèi)容可以優(yōu)化,然后再具體講解每項(xiàng)優(yōu)化手段以及注意事項(xiàng),最后介紹相關(guān)的工程實(shí)踐經(jīng)驗(yàn)。希望能對從事包體積優(yōu)化的同學(xué)有所幫助或啟發(fā)。

作者:洪凱 常強(qiáng) 

1. 背景

應(yīng)用安裝包的體積影響著用戶的下載時(shí)長、安裝時(shí)長、磁盤占用空間等諸多方面,因此減小安裝包的體積對于提升用戶體驗(yàn)和下載轉(zhuǎn)化率都大有益處。Android 應(yīng)用安裝包其實(shí)是一個(gè) zip 文件,主要由 dex、assets、resource、so 等各類型文件壓縮而成。目前業(yè)內(nèi)常見的包體積優(yōu)化方案大體分為以下幾類:

針對 dex 的優(yōu)化,例如 Proguard、dex 的 DebugItem 刪除、字節(jié)碼優(yōu)化等;

針對 resource 的優(yōu)化,例如 AndResGuard、webp 優(yōu)化等;

針對 assets 的優(yōu)化,例如壓縮、動(dòng)態(tài)下發(fā)等;

針對 so 的優(yōu)化,同 assets,另外還有移除調(diào)試符號(hào)等。

隨著動(dòng)態(tài)化、端智能等技術(shù)的廣泛應(yīng)用,在采用上述優(yōu)化手段后, so 在安裝包體積中的比重依然很高,我們開始思索這部分體積是否能進(jìn)一步優(yōu)化。經(jīng)過一段時(shí)間的調(diào)研、分析和驗(yàn)證,我們逐漸摸索出一套可以將應(yīng)用安裝包中 so 體積進(jìn)一步減小 30%~60% 的方案。

該方案包含一系列純技術(shù)優(yōu)化手段,對業(yè)務(wù)侵入性低,通過簡單的配置,可以快速部署生效,目前美團(tuán) App 已在線上部署使用。為讓大家能知其然,也能知其所以然,本文將先從 so 文件格式講起,結(jié)合文件格式分析哪些內(nèi)容可以優(yōu)化。

2. so 文件格式分析

so 即動(dòng)態(tài)庫,本質(zhì)上是 ELF(Executable and Linkable Format)文件。可以從兩個(gè)維度查看 so 文件的內(nèi)部結(jié)構(gòu):鏈接視圖(Linking View)和執(zhí)行視圖(Execution View)。鏈接視圖將 so 主體看作多個(gè) section 的組合,該視圖體現(xiàn)的是 so 是如何組裝的,是編譯鏈接的視角。而執(zhí)行視圖將 so 主體看作多個(gè) segment 的組合,該視圖告訴動(dòng)態(tài)鏈接器如何加載和執(zhí)行該 so,是運(yùn)行時(shí)的視角。鑒于對 so 優(yōu)化更側(cè)重于編譯鏈接角度,并且通常一個(gè) segment 包含多個(gè) section(即鏈接視圖對 so 的分解粒度更小),因此我們這里只討論 so 的鏈接視圖。通過 readelf -S 命令可以查看一個(gè) so 文件的所有 section 列表,參考 ELF 文件格式說明,這里簡要介紹一下本文涉及的 section:

  • .text:存放的是編譯后的機(jī)器指令,C/C++代碼的大部分函數(shù)編譯后就存放在這里。這里只有機(jī)器指令,沒有字符串等信息。
  • .data:存放的是初始值不為零的一些可讀寫變量。
  • .bss:存放的是初始值為零或未初始化的一些可讀寫變量。該 section 僅指示運(yùn)行時(shí)需要的內(nèi)存大小,不會(huì)占用 so 文件的體積。
  • .rodata:存放的是一些只讀常量。
  • .dynsym:動(dòng)態(tài)符號(hào)表,給出了該 so 對外提供的符號(hào)(導(dǎo)出符號(hào))和依賴外部的符號(hào)(導(dǎo)入符號(hào))的信息。
  • .dynstr?:字符串池,不同字符串以 '\0' 分割,供 .dynsym 和其他部分使用。
  • .gnu.hash? 和.hash?:兩種類型的哈希表,用于快速查找 .dynsym 中的導(dǎo)出符號(hào)或全部符號(hào)。
  • .gnu.version、.gnu.version_d、.gnu.version_r?:這三個(gè) section 用于指定動(dòng)態(tài)符號(hào)表中每個(gè)符號(hào)的版本,其中.gnu.version? 是一個(gè)數(shù)組,其元素個(gè)數(shù)與動(dòng)態(tài)符號(hào)表中符號(hào)的個(gè)數(shù)相同,即數(shù)組每個(gè)元素與動(dòng)態(tài)符號(hào)表的每個(gè)符號(hào)是一一對應(yīng)的關(guān)系。數(shù)組每個(gè)元素的類型為 Elfxx_Half?,其意義是索引,指示每個(gè)符號(hào)的版本。.gnu.version_d? 描述了該 so 定義的所有符號(hào)的版本,供.gnu.version? 索引。.gnu.version_r? 描述了該 so 依賴的所有符號(hào)的版本,也供 .gnu.version 索引。因?yàn)椴煌姆?hào)可能具有相同的版本,所以采用這種索引結(jié)構(gòu),可以減小 so 文件的大小。

在進(jìn)行優(yōu)化之前,我們需要對這些 section 以及它們之間的關(guān)系有一個(gè)清晰的認(rèn)識(shí),下圖較直觀地展示了 so 中各個(gè) section 之間的關(guān)系(這里只繪制了本文涉及的 section):

圖片

圖1 so文件結(jié)構(gòu)示意圖

結(jié)合上圖,我們從另一個(gè)角度來理解 so 文件的結(jié)構(gòu):想象一下,我們把所有的函數(shù)實(shí)現(xiàn)體都放到.text 中,.text 中的指令會(huì)去讀取 .rodata 中的數(shù)據(jù),讀取或修改 .data 和 .bss 中的數(shù)據(jù)。看上去 so 中有這些內(nèi)容也足夠了。但是這些函數(shù)怎樣執(zhí)行呢?也就是說,只把這些函數(shù)和數(shù)據(jù)加載進(jìn)內(nèi)存是不夠的,這些函數(shù)只有真正去執(zhí)行,才能發(fā)揮作用。

我們知道想要執(zhí)行一個(gè)函數(shù),只要跳轉(zhuǎn)到它的地址就行了。那外界調(diào)用者(該 so 之外的模塊)怎樣知道它想要調(diào)用函數(shù)的地址呢?這里就涉及一個(gè)函數(shù) ID 的問題:外部調(diào)用者給出需要調(diào)用的函數(shù)的 ID,而動(dòng)態(tài)鏈接器(Linker)根據(jù)該 ID 查找目標(biāo)函數(shù)的地址并告知外部調(diào)用者。所以 so 文件還需要一個(gè)結(jié)構(gòu)去存儲(chǔ)“ID-地址”的映射關(guān)系,這個(gè)結(jié)構(gòu)就是動(dòng)態(tài)符號(hào)表的所有導(dǎo)出符號(hào)。

具體到動(dòng)態(tài)符號(hào)表的實(shí)現(xiàn),ID 的類型是“字符串”,可以說動(dòng)態(tài)符號(hào)表的所有導(dǎo)出符號(hào)構(gòu)成了一個(gè)“字符串-地址“的映射表。調(diào)用者獲取目標(biāo)函數(shù)的地址后,準(zhǔn)備好參數(shù)跳轉(zhuǎn)到該地址就可以執(zhí)行這個(gè)函數(shù)了。另一方面,當(dāng)前 so 可能也需要調(diào)用其他 so 中的函數(shù)(例如 libc.so 中的 read、write 等),動(dòng)態(tài)符號(hào)表的導(dǎo)入符號(hào)記錄了這些函數(shù)的信息,在 so 內(nèi)函數(shù)執(zhí)行之前動(dòng)態(tài)鏈接器會(huì)將目標(biāo)函數(shù)的地址填入到相應(yīng)位置,供該 so 使用。

所以動(dòng)態(tài)符號(hào)表是連接當(dāng)前 so 與外部環(huán)境的“橋梁”:導(dǎo)出符號(hào)供外部使用,導(dǎo)入符號(hào)聲明了該 so 需要使用的外部符號(hào)(注:實(shí)際上.dynsym中的符號(hào)還可以代表變量等其他類型,與函數(shù)類型類似,這里就不再贅述)。結(jié)合 so 文件結(jié)構(gòu),接下來我們開始分析 so 中有哪些內(nèi)容可以優(yōu)化。

3. so 可優(yōu)化內(nèi)容分析

在討論 so 可優(yōu)化內(nèi)容之前,我們先了解一下 Android 構(gòu)建工具(Android Gradle Plugin,下文簡稱 AGP)對 so 體積做的 strip 優(yōu)化(移除調(diào)試信息和符號(hào)表)。

AGP 編譯 so 時(shí),首先產(chǎn)生的是帶調(diào)試信息和符號(hào)表的 so(任務(wù)名為 externalNativeBuildRelease),之后對剛產(chǎn)生的帶調(diào)試信息和符號(hào)表的 so 進(jìn)行 strip,就得到了最終打包到 apk 或 aar 中的 so(任務(wù)名為 stripReleaseDebugSymbols)。

strip 優(yōu)化的作用就是刪除輸入 so 中的調(diào)試信息和符號(hào)表。這里說的符號(hào)表與上文中的“動(dòng)態(tài)符號(hào)表”不同,符號(hào)表所在 section 名通常為 .symtab,它通常包含了動(dòng)態(tài)符號(hào)表中的全部符號(hào),并且額外還有很多符號(hào)。

調(diào)試信息顧名思義就是用于調(diào)試該 so 的信息,主要是各種名字以 .debug_ 開頭的 section,通過這些 section 可以建立 so 每條指令與源碼文件的映射關(guān)系(也就是能夠?qū)?so 中每條指令找到其對應(yīng)的源碼文件名、文件行號(hào)等信息)。之所以叫 strip 優(yōu)化,是因?yàn)槠鋵?shí)際調(diào)用的是 NDK 提供的的 strip 命令(所用參數(shù)為--strip-unneeded)。

注:為什么 AGP 要先編譯出帶調(diào)試信息和符號(hào)表的 so,而不直接編譯出最終的 so 呢(通過添加-s參數(shù)是可以做到直接編譯出沒有調(diào)試信息和符號(hào)表的 so 的)?原因就在于需要使用帶調(diào)試信息和符號(hào)表的 so 對崩潰調(diào)用棧進(jìn)行還原。刪除了調(diào)試信息和符號(hào)表的 so 完全可以正常運(yùn)行,但是當(dāng)它發(fā)生崩潰時(shí),只能保證獲取到崩潰調(diào)用棧的每個(gè)棧幀的相應(yīng)指令在 so 中的位置,不一定能獲取到符號(hào)。但是排查崩潰問題時(shí),我們希望得知 so 崩潰在源碼的哪個(gè)位置。帶調(diào)試信息和符號(hào)表的 so 可以將崩潰調(diào)用棧的每個(gè)棧幀還原成其對應(yīng)的源碼文件名、文件行號(hào)、函數(shù)名等,大大方便了崩潰問題的排查。所以說,雖然帶調(diào)試信息和符號(hào)表的 so 不會(huì)打包到最終的 apk 中,但它對排查問題來說非常重要。

AGP 通過開啟 strip 優(yōu)化,可以大幅縮減 so 的體積,甚至可以達(dá)到十倍以上。以一個(gè)測試 so 為例,其最終 so 大小為14 KB,但是對應(yīng)的帶調(diào)試信息和符號(hào)表的 so 大小為 136 KB。不過在使用中,我們需要注意的是,如果 AGP 找不到對應(yīng)的 strip 命令,就會(huì)把帶調(diào)試信息和符號(hào)表的 so 直接打包到 apk 或 aar 中,并不會(huì)打包失敗。例如缺少 armeabi 架構(gòu)對應(yīng)的 strip 命令時(shí)提示信息如下:

Unable to strip library 'XXX.so' due to missing strip tool for ABI 'ARMEABI'. Packaging it as is.

除了上述 Android 構(gòu)建工具默認(rèn)為 so 體積做的優(yōu)化,我們還能做哪些優(yōu)化呢?首先明確我們優(yōu)化的原則:

  • 對于必須保留的內(nèi)容考慮進(jìn)行縮減,減小體積占用;
  • 對于無需保留的內(nèi)容直接刪除。

基于以上原則,可以從以下三個(gè)方面對 so 繼續(xù)進(jìn)行深入優(yōu)化:

  • 精簡動(dòng)態(tài)符號(hào)表:上文已經(jīng)提到,動(dòng)態(tài)符號(hào)表是 so 與外部進(jìn)行連接的“橋梁”,其中的導(dǎo)出表相當(dāng)于是 so 對外暴露的接口。哪些接口是必須對外暴露的呢?在 Android 中,大部分 so 是用來實(shí)現(xiàn) Java 的 native 方法的,對于這種 so,只要讓應(yīng)用運(yùn)行時(shí)能夠獲取到 Java native 方法對應(yīng)的函數(shù)地址即可。要實(shí)現(xiàn)這個(gè)目標(biāo),有兩種方法:一種是使用 RegisterNatives 動(dòng)態(tài)注冊 Java native 方法,一種是按照 JNI 規(guī)范定義 java_***? 樣式的函數(shù)并導(dǎo)出其符號(hào)。RegisterNatives 方式可以提前檢測到方法簽名不匹配的問題,并且可以減少導(dǎo)出符號(hào)的數(shù)量,這也是 Google 推薦的做法。所以在最優(yōu)情況下只需導(dǎo)出 JNI_OnLoad?(在其中使用 RegisterNatives 對 Java native 方法進(jìn)行動(dòng)態(tài)注冊)和 JNI_OnUnload?(可以做一些清理工作)這兩個(gè)符號(hào)即可。如果不希望改寫項(xiàng)目代碼,也可以再導(dǎo)出 java_***? 樣式的符號(hào)。除了上述類型的 so,剩余的 so 通常是被應(yīng)用的其他 so 動(dòng)態(tài)依賴的,對于這類 so,需要確定所有動(dòng)態(tài)依賴它的 so 依賴了它的哪些符號(hào),僅保留這些被依賴的符號(hào)即可。另外,這里應(yīng)區(qū)分符號(hào)表項(xiàng)與實(shí)現(xiàn)體,符號(hào)表項(xiàng)是動(dòng)態(tài)符號(hào)表中相應(yīng)的 Elfxx_Sym? 項(xiàng)(見上圖),實(shí)現(xiàn)體是其在 .text、.data?、 .bss、.rodata? 等或其他部分的實(shí)體。刪除了符號(hào)表項(xiàng),實(shí)現(xiàn)體不一定要被刪除。結(jié)合上文 so 文件結(jié)構(gòu)示意圖,可以預(yù)估出刪除一個(gè)符號(hào)表項(xiàng)后 so 減小的體積為:符號(hào)名字符串長度+ 1 + Elfxx_Sym? + Elfxx_Half? + Elfxx_Word 。
  • 移除無用代碼:在實(shí)際的項(xiàng)目中,有一些代碼在 Release 版中永遠(yuǎn)不會(huì)被使用到(例如歷史遺留代碼、用于測試的代碼等),這些代碼被稱為 DeadCode。而根據(jù)上文分析,只有動(dòng)態(tài)符號(hào)表的導(dǎo)出符號(hào)直接或間接引用到的所有代碼才需要保留,其他剩余的所有代碼都是 DeadCode,都是可以刪除的(注:事實(shí)上.init_array等特殊 section 涉及的代碼也要保留)。刪除無用代碼的潛在收益較大。
  • 優(yōu)化指令長度:實(shí)現(xiàn)某個(gè)功能的指令并不是固定的,編譯器有可能能用更少的指令完成相同的功能,從而實(shí)現(xiàn)優(yōu)化。由于指令是 so 的主要組成部分,因此優(yōu)化這一部分的潛在收益也比較大。

so 可優(yōu)化內(nèi)容如下圖所示(可刪除部分用紅色背景標(biāo)出,可優(yōu)化部分是.text?),其中 funC、value2、value3、value6 由于分別被需保留部分使用,所以需要保留其實(shí)現(xiàn)體,只能刪除其符號(hào)表項(xiàng)。funD、value1、value4、value5 可刪除符號(hào)表項(xiàng)及其實(shí)現(xiàn)體(注:因?yàn)?value4 的實(shí)現(xiàn)體在.bss?中,而.bss?實(shí)際不占用 so 的體積,所以刪除 value4 的實(shí)現(xiàn)體不會(huì)減小 so 的體積)。圖片

圖2 so可優(yōu)化部分

在確定了 so 中可以優(yōu)化的內(nèi)容后,我們還需要考慮優(yōu)化時(shí)機(jī)的問題:是直接修改 so 文件,還是控制其生成過程?考慮到直接修改 so 文件的風(fēng)險(xiǎn)與難度較大,控制 so 的生成過程顯然更穩(wěn)妥。為了控制 so 的生成過程,我們先簡要介紹一下 so 的生成過程:

圖片

圖3 so文件的生成過程如上圖所示,so 的生成過程可以分為四個(gè)階段:

  • 預(yù)處理:將 include 頭文件處擴(kuò)展為實(shí)際文件內(nèi)容并進(jìn)行宏定義替換。
  • 編譯:將預(yù)處理后的文件編譯成匯編代碼。
  • 匯編:將匯編代碼匯編成目標(biāo)文件,目標(biāo)文件中包含機(jī)器指令(大部分情況下是機(jī)器指令,見下文 LTO 一節(jié))和數(shù)據(jù)以及其他必要信息。
  • 鏈接:將輸入的所有目標(biāo)文件以及靜態(tài)庫(.a 文件)鏈接成 so 文件。

可以看出,預(yù)處理和匯編階段對特定輸入產(chǎn)生的輸出基本是固定的,優(yōu)化空間較小。所以我們的優(yōu)化方案主要是針對編譯和鏈接階段進(jìn)行優(yōu)化。

4. 優(yōu)化方案介紹

我們對所有能控制最終 so 體積的方案都進(jìn)行調(diào)研,并驗(yàn)證了其效果,最后總結(jié)出較為通用的可行方案。

4.1 精簡動(dòng)態(tài)符號(hào)表

使用 visibility 和 attribute 控制符號(hào)可見性

可以通過給編譯器傳遞 -fvisibility=VALUE 控制全局的符號(hào)可見性,VALUE 常取值為 default 和 hidden:

  • default:除非對變量或函數(shù)特別指定符號(hào)可見性,所有符號(hào)都在動(dòng)態(tài)符號(hào)表中,這也是不使用 -fvisibility 時(shí)的默認(rèn)值。
  • hidden:除非對變量或函數(shù)特別指定符號(hào)可見性,所有符號(hào)在動(dòng)態(tài)符號(hào)表中都不可見。

CMake 項(xiàng)目的配置方式:

set(CMAKE_C_FLAGS "${CMAKE_C_FLAGS} -fvisibility=hidden")
set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -fvisibility=hidden")

ndk-build 項(xiàng)目的配置方式:

LOCAL_CFLAGS += -fvisibility=hidden

另一方面,針對單個(gè)變量或函數(shù),可以通過 attribute 方式指定其符號(hào)可見性,示例如下:

__attribute__((visibility("hidden")))
int hiddenInt=3;

其常用值也是 default 和 hidden,與 visibility 方式意義類似,這里不再贅述。attribute 方式指定的符號(hào)可見性的優(yōu)先級(jí),高于 visibility 方式指定的可見性,相當(dāng)于 visibility 是全局符號(hào)可見性開關(guān),attribute 方式是針對單個(gè)符號(hào)的可見性開關(guān)。這兩種方式結(jié)合就能控制源碼中每個(gè)符號(hào)的可見性。需要注意的是上面這兩種方式,只能控制變量或函數(shù)是否存在于動(dòng)態(tài)符號(hào)表中(即是否刪除其動(dòng)態(tài)符號(hào)表項(xiàng)),而不會(huì)刪除其實(shí)現(xiàn)體。

使用 static 關(guān)鍵字控制符號(hào)可見性

在C/C++語言中,static 關(guān)鍵字在不同場景下有不同意義,當(dāng)使用 static 表示“該函數(shù)或變量僅在本文件可見”時(shí),那么這個(gè)函數(shù)或變量就不會(huì)出現(xiàn)在動(dòng)態(tài)符號(hào)表中,但只會(huì)刪除其動(dòng)態(tài)符號(hào)表項(xiàng),而不會(huì)刪除其實(shí)現(xiàn)體。static 關(guān)鍵字相當(dāng)于是增強(qiáng)的 hidden(因?yàn)?static 聲明的函數(shù)或變量編譯時(shí)只對當(dāng)前文件可見,而 hidden 聲明的函數(shù)或變量只是在動(dòng)態(tài)符號(hào)表中不存在,在編譯期間對其他文件還是可見的)。在項(xiàng)目開發(fā)中,使用 static 關(guān)鍵字聲明一個(gè)函數(shù)或變量“僅在本文件可見”是很好的習(xí)慣,但是不建議使用 static 關(guān)鍵字控制符號(hào)可見性:無法使用 static 關(guān)鍵字控制一個(gè)多文件可見的函數(shù)或變量的符號(hào)可見性。

使用 exclude libs 移除靜態(tài)庫中的符號(hào)

上述 visibility 方式、attribute 方式和 static 關(guān)鍵字,都是控制項(xiàng)目源碼中符號(hào)的可見性,而無法控制依賴的靜態(tài)庫中的符號(hào)在最終 so 中是否存在。exclude libs 就是用來控制依賴的靜態(tài)庫中的符號(hào)是否可見,它是傳遞給鏈接器的參數(shù),可以使依賴的靜態(tài)庫的符號(hào)在動(dòng)態(tài)符號(hào)表中不存在。同樣,也是只能刪除符號(hào)表項(xiàng),實(shí)現(xiàn)體仍然會(huì)存在于產(chǎn)生的 so 文件中。CMake 項(xiàng)目的配置方式:

set(CMAKE_SHARED_LINKER_FLAGS "${CMAKE_SHARED_LINKER_FLAGS} -Wl,--exclude-libs,ALL")#使所有靜態(tài)庫中的符號(hào)都不被導(dǎo)出
set(CMAKE_SHARED_LINKER_FLAGS "${CMAKE_SHARED_LINKER_FLAGS} -Wl,--exclude-libs,libabc.a")#使 libabc.a 的符號(hào)都不被導(dǎo)出

ndk-build 項(xiàng)目的配置方式:

LOCAL_LDFLAGS += -Wl,--exclude-libs,ALL #使所有靜態(tài)庫中的符號(hào)都不被導(dǎo)出
LOCAL_LDFLAGS += -Wl,--exclude-libs,libabc.a #使 libabc.a 的符號(hào)都不被導(dǎo)出

使用 version script 控制符號(hào)可見性

version script 是傳遞給鏈接器的參數(shù),用來指定動(dòng)態(tài)庫導(dǎo)出哪些符號(hào)以及符號(hào)的版本。該參數(shù)會(huì)影響到上面“so 文件格式”一節(jié)中 .gnu.version 和 .gnu.version_d 的內(nèi)容。我們現(xiàn)在只使用它的指定所有導(dǎo)出符號(hào)的功能(即符號(hào)版本名使用空字符串)。開啟 version script 需要先編寫一個(gè)文本文件,用來指定動(dòng)態(tài)庫導(dǎo)出哪些符號(hào)。示例如下(只導(dǎo)出 usedFun 這一個(gè)函數(shù)):

{
global:usedFun;
local:*;
};

然后將上述文件的路徑傳遞給鏈接器即可(假定上述文件名為version_script.txt)。CMake 項(xiàng)目的配置方式:

set(CMAKE_SHARED_LINKER_FLAGS "${CMAKE_SHARED_LINKER_FLAGS} -Wl,--version-script=${CMAKE_CURRENT_SOURCE_DIR}/version_script.txt") #version_script.txt 與當(dāng)前 CMakeLists.txt 同目錄

ndk-build 項(xiàng)目的配置方式:

LOCAL_LDFLAGS += -Wl,--version-script=${LOCAL_PATH}/version_script.txt #version_script.txt 與當(dāng)前 Android.mk 同目錄

看上去,version script 是明確地指定需要保留的符號(hào),如果通過 visibility 結(jié)合 attribute 的方式控制每個(gè)符號(hào)是否導(dǎo)出,也能達(dá)到 version script 的效果,但是 version script 方式有一些額外的好處:

  1. version script 方式可以控制編譯進(jìn) so 的靜態(tài)庫的符號(hào)是否導(dǎo)出,visibility 和 attribute 方式都無法做到這一點(diǎn)。
  2. visibility 結(jié)合 attribute 方式需要在源碼中標(biāo)明每個(gè)需要導(dǎo)出的符號(hào),對于導(dǎo)出符號(hào)較多的項(xiàng)目來說是很繁雜的。version script 把需要導(dǎo)出的符號(hào)統(tǒng)一地放到了一起,能夠直觀方便地查看和修改,對導(dǎo)出符號(hào)較多的項(xiàng)目也非常友好。
  3. version script 支持通配符,*? 代表0個(gè)或者多個(gè)字符,?? 代表單個(gè)字符。比如 my*; 就代表所有以 my 開頭的符號(hào)。有了通配符的支持,配置 version script 會(huì)更加方便。
  4. 還有非常特殊的一點(diǎn),version script 方式可以刪除 __bss_start 這樣的一些符號(hào)(這是鏈接器默認(rèn)加上的符號(hào))。

綜上所述,version script 方式優(yōu)于 visibility 結(jié)合 attribute 的方式。同時(shí),使用了 version script 方式,就不需要使用 exclude libs 方式控制依賴的靜態(tài)庫中的符號(hào)是否導(dǎo)出了。

4.2 移除無用代碼

開啟 LTO

LTO 是 Link Time Optimization 的縮寫,即鏈接期優(yōu)化。LTO 能夠在鏈接目標(biāo)文件時(shí)檢測出 DeadCode 并刪除它們,從而減小編譯產(chǎn)物的體積。DeadCode 舉例:某個(gè) if 條件永遠(yuǎn)為假,那么 if 為真下的代碼塊就可以移除。進(jìn)一步地,被移除代碼塊所調(diào)用的函數(shù)也可能因此而變?yōu)?DeadCode,它們又可以被移除。能夠在鏈接期做優(yōu)化的原因是,在編譯期很多信息還不能確定,只有局部信息,無法執(zhí)行一些優(yōu)化。但是鏈接時(shí)大部分信息都確定了,相當(dāng)于獲取了全局信息,所以可以進(jìn)行一些優(yōu)化。GCC 和 Clang 均支持 LTO。LTO 方式編譯的目標(biāo)文件中存儲(chǔ)的不再是具體機(jī)器的指令,而是機(jī)器無關(guān)的中間表示(GCC 采用的是 GIMPLE 字節(jié)碼,Clang 采用的是 LLVM IR 比特碼)。CMake 項(xiàng)目的配置方式:

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 項(xiàng)目的配置方式:

LOCAL_CFLAGS += -flto
LOCAL_LDFLAGS += -O3 -flto

使用 LTO 時(shí)需要注意幾點(diǎn):

  1. 如果使用 Clang,編譯參數(shù)和鏈接參數(shù)中都要開啟 LTO,否則會(huì)出現(xiàn)無法識(shí)別文件格式的問題(NDK22 之前存在此問題)。使用 GCC 的話,只需要編譯參數(shù)中開啟 LTO 即可。
  2. 如果項(xiàng)目工程依賴了靜態(tài)庫,可以使用 LTO 方式重新編譯該靜態(tài)庫,那么編譯動(dòng)態(tài)庫時(shí),就能移除靜態(tài)庫中的 DeadCode,從而減小最終 so 的體積。
  3. 經(jīng)過測試,如果使用 Clang,鏈接器需要開啟非 0 級(jí)別的優(yōu)化,LTO 才能真正生效。經(jīng)過實(shí)際測試(NDK 為 r16b),O1 優(yōu)化效果較差,O2、O3 優(yōu)化效果比較接近。
  4. 由于需要進(jìn)行更多的分析計(jì)算,開啟 LTO 后,鏈接耗時(shí)會(huì)明顯增加。

開啟 GC sections

這是傳遞給鏈接器的參數(shù),GC 即 Garbage Collection(垃圾回收),也就是對無用的 section 進(jìn)行回收。注意,這里的 section 不是指最終 so 中的 section,而是作為鏈接器的輸入的目標(biāo)文件中的 section。

簡要介紹一下目標(biāo)文件,目標(biāo)文件(擴(kuò)展名 .o )也是 ELF 文件,所以也是由 section 組成的,只不過它只包含了相應(yīng)源文件的內(nèi)容:函數(shù)會(huì)放到 .text 樣式的 section 中,一些可讀寫變量會(huì)放到 .data  樣式的 section 中,等等。鏈接器會(huì)把所有輸入的目標(biāo)文件的同類型的 section 進(jìn)行合并,組裝出最終的 so 文件。

GC sections 參數(shù)通知鏈接器:僅保留動(dòng)態(tài)符號(hào)(及 .init_array等)直接或者間接引用到的 section,移除其他無用 section。這樣就能減小最終 so 的體積。但開啟 GC sections 還需要考慮一個(gè)問題:編譯器默認(rèn)會(huì)把所有函數(shù)放到同一個(gè) section 中,把所有相同特點(diǎn)的數(shù)據(jù)放到同一個(gè) section 中,如果同一個(gè) section 中既有需要?jiǎng)h除的部分又有需要保留的部分,會(huì)使得整個(gè) section 都要保留。

所以我們需要減小目標(biāo)文件 section 的粒度,這需要借助另外兩個(gè)編譯參數(shù) -fdata-sections 和 -ffunction-sections ,這兩個(gè)參數(shù)通知編譯器,將每個(gè)變量和函數(shù)分別放到各自獨(dú)立的 section 中,這樣就不會(huì)出現(xiàn)上述問題了。實(shí)際上 Android 編譯目標(biāo)文件時(shí)會(huì)自動(dòng)帶上 -fdata-sections 和 -ffunction-sections 參數(shù),這里一并列出來,是為了突出它們的作用。CMake 項(xiàng)目的配置方式:

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 項(xiàng)目的配置方式:

LOCAL_CFLAGS += -fdata-sections -ffunction-sections
LOCAL_LDFLAGS += -Wl,--gc-sections

4.3 優(yōu)化指令長度

使用 Oz/Os 優(yōu)化級(jí)別

編譯器根據(jù)輸入的 -Ox 參數(shù)決定編譯的優(yōu)化級(jí)別,其中 O0 表示不開啟優(yōu)化(這種情況主要是為了便于調(diào)試以及更快的編譯速度),從 O1 到 O3,優(yōu)化程度越來越強(qiáng)。Clang 和 GCC 均提供了 Os 的優(yōu)化級(jí)別,其與 O2 比較接近,但是優(yōu)化了生成產(chǎn)物的體積。而 Clang 還提供了 Oz 優(yōu)化級(jí)別,在 Os 的基礎(chǔ)上能進(jìn)一步優(yōu)化產(chǎn)物體積。綜上,編譯器是 Clang,可以開啟 Oz 優(yōu)化。如果編譯器是 GCC,則只能開啟 Os 優(yōu)化(注:NDK 從 r13 開始默認(rèn)編譯器從 GCC 變?yōu)?Clang,r18 中正式移除了 GCC。GCC 不支持 Oz 是指 Android 最后使用的 GCC4.9 版本不支持 Oz 參數(shù))。Oz/Os 優(yōu)化相比于 O3 優(yōu)化,優(yōu)化了產(chǎn)物體積,性能上可能有一定損失,因此如果項(xiàng)目原本使用了 O3 優(yōu)化,可根據(jù)實(shí)際測試結(jié)果以及對性能的要求,決定是否使用 Os/Oz 優(yōu)化級(jí)別,如果項(xiàng)目原本未使用 O3 優(yōu)化級(jí)別,可直接使用 Os/Oz 優(yōu)化。CMake 項(xiàng)目的配置方式(如果使用 GCC,應(yīng)將 Oz 改為 Os):

set(CMAKE_C_FLAGS "${CMAKE_C_FLAGS} -Oz")
set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -Oz")

ndk-build 項(xiàng)目的配置方式(如果使用 GCC,應(yīng)將 Oz 改為 Os):

LOCAL_CFLAGS += -Oz

4.4 其他措施

禁用 C++ 的異常機(jī)制

如果項(xiàng)目中沒有使用 C++ 的異常機(jī)制(例如try...catch等),可以通過禁用 C++ 的異常機(jī)制,來減小 so 的體積。CMake 項(xiàng)目的配置方式:

set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -fno-exceptions")

ndk-build 默認(rèn)會(huì)禁用 C++ 的異常機(jī)制,因此無需特意禁用(如果現(xiàn)有項(xiàng)目開啟了 C++ 的異常機(jī)制,說明確有需要,需仔細(xì)確認(rèn)后才能禁用)。

禁用 C++ 的 RTTI 機(jī)制

如果項(xiàng)目中沒有使用 C++ 的 RTTI 機(jī)制(例如 typeid 和 dynamic_cast 等),可以通過禁用 C++ 的 RTTI ,來減小 so 的體積。CMake 項(xiàng)目的配置方式:

set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -fno-rtti")

ndk-build 默認(rèn)會(huì)禁用 C++ 的 RTTI 機(jī)制,因此無需特意禁用(如果現(xiàn)有項(xiàng)目開啟了 C++ 的 RTTI 機(jī)制,說明確有需要,需仔細(xì)確認(rèn)后才能禁用)。

合并 so

以上都是針對單個(gè) so 的優(yōu)化方案,對單個(gè) so 進(jìn)行優(yōu)化后,還可以考慮對 so 進(jìn)行合并,能夠進(jìn)一步減小 so 的體積。具體來講,當(dāng)安裝包內(nèi)某些 so 僅被另外一個(gè) so 動(dòng)態(tài)依賴時(shí),可以將這些 so 合并為一個(gè) so。例如 liba.so 和 libb.so 僅被 libx.so 動(dòng)態(tài)依賴,可以將這三個(gè) so 合并為一個(gè)新的 libx.so。合并 so 有以下好處:

  1. 可以刪除部分動(dòng)態(tài)符號(hào)表項(xiàng),減小 so 總體積。具體來講,就是可以刪除 liba.so 和 libb.so 的動(dòng)態(tài)符號(hào)表中的所有導(dǎo)出符號(hào),以及 libx.so 的動(dòng)態(tài)符號(hào)表中從 liba.so 和 libb.so 中導(dǎo)入的符號(hào)。
  2. 可以刪除部分 PLT 表項(xiàng)和 GOT 表項(xiàng),減小 so 總體積。具體來講,就是可以刪除 libx.so 中與 liba.so、libb.so 相關(guān)的 PLT 表項(xiàng)和 GOT 表項(xiàng)。
  3. 可以減輕優(yōu)化的工作量。如果沒有合并 so,對 liba.so 和 libb.so 做體積優(yōu)化時(shí)需要確定 libx.so 依賴了它們的哪些符號(hào),才能對它們進(jìn)行優(yōu)化,做了 so 合并后就不需要了。鏈接器會(huì)自動(dòng)分析引用關(guān)系,保留使用到的所有符號(hào)的對應(yīng)內(nèi)容。
  4. 由于鏈接器對原 liba.so 和 libb.so 的導(dǎo)出符號(hào)擁有了更全的上下文信息,LTO 優(yōu)化也能取得更好的效果。

可以在不修改項(xiàng)目源碼的情況下,在編譯層面實(shí)現(xiàn) so 的合并。

提取多 so 共同依賴庫

上面“合并 so”是減小 so 總個(gè)數(shù),而這里是增加 so 總個(gè)數(shù)。當(dāng)多個(gè) so 以靜態(tài)方式依賴了某個(gè)相同的庫時(shí),可以考慮將此庫提取成一個(gè)單獨(dú)的 so,原來的幾個(gè) so 改為動(dòng)態(tài)依賴該 so。例如 liba.so 和 libb.so 都靜態(tài)依賴了 libx.a,可以優(yōu)化為 liba.so 和 libb.so 均動(dòng)態(tài)依賴 libx.so。提取多 so 共同依賴庫,可以對不同 so 內(nèi)的相同代碼進(jìn)行合并,從而減小總的 so 體積。這里典型的例子是 libc++ 庫:如果存在多個(gè) so 都靜態(tài)依賴 libc++ 庫的情況,可以優(yōu)化為這些 so 都動(dòng)態(tài)依賴于 libc++_shared.so。

4.5 整合后的通用方案

通過上述分析,我們可以整合出普通項(xiàng)目均可使用的通用的優(yōu)化方案,CMake 項(xiàng)目的配置方式(如果使用 GCC,應(yīng)將 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 與當(dāng)前 CMakeLists.txt 同目錄

ndk-build 項(xiàng)目的配置方式(如果使用 GCC,應(yīng)將 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 與當(dāng)前 Android.mk 同目錄

其中 version_script.txt 較為通用的配置如下,可根據(jù)實(shí)際情況添加需要保留的導(dǎo)出符號(hào):

{
global:JNI_OnLoad;JNI_OnUnload;Java_*;
local:*;
};

說明:version script 方式指定所有需要導(dǎo)出的符號(hào),不再需要 visibility 方式、attribute 方式、static 關(guān)鍵字和 exclude libs 方式控制導(dǎo)出符號(hào)。是否禁用 C++ 的異常機(jī)制和 RTTI 機(jī)制、合并 so 以及提取多 so 共同依賴庫取決于具體項(xiàng)目,不具有通用性。至此,我們總結(jié)出一套可行的 so 體積優(yōu)化方案。但在工程實(shí)踐中,還有一些問題要解決。

5. 工程實(shí)踐

支持多種構(gòu)建工具

美團(tuán)有眾多業(yè)務(wù)使用了 so,所使用的構(gòu)建工具也不盡相同,除了上述常見的 CMake 和 ndk-build,也有項(xiàng)目在使用 Make、Automake、Ninja、GYP 和 GN 等各種構(gòu)建工具。不同構(gòu)建工具應(yīng)用 so 優(yōu)化方案的方式也不相同,尤其對大型工程而言,配置復(fù)雜性較高。基于以上原因,每個(gè)業(yè)務(wù)自行配置 so 優(yōu)化方案會(huì)消耗較多的人力成本,并且有配置無效的可能。為了降低配置成本、加快優(yōu)化方案的推進(jìn)速度、保證配置的有效性和正確性,我們在構(gòu)建平臺(tái)上統(tǒng)一支持了 so 的優(yōu)化(支持使用任意構(gòu)建工具的項(xiàng)目)。業(yè)務(wù)只需進(jìn)行簡單的配置即可開啟 so 的體積優(yōu)化。

配置導(dǎo)出符號(hào)的注意事項(xiàng)

注意事項(xiàng)有以下兩點(diǎn):

  1. 如果一個(gè) so 的某些符號(hào),被其他 so 通過 dlsym 方式使用,那么這些符號(hào)也應(yīng)該保留在該 so 的導(dǎo)出符號(hào)中(否則會(huì)導(dǎo)致運(yùn)行時(shí)異常)。
  2. 編寫 version_script.txt? 時(shí)需要注意 C++ 等語言對符號(hào)的修飾,不能直接把函數(shù)名填寫進(jìn)去。符號(hào)修飾就是把一個(gè)函數(shù)的命名空間(如果有)、類名(如果有)、參數(shù)類型等都添加到最終的符號(hào)中,這也是 C++ 語言實(shí)現(xiàn)重載的基礎(chǔ)。有兩種方式可以把 C++ 的函數(shù)添加到導(dǎo)出符號(hào)中:第一種是查看未優(yōu)化 so 的導(dǎo)出符號(hào)表,找到目標(biāo)函數(shù)被修飾后的符號(hào),然后填寫到 version_script.txt 中。例如有一個(gè) MyClass 類:
class MyClass{
void start(int arg);
void stop();
};

要確定 start 函數(shù)真正的符號(hào)可以對未優(yōu)化的 libexample.so 執(zhí)行以下命令。因?yàn)?C++ 對符號(hào)修飾后,函數(shù)名是符號(hào)的一部分,所以可以通過 grep 加快查找:

圖片

圖4 查找 start 函數(shù)真正符號(hào)可以看到 start 函數(shù)真正的符號(hào)是 _ZN7MyClass5startEi。如果想導(dǎo)出該函數(shù),version_script.txt 相應(yīng)位置填入 _ZN7MyClass5startEi 即可。第二種方式是在 version_script.txt 中使用 extern 語法,如下所示:

{
global:
extern "C++" {
MyClass::start*;
"MyClass::stop()";
};
local:*;
};

上述配置可以導(dǎo)出 MyClass 的 start 和 stop 函數(shù)。其原理是,鏈接時(shí)鏈接器對每個(gè)符號(hào)進(jìn)行 demangle(解構(gòu),即把修飾后的符號(hào)還原為可讀的表示),然后與 extern "C++" 中的條目進(jìn)行匹配,如果能與任一條目匹配成功就保留該符號(hào)。

匹配的規(guī)則是:有雙引號(hào)的條目不能使用通配符,需要全字符串完全匹配才可以(例如 stop 條目,如果括號(hào)之間多一個(gè)空格就會(huì)匹配失敗)。對于沒有雙引號(hào)的條目能夠使用通配符(例如 start 條目)。

查看優(yōu)化后 so 的導(dǎo)出符號(hào)

業(yè)務(wù)對 so 進(jìn)行優(yōu)化之后,需要查看最終的 so 文件中保留了哪些導(dǎo)出符號(hào),驗(yàn)證優(yōu)化效果是否符合預(yù)期。在 Mac 和 Linux 下均可使用下述命令查看 so 保留了哪些導(dǎo)出符號(hào):

nm -D --defined-only xxx.so

例如:

圖片

圖5 nm命令查看so文件的導(dǎo)出符號(hào)可以看出,libexample.so 的導(dǎo)出符號(hào)有兩個(gè):JNI_OnLoad 和 Java_com_example_MainActivity_stringFromJNI。

解析崩潰堆棧

本文的優(yōu)化方案會(huì)移除非必要導(dǎo)出的動(dòng)態(tài)符號(hào),那 so 如果發(fā)生崩潰的話是不是就無法解析崩潰堆棧了呢?答案是完全不會(huì)影響崩潰堆棧的解析結(jié)果。“so 可優(yōu)化內(nèi)容分析”一節(jié)已經(jīng)提過,使用帶調(diào)試信息和符號(hào)表的 so 解析線上崩潰,是分析 so 崩潰的標(biāo)準(zhǔn)方式(這也是 Google 解析 so 崩潰的方式)。本文的優(yōu)化方案并未修改調(diào)試信息和符號(hào)表,所以可以使用帶調(diào)試信息和符號(hào)表的 so 對崩潰堆棧進(jìn)行完整的還原,解析出崩潰堆棧每個(gè)棧幀對應(yīng)的源碼文件、行號(hào)和函數(shù)名等信息。業(yè)務(wù)編譯出 release 版的 so 后將相應(yīng)的帶調(diào)試信息和符號(hào)表的 so 上傳到 crash 平臺(tái)即可。

6. 方案收益

優(yōu)化 so 對安裝包體積和安裝后占用的本地存儲(chǔ)空間有直接收益,收益大小取決于原 so 冗余代碼數(shù)量和導(dǎo)出符號(hào)數(shù)量等具體情況,下面是部分 so 優(yōu)化前后占用安裝包體積的對比:

圖片

下面是上述 so 優(yōu)化前后占用本地存儲(chǔ)空間的對比:

圖片

7. 總結(jié)與規(guī)劃

對 so 體積進(jìn)行優(yōu)化不僅能夠減小安裝包體積,而且能獲得以下收益:

  • 刪除了大量的非必要導(dǎo)出符號(hào)從而提升了 so 的安全性。
  • 因?yàn)?nbsp;.data.bss.text 等運(yùn)行時(shí)占用內(nèi)存的 section 減小了,所以也能減小應(yīng)用運(yùn)行時(shí)的內(nèi)存占用。
  • 如果優(yōu)化過程中減少了 so 對外依賴的符號(hào),還可以加快 so 的加載速度。

我們對后續(xù)工作做了如下的規(guī)劃:

  • 提升編譯速度。因?yàn)槭褂?LTO、gc sections 等會(huì)增加編譯耗時(shí),計(jì)劃調(diào)研 ThinLTO 等方案對編譯速度進(jìn)行優(yōu)化。
  • 詳細(xì)展示保留各個(gè)函數(shù)/數(shù)據(jù)的原因。
  • 進(jìn)一步完善平臺(tái)優(yōu)化 so 的能力。?
責(zé)任編輯:張燕妮 來源: 美團(tuán)技術(shù)團(tuán)隊(duì)
相關(guān)推薦

2022-06-01 09:18:37

抖音ReDex算法優(yōu)化

2022-10-28 13:41:51

字節(jié)SDK監(jiān)控

2023-07-19 22:17:21

Android資源優(yōu)化

2022-04-28 09:36:47

Redis內(nèi)存結(jié)構(gòu)內(nèi)存管理

2024-11-13 21:18:02

2023-10-31 12:50:35

智能優(yōu)化探索

2022-08-12 12:23:28

神經(jīng)網(wǎng)絡(luò)優(yōu)化

2024-12-05 12:01:09

2017-05-18 11:43:41

Android模塊化軟件

2024-01-03 16:29:01

Agent性能優(yōu)化

2022-05-07 15:51:47

Android資源文件文件名

2022-08-21 21:28:32

數(shù)據(jù)庫實(shí)踐

2024-12-26 09:27:51

2024-12-18 10:03:30

2023-06-30 13:10:54

數(shù)據(jù)聚合網(wǎng)關(guān)

2023-01-05 07:54:49

vivo故障定位

2017-09-08 17:25:18

Vue探索實(shí)踐

2021-12-08 10:35:04

開源監(jiān)控Zabbix

2023-10-27 12:16:23

游戲發(fā)行平臺(tái)SOP

2017-09-11 16:34:00

點(diǎn)贊
收藏

51CTO技術(shù)棧公眾號(hào)

主站蜘蛛池模板: 男人阁久久 | 天天操夜夜拍 | 国产精品一二区 | 亚洲草草视频 | 精品欧美一区二区久久久伦 | 欧美色成人| 国产精品久久久久久久免费大片 | 亚洲视频在线观看 | 精品国产一区二区三区在线观看 | 国产精品99久久久久久大便 | 国产亚洲欧美日韩精品一区二区三区 | 久久99网| 亚洲一区二区三区在线 | 欧美精品一级 | 国产在线一区二区三区 | 久久精品免费 | 国产在线精品一区 | 香蕉av免费 | 在线视频成人 | 国产1区2区在线观看 | 伊人网站在线观看 | a级免费观看视频 | 91精品国产综合久久久久久丝袜 | 精品免费观看 | 在线欧美一区 | 国产三级日本三级 | 国产免费又色又爽又黄在线观看 | 涩涩视频网站在线观看 | 天天干狠狠| 不卡一二三区 | 国产一级片91 | 日韩在线小视频 | 国精产品一区二区三区 | 91se在线 | 91在线精品一区二区 | 91在线观看| 亚洲一区二区免费视频 | 国产精品久久久久久久久免费相片 | 免费一区二区三区 | 亚洲精品一二区 | 亚洲视频观看 |