NDK撩妹三部曲(續2)— NDK 開發以及 so 庫體積優化總結與學習筆記(深度乾貨,值得收藏)

  前段時間做完我們的 SDK 項目,沒有關注 so 庫大小這塊,現在慢慢穩定了就需要追求 so 庫體積了。小團隊一般可能不會在意這個東西,畢竟現在流量已經不是幾年前的奢侈品了。但是要知道so庫的大小不僅影響的是應用商店app的大小,還有一個很大的影響就是在廣告頁面渠道要求的秒下載,太大的app下載速度慢用戶會不耐煩,直接影響了這部分用戶的轉化。

1、從支持的abi架構入手優化

7種abi架構簡介

armeabi 第5/6代 ARM v5TE,使用軟件浮點運算,兼容所有ARM設備,通用性強,速度慢

armeabi-v7a 第7代 ARM v7,使用硬件浮點運算,具有高級擴展功能(目前大部分手機都是這個架構)

arm64-v8a 第8代,64位,包含AArch32、AArch64兩個執行狀態對應32、64bit

x86 intel 32位,一般用於平板

x86_64 intel 64位,一般用於平板(支持 x86 和 x86_64)

mips 基本沒見過(支持 mips)

mips64 基本沒見過(支持 mips 和 mips_64)

  對於手機來說,目前市面上佔到 99% 的設備都是 armeabi 或者armeabi-v7a 和 arm64-v8a。雖然說 arm64-v8a 架構的手機慢慢發展起來了,但是其中 armeabi-v7a 還是佔到絕大多數位置,但是隨着現在手機更新換代的加速,arm64-v8a 慢慢的就會成爲主流。

  一般來說我們編譯的 ABI 爲 armeabi-v7a 的包已經能基本上能適配市面上絕大多數手機了,可以保證運行在 armeabi-v7a 架構上效率肯定是最高的,而在其他的架構上由於增加了模擬層,導致性能會有所損失。比如64位設備(arm64-v8a)能夠運行32位的函數庫,但是以32位模式運行,將丟失專爲64位優化過的性能(ART,webview,media等)。

abi 兼容性

  • arm64-v8a : 能兼容 armeabi-v7a 和 armeabi
  • armeabi-v7a :armeabi-v7a向下兼容 armeabi
  • x86_64 : 兼容 x86
  • mips64 : 兼容 mips

即意味着 arm64-v8a 架構的 so 庫是可以運行在 arm64-v8a、armeabi-v7a 和 armeabi 設備上的。armeabi-v7a 架構的 so 庫是可以運行在 armeabi-v7a 和 armeabi 設備上的。

Android 加載so庫順序

這塊的內容很多文章沒有說清楚,我根據實測案例描述一遍(測試環境:小米10,android studio 3.1.3,NDK:r20):

Android 加載 so 庫時是從當前手機支持的最高 CPU 架構文件夾開始:

  1. 假如當前手機是 arm64-v8a 架構(現在我們使用的很多新手機都是這個架構),你的 APK 存在 arm64-v8a 文件夾,則從 arm64-v8a 文件夾開始,如果 arm64-v8a 下面有庫,且完整,則結束,安裝的時候也安裝的是這個文件夾下的 so庫,哪怕此時你存在armeabi-v7a 文件夾,且裏面的庫不全也沒關係,不會報錯
  2. 假如你的 APK 存在 arm64-v8a 文件夾,且在 arm64-v8a 下沒有找到庫,不管是直接 load 的庫還是依賴的庫,找不到則直接報錯:
java.lang.UnsatisfiedLinkError: Unable to load library 'soTest'
  1. 假如你的 APK 存在 arm64-v8a 文件夾,也存在 armeabi-v7a 文件夾,而且兩者裏面的庫都完整,則 android 包管理器會安裝 arm64-v8a 下面的文件,而忽略 armeabi-v7a 下面的庫。

所以最好的情況便是分別編譯不同 abi 架構的 so 庫。

