一、寫在前面
直到現在,基本我寫的每一個項目都會用到NDK動態鏈接庫的知識,可見這個也的確十分常用。那么,今天,咱們就來談談它。
二、什么是ABI和so
1、發展
早起的Android系統幾乎只支持ARMv5的CPU架構,而現在卻發展到了7種:ARMv5,ARMv7 (從2010年起),x86 (從2011年起),MIPS (從2012年起),ARMv8,MIPS64和x86_64 (從2014年起),每一種都關聯着一個相應的ABI。每一種ABI的詳細介紹可以參見官方的介紹ABI Management。
2、關系
我們可以通過Build.SUPPORTED_ABIS得到根據偏好排序的設備支持的ABI列表。但你不應該從你的應用程序中讀取它,因為Android包管理器安裝APK時,會自動選擇APK包中為對應系統ABI預編譯好的.so文件,如果在對應的lib/ABI目錄中存在.so文件的話。
ABI(橫向)和cpu(縱向) | armeabi | armeabi-v7a | arm64-v8a | mips | mips64 | x86 | x86_64 |
---|---|---|---|---|---|---|---|
ARMv5 | 支持 | ||||||
ARMv7 | 支持 | 支持 | |||||
ARMv8 | 支持 | 支持 | 支持 | ||||
MIPS | 支持 | ||||||
MIPS64 | 支持 | 支持 | |||||
x86 | 支持(3) | 支持(2) | 支持(1) | ||||
x86_64 | 支持 | 支持 | 支持 |
解析: x86設備上,libs/x86目錄中如果存在.so文件的話,會被安裝,如果不存在,則會選擇armeabi-v7a中的.so文件,如果也不存在,則選擇armeabi目錄中的.so文件。
x86設備能夠很好的運行ARM類型函數庫,但並不保證100%不發生crash,特別是對舊設備。
64位設備(arm64-v8a, x86_64, mips64)能夠運行32位的函數庫,但是以32位模式運行,在64位平台上運行32位版本的ART和Android組件,將丟失專為64位優化過的性能(ART,webview,media等等)。
三、我什么我們要關注so
- so機制讓開發者最大化利用已有的C和C++代碼,達到重用的效果,利用軟件世界積累了幾十年的優秀代碼;
- so是二進制,沒有解釋編譯的開消,用so實現的功能比純java實現的功能要快;
- so內存分配不受Dalivik/ART的單個應用限制,減少OOM;
- 相對於java代碼,二進制代碼的反編譯難度更大,一些核心代碼可以考慮放在so中。
四、NDK的兼容性
使用NDK時,你可能會傾向於使用最新的編譯平台,但事實上這是錯誤的,因為NDK平台不是后向兼容(兼容過去的版本)的,而是前向兼容(兼容將來的版本)的。推薦使用app的minSdkVersion對應的編譯平台。
這也意味着當你引入一個預編譯好的.so文件時,你需要檢查它被編譯所用的平台版本。
五、一個法則
處理.so文件時有一條簡單卻並不知名的重要法則。
你應該盡可能的提供專為每個ABI優化過的.so文件,但要么全部支持,要么都不支持:你不應該混合着使用。你應該為每個ABI目錄提供對應的.so文件。
六、so文件的加載
對於so文件的加載,Android在System類中提供了下面兩種方法。
1 /** 2 * See {@link Runtime#loadLibrary}. 3 */ 4 public static void loadLibrary(String libName) { 5 Runtime.getRuntime().loadLibrary(libName, VMStack.getCallingClassLoader()); 6 } 7 /** 8 * See {@link Runtime#load}. 9 */ 10 public static void load(String pathName) { 11 Runtime.getRuntime().load(pathName, VMStack.getCallingClassLoader()); 12 }
1、System.loadLibrary
這是我們最常用的一個方法,System.loadLibrary
只需要傳入so在Android.mk中定義的LOCAL_MODULE的值即可,系統會調用System.mapLibraryName
把這個libName轉化成對應平台的so的全稱並去嘗試尋找這個so加載。比如我們的so文件全名為libmath.so,加載該動態庫只需要傳入math
即可:
System.loadLibrary("math");
2、System.load
對於System.load
方法,官方是這樣介紹的:
Loads a code file with the specified filename from the local file system as a dynamic library.
The filename argument must be a complete path name.
所以它為動態加載非apk打包期間內置的so文件提供了可能,也就是說可以使用這個方法來指定我們要加載的so文件的路徑來動態的加載so文件。
比如我們在打包期間並不打包so文件,而是在應用運行時將當前設備適用的so文件從服務器上下載下來,放在/data/data/<package-name>/mydir
下,然后在使用so時調用:
System.load("/data/data/<package-name>/mydir/libmath.so");
即可成功加載這個so,開始調用本地方法了。
其實loadLibrary和load最終都會調用nativeLoad(name, loader, ldLibraryPath)方法,只是因為loadLibrary的參數傳入的僅僅是so的文件名,所以,loadLibrary需要首先找到這個文件的路徑,然后加載這個so文件。
而load傳入的參數是一個文件路徑,所以它不需要去尋找這個文件路徑,而是直接通過這個路徑來加載so文件。
但是當我們把需要加載的so文件放在SdCard中,會發生什么呢?把上面so的路徑改成/mnt/sdcard/libmath.so
,再嘗試加載時,會得到如下錯誤:
java.lang.UnsatisfiedLinkError: dlopen failed: couldn't map "/mnt/sdcard/libmath.so" segment 1: Permission denied
這是因為SD卡等外部存儲路徑是一種可拆卸的(mounted)不可執行(noexec)的儲存媒介,不能直接用來作為可執行文件的運行目錄,使用前應該把可執行文件復制到APP內部存儲下再運行。所以使用System.load
加載so時要注意把so拷貝至/data/data/<package-name>/
下。
七、通過精簡so來減小apk大小
1、為什么
現在的apk動輒幾十M或者更大,apk包大小的精簡成為了開發過程中的重要一環。通過上面的介紹,我們知道x86、x86_64、armeabi-v7a、arm64-v8a設備都支持armeabi架構的so,因此,通過移除不必要的so來減小包大小是一個不錯的選擇。
2、按照ABI分別單獨打包APK
我們可以選擇在Google Play上傳指定ABI版本的APK,生成不同ABI版本的APK可以在build.gradle中進行如下配置:(引自別處,未考證)
1 android { 2 // Some other configuration here... 3 splits { 4 abi { 5 enable true 6 reset() 7 include 'x86', 'armeabi', 'armeabi-v7a', 'mips' //select ABIs to build APKs for 8 universalApk false // generate an additional APK that contains all the ABIs 9 } 10 } 11 }
3、只提供armabi
的so
上面的方法需要應用市場提供用戶設備CPU類型更識別的支持,在國內並不是一個十分適用的方案。常用的處理方式是利用gradle中的abiFilters配置。
首先配置修改主工程build.gradle
下的abiFilters
:
1 android { 2 // Some other configuration here... 3 defaultConfig { 4 ndk { 5 abiFilters 'armeabi' 6 } 7 } 8 }
abiFilters后面的ABI類型即為要打包進apk的ABI類型,除此以外都不打包進apk里。然后在項目的根目錄下的gradle.properties
(沒有的話新建一個)中加入下面這行:
android.useDeprecatedNdk=true
通過上面方法減少的apk體積是十分可觀的,也是目前比較主流的處理方案。
4、進階版方案
如果進一步,會發現上面的方案並不完美。首先是性能問題:使用兼容模式去運行arm架構的so,會丟失專門為當前ABI優化過的性能;其次還有兼容性問題,雖然x86設備能兼容arm類型的函數庫,但是並不意味着100%的兼容,某些情況下還是會發生crash,所以x86的arm兼容只是一個折中方案,為了最好的利用x86自身的性能和避免兼容性問題,我們最好的做法仍是專為x86
提供對應的so。
針對這些問題,我們可以采用一個相對更好的方案:讓所有so都來自於網路,應用下載服務器上的so庫后,利用System.load
方法動態加載當前設備對應的so.
八、需要注意的問題
1、不要把so放錯地方
首先要注意的是不要把另一個ABI下的so文件放在另一個ABI文件夾下(每個ABI文件夾下的so文件名是相同的,有可能會搞錯)。
2、盡可能為所有ABI提供so
理想狀況下,應該盡可能為所有ABI都提供對應的so,這一點的好處我們已經在上面討論過了:在可以發揮更好性能的同時,還能減少由於兼容帶來的某些crash問題。當然,這一點要結合實際情況(如SDK提供的so不全、芯片市場占有率、apk包大小等)去考量,如果使用的so本身就很小,我們大可為盡可能多的ABI都提供so。
若是局限於包大小等因素,可以結合通過精簡so來減小包大小一節中提供的第三個方案來調整so的使用策略。
3、所有ABI文件夾提供的so要保持一致
這是一個十分容易出現的錯誤。
如果我們的應用選擇了支持多個ABI,要十分注意:對於每個ABI下的so,但要么全部支持,要么都不支持。不應該混合着使用,而應該為每個ABI目錄提供對應的.so文件。
先舉個例子,Bugtags的so支持所有的ABI:
libs | ├── arm64-v8a │ └── libBugtags.so ├── armeabi │ └── libBugtags.so ├── armeabi-v7a │ └── libBugtags.so ├── mips │ └── libBugtags.so ├── mips64 │ └── libBugtags.so ├── x86 │ └── libBugtags.so └── x86_64 └── libBugtags.so
但不是所有開發者提供的so都支持所有ABI:
lib | ├── armeabi │ └── libImages.so └── armeabi-v7a └── libImages.so
如果不做任何設置,最終打出來的apk的lib目錄會是這樣的:
lib | ├── arm64-v8a │ └── libBugtags.so ├── armeabi │ ├── libBugtags.so │ └── libImages.so ├── armeabi-v7a │ ├── libBugtags.so │ └── libImages.so ├── mips │ └── libBugtags.so ├── mips64 │ └── libBugtags.so ├── x86 │ └── libBugtags.so └── x86_64 └── libBugtags.so
假設當前設備是x86機器,包管理器會先去lib/x86下尋找,發現該文件夾是存在的,所以最終只有lib/x86下的so–即只有libBugtags.so會被安裝。當嘗試在運行期間加載libImages.so
時,就會遇上下面常見的UnsatisfiedLinkError錯誤:
1 E/xxx (10674): java.lang.UnsatisfiedLinkError: dalvik.system.PathClassLoader[DexPathList[[zip file "/data/app/xxx-2/base.apk"],nativeLibraryDirectories=[/data/app/xxx-2/lib/x86, /vendor/lib, /system/lib]]] couldn't find "libImages.so" 2 E/xxx (10674): at java.lang.Runtime.loadLibrary(Runtime.java:366)
所以,我們需要遵循這樣的准則:
- 對於so開發者:支持所有的平台,否則將會搞砸你的用戶。
- 對於so使用者:要么支持所有平台,要么都不支持。
然而,因為種種原因(遺留so、芯片市場占有率、apk包大小等),並不是所有人都遵循這樣的原則。
一種可行的處理方案是:取你所有的so庫所支持的ABI的交集,移除其他(可以通過上面介紹的abiFilters
來實現)。
如上面的例子,最終生成的apk可以是:
lib | ├── armeabi │ ├── libBugtags.so │ └── libImages.so └── armeabi-v7a ├── libBugtags.so └── libImages.so