注意事項

  1. Android 包管理器安裝 app 時,只有當前手機支持的 cpu 架構下的包纔會被安裝。即哪怕你打包裏面有 arm64-v8a ,也有 armeabi-v7a,但是安裝時只會安裝其中的一個。比如我的小米手機裏面有 arm64-v8a 和 armeabi-v7a 兩個文件夾,但是安裝完成後,使用 Native Libs Monitor 軟件查看只安裝了 arm64-v8a 下面的包。
  2. 與我上面提到的測試案例不同,假如你的手機是 armeabi-v7a 架構的,哪怕 arm64-v8a 文件夾下的庫都有,而 armeabi-v7a 下面的庫卻不完整,app 會 crash 的。所以爲了兼容性(因爲你根本不知道你的目標用戶手機架構是什麼樣的),一定要保證已經存在的 abi 文件夾下 so 庫的數量一致,要麼都支持,要麼都不支持。
  3. 因爲你使用的 so 庫可能來自不同的源頭,因此一定要保證這些庫依賴了 相同的c++ 運行時,例如一個 abi 目錄下只有一個 libc++_shared.so。

總結下來,有兩種解決方案去優化 App 大小:

  1. 建議只提供一種 abi 架構的 so 庫,就是 armeabi-v7a,損失一些性能。
  2. 現在的應用市場支持上傳不同 abi 架構的 APK 包,因此建議針對不同的 abi 架構上傳不同的 APK 包。

主流app支持的abi

  • 抖音:armeabi-v7a
  • 微信:微信下載的時候 apk 分爲兩個版本,一個 32 的,一個 64 的,下載 64 位的解壓後發現只有 armeabi-v8a 文件夾,32 位解壓後只有 armeabi-v7a 文件夾。
  • QQ:armeabi
  • 淘寶:arm64-v8a 和 armeabi-v7a

另外還發現個小彩蛋,抖音和QQ還沒有使用 flutter 開發。

2、gcc/clang編譯參數優化

從 abi 架構去優化 so 庫體積,其實不是我們想要的方案,因爲現在大多數應用已經不會附帶 3 個以上的 abi 架構 so 庫。因此這方面的優化程度有限。因此我們要從另外的方向,即編譯指令上優化生成的 so 庫體積。

本來想直接說使用哪些指令優化,優化的效果是什麼的,但是裏面又牽扯一些其他知識,比如這個優化指令是誰的指令,編譯器還是 ndk?那不同的編譯器能使用相同的指令嗎?如果不從頭理一下這個流程,就感覺來的很突兀,容易讓人摸不着頭腦。

cmake、nmake、makefile、make概念詳解

在此之前我們需要理清楚一個概念,即 Cmake、MakeFile、nmake、make 這些概念的聯繫和本質:

  • cmake :Cmake 是一個跨平臺的編譯構建工具,幫助我們在不同平臺下生成工程,比如 linux 下的 makefile 工程,windows 下的 vcproj 工程。在 cmake 中,我們可以指定使用的編譯器,比如 gcc/g++, 或者 clang/clang++,或者 cl/cl++ 等。
  • 生成器(generator):那這個 makefile 是根據什麼生成的呢?就是根據“生成器”,下面的圖可以看到我的 cmake3.11 當前支持這麼多種生成器(使用cmake --help查看),生成器告訴 cmake 生成那種類型的 makefile 文件(即哪種工程)。
  • MakeFile: makefile 文件是一個描述文件,裏面定義了我們項目所有源代碼文件的編譯規則和編譯指令,目的是爲了使用這一個腳本達到我們項目的“自動化構建”。makefile 文件根據不同的“生成器”所生成的格式是不同的,比如在 linux 常見的使用 autoconf 和 automake 生成 makefile 文件,然後使用 ./configure 和 make 便能編譯出最終的可執行文件。比如 windows VS2017 下使用 “Visual Studio 15 2017” 生成器生成適用於 VS2017 的 vcproj 文件,雖然它不叫 makefile 文件,但是道理相同。
  • nmakemake、nmake、gmake 都是解析 makefile 文件的工具,在 linux 系統下會用到 make 或者 gmake,在 windows 下會使用 nmake。到底使用哪個工具取決於上面我們在 cmake 時選用的哪個“生成器”,比如如果選擇 nmake makefiles生成器(見下圖),則最後編譯的時候我們就需要選擇 nmake 工具。

[外鏈圖片轉存失敗,源站可能有防盜鏈機制,建議將圖片保存下來直接上傳(img-23VjDSJ4-1592311644999)(DED91F803B8C469281C1B0AD01A7D385)]

NDK和JNI的關係

  • NDKNative Development Kit,是一個屬於 Android 的開發工具包,和 Java 無關,有了它,讓 Android 程序可以和 C/C++ 交互,它裏面提供的工具可以將 so 庫和 Android 代碼一起打包成 APK。並且 NDK 裏面提供的各種交叉編譯器,可以生成不同 CPU 架構的動態庫。
  • JNIJava Native Interface,Java 本地接口,顧名思義,是接口定義,JNI 代碼可以在 Java 代碼裏調用 C、C++ 等語言的代碼 或 C、C++ 代碼調用 Java 代碼。由於 Java 語言的跨平臺性,使得它和本地代碼的交互能力很弱,因此纔有了 JNI 可以增強 Java 和 本地代碼交互的能力。
  • NDK與JNI的關係:NDK 是在 Android 中實現 JNI 的工具。而 JNI 只是 API 接口定義。有了 NDK,才能更方便的讓 Java 調用 C/C++。簡單說就是 JNI 負責 Java 與 C/C++ 進行互相操作,NDK 提供工具方便在 Android 平臺使用 JNI。

so庫的編譯流程

看懂了上面的釋義,然後我們再理一下一個 so 庫從編譯到可以在 Android 中運行所經歷的過程(基於windows平臺):

  1. 一段 C++ 代碼,首先編寫 cmakelist.txt 文件。
  2. 選擇生成器(比如nmake)並使用 cmake 工具構建工程,cmake 中有參數可以指定 C/C++ 編譯器,可以提前指定 ndk 版本等信息。
  3. 生成 “生成器”所能解釋的 makefile 文件。
  4. 執行 make 指令。(即使用生成器根據 makefile 文件生成真正的工程)。
  5. 使用 cmake 中指定的編譯器編譯工程。
  6. 最終生成目標文件(可執行文件/動態庫/靜態庫)。

NDK所使用的編譯器

由於 NDK 從 r17 已經廢棄了gcc,推薦使用 clang 編譯,因此本文基於 cmake + clang + ndk r20 構建 so 庫。

  • GCC特性:除支持C/C++/ Objective-C/Objective-C++語言外,還支持Java/Ada/Fortran/Go等;支持更多平臺;更流行,廣泛使用,支持完備。

  • Clang特性:編譯速度快;內存佔用小;兼容GCC;設計清晰簡單、容易理解,易於擴展增強;基於庫的模塊化設計,易於IDE集成;出錯提示更友好

因此推薦以後不管是學習測試還是項目都使用 clang 進行編譯。

容易陷入誤區的地方

上面提一嘴 gcc 與 clang 的原因是有一個容易讓人陷入誤區的地方。在 cmake 中有兩個參數是:CMAKE_C_FLAGS 和 CMAKE_CXX_FLAGS,用來設置編譯器選項。但是我們知道 CFLAGS 參數和 CPPFLAGS 參數是 gcc 編譯器纔有的指令,clang 是沒有這個指令的。那在 cmake 中設置了CMAKE_CXX_FLAGS還會有效果嗎?

重點:CMAKE_CXX_FLAGS != CXXFLAGS

即 cmake 中的 CMAKE_CXX_FLAGS 並不是 gcc 編譯指令中的 CXXFLAGS。
CMAKE_CXX_FLAGS 只是 cmake 用來告訴編譯器(不管是gcc還是clang)的編譯指令,即 cmake 會解析 CMAKE_CXX_FLAGS 參數中的內容傳遞給具體的編譯器。

因此對於 clang 編譯器來說,cmake 中設置 CMAKE_CXX_FLAGS 也是生效的。只是說有可能 CMAKE_CXX_FLAGS 中的某些 gcc 指令 clang 不識別,或者說某些 clang 指令 gcc 不識別。比如說:-lz指令在 clang 下編譯會出現警告:

在這裏插入圖片描述
或者說出現錯誤;

在這裏插入圖片描述

gcc/clang編譯指令優化so庫

有了上面的內容,終於可以進入正題說下那些參數可以幫助我們減小 so 庫的體積。
由於我們使用 ndk 編譯時,編譯器是 ndk 自帶的,比如下面的編譯器:

在這裏插入圖片描述

clang 是在 ndk 目錄下,下面的參數都是 gcc 或者 clang 編譯參數。

1.異常與運行時(gcc 和 clang)

-fno-exceptions 
-fno-rtti

開啓異常和運行時:3998kb
在這裏插入圖片描述

關閉異常和運行時:3998kb
在這裏插入圖片描述

默認情況下,ndk 中的 C++ 異常和運行時是被關閉的,如果項目打開這個選項了,可以考慮關閉,因爲 ndk 對 C++ 異常支持的不夠友好,所以大多數情況下異常是起不到實質作用的。 但是從上面我們的測試可以看出,so 庫大小沒變,可能和代碼有關,但是也可以看出這兩個選項對 so 庫的大小影響有限,因此重要程度並不高。

2.導出函數可見性(gcc 和 clang)

-fvisibility=hidden

默認時:3998kb
在這裏插入圖片描述

設置 hidden 後:3933kb,減小了 0.01%
在這裏插入圖片描述

默認情況下,該選項是 default 的,即so庫中大部分的函數或者全局變量都會被導出,且是可見的,-fvisibility=hidden可以顯著地提高鏈接和加載共享庫的性能,生成更加優化的代碼,保證只有 export 修飾的函數纔會導出。建議在編譯共享庫的時候使用它。

3.丟棄未使用的函數(只有gcc)

set(CMAKE_SHARED_LINKER_FLAGS "-Wl,--gc-sections")
set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS}  -ffunction-sections -fdata-sections")

編譯的時候,加入-ffunction-sections, -fdata-sections 選項,在鏈接的時候,加入–gc-sections選項。
編譯的時候,把每個函數作爲一個section,每個數據(應該是指全局變量之類的吧)也作爲一個section,這樣鏈接的時候,–gc-sections會把沒用到的section丟棄掉,最終的可執行文件就只包含用到了的函數和數據。

4. 產生與位置無關代碼,避免so庫加載重定位(gcc)

set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS}  -fPIC")

-fPIC 作用於編譯階段,告訴編譯器產生與位置無關代碼(Position-Independent Code),則產生的代碼中,沒有絕對地址,全部使用相對地址,故而代碼可以被加載器加載到內存的任意位置,都可以正確的執行。如果不加 -fPIC,則加載 so 文件的代碼段時,代碼段引用的數據對象需要重定位, 重定位會修改代碼段的內容,這就造成每個使用這個 so 文件代碼段的進程在內核裏都會生成這個 so 文件代碼段的 copy。

5. O1(gcc 和 clang)

set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS}  -O1")

目的是在不影響編譯速度的前提下,儘量採用一些優化算法降低代碼大小和可執行代碼的運行速度。

6.O2(gcc 和 clang)

set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS}  -O2")

該優化選項會犧牲部分編譯速度,除了執行 -O1 所執行的所有優化之外,還會採用幾乎所有的目標配置支持的優化算法,用以提高目標代碼的運行速度。

7.O3(gcc 和 clang)

set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS}  -O3")

該選項除了執行 -O2 所有的優化選項之外,一般都是採取很多向量化算法,提高代碼的並行執行程度,利用現代CPU中的流水線,Cache 等。

8. Os(gcc 和 clang)

set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS}  -Os")

這個優化標識和-O3有異曲同工之妙,當然兩者的目標不一樣,-O3的目標是寧願增加目標代碼的大小,也要拼命的提高運行速度,但是這個選項是在-O2的基礎之上,儘量的降低目標代碼的大小,這對於存儲容量很小的設備來說非常重要。

9. Ofast(gcc 和 clang)

set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS}  -Ofast")

該選項將不會嚴格遵循語言標準,除了啓用所有的-O3優化選項之外,也會針對某些語言啓用部分優化。如:-ffast-math。

10. -s(gcc 和 clang)

set(CMAKE_SHARED_LINKER_FLAGS "-Wl,-s") 
set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS}  -s")

添加 -s 前:
在這裏插入圖片描述
添加 -s 後:
在這裏插入圖片描述
清除符號表信息,-s和-S的區別在於-S移除調試符號信息,而-s移除所有符號信息。


參考:Clang 11 documentation-Clang Compiler User’s Manual
參考:Using the GNU Compiler Collection (GCC)-Options That Control Optimization
參考:Using the GNU Compiler Collection (GCC)-Options Controlling C++ Dialect
參考:Android NDK: How to Reduce Binaries Size – The Algolia Blog
參考:GCC中-O1 -O2 -O3 優化的原理是什麼?

如有幫助,請多多點贊支持,謝謝。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章