常和 SO 庫開發打交道的同學來說已經是老生長談,但是既然要討論一整個動態加載系列,我想還是有必要說說使用 SO 庫時的一些問題。
在項目里使用 SO 庫非常簡單,在 加載 SD 卡中的 SO 庫 中也有談到,只需要把需要用到的 SO 庫拷貝進 jniLibs(或者 Eclipse 項目里面的 libs) 中,然后在 JAVA 代碼中調用 System.loadLibrary(“xxx”) 加載對應的 SO 庫,就可以使用 JNI 語句調用 SO 庫里面的 Native 方法了。
但是有同學注意到了,SO 庫文件可以隨便改文件名,卻不能任意修改文件夾路徑,而是 “armeabi”、“armeabi-v7a”、“x86” 等文件夾名有着嚴格的要求,這些文件夾名有什么意義么?
SO 庫類型和 CPU 架構類型
原因很簡單,不同 CPU 架構的設備需要用不同類型 SO 庫(從文件名也可以猜出來個大概嘛 ╮( ̄▽ ̄”)╭)。
記得還在學校的時候,提及 ARM 處理器時,老師說以后移動設備的 CPU 基本就是 ARM 類型的了。老師不曾欺我,早期的 Android 系統幾乎只支持 ARM 的 CPU 架構,不過現在至少支持以下七種不同的 CPU 架構:ARMv5,ARMv7,x86,MIPS,ARMv8,MIPS64 和 x86_64。每一種 CPU 類型都對應一種 ABI(Application Binary Interface),“armeabi-v7a”文件夾前面的 “armeabi” 指的就是 ARM 這種類型的 ABI,后面的 “v7a” 指的是 ARMv7。這 7 種 CPU 類型對應的 SO 庫的文件夾名是:armeabi,armeabi-v7a,x86,mips,arm64-v8a,mips64,x86_64。
不同類型的移動設備在運行 APP 時,需要加載自己支持的類型的 SO 庫,不然就 GG 了。通過 Build.SUPPORTED_ABIS 我們可以判斷當前設備支持的 ABI,不過一般情況下,不需要開發者自己去判斷 ABI,Android 系統在安裝 APK 的時候,不會安裝 APK 里面全部的 SO 庫文件,而是會根據當前 CPU 類型支持的 ABI,從 APK 里面拷貝最合適的 SO 庫,並保存在 APP 的內部存儲路徑的 libs 下面。(這里說一般情況,是因為有例外的情況存在,比如我們動態加載外部的 SO 庫的時候,就需要自己判斷 ABI 類型了。)
一種 CPU 架構 = 一種對應的 ABI 參數 = 一種對應類型的 SO 庫
到這里,我們發現使用 SO 庫的邏輯還是比較簡單的,但是 Android 系統加載 SO 庫的邏輯還是給我們留下了一些坑。
使用 SO 庫時要注意的一些問題
1. 別把 SO 庫放錯地方
SO 庫其實都是 APP 運行時加載的,也就是說 APP 只有在運行的時候才知道 SO 庫文件的存在,這就無法通過靜態代碼檢查或者在編譯 APP 時檢查 SO 庫文件是否正常。所以,Android 開發對 SO 庫的存放路徑有嚴格的要求。
使用 SO 庫的時候,除了 “armeabi-v7a” 等文件夾名需要嚴格按照規定的來自外,SO 庫要放在項目的哪個文件夾下也要按照套路來,以下是一些總結:
- Android Studio 工程放在 jniLibs/xxxabi 目錄中(當然也可以通過在 build.gradle 文件中的設置 jniLibs.srcDir 屬性自己指定);
- Eclipse 工程放在 libs/xxxabi 目錄中(這也是使用 ndk-build 命令生成 SO 庫的默認目錄);
- aar 依賴包中位於 jni/ABI 目錄中(SO 庫會自動包含到引用 AAR 壓縮包到 APK 中);
- 最終構建出來的 APK 文件中,SO 庫存在 lib/xxxabi 目錄中(也就是說無論你用什么方式構建,只要保證 APK 包里 SO 庫的這個路徑沒錯就沒問題);
- 通過 PackageManager 安裝后,在小於 Android 5.0 的系統中,SO 庫位於 APP 的 nativeLibraryPath 目錄中;在大於等於 Android 5.0 的系統中,SO 庫位於 APP 的 nativeLibraryRootDir/CPU_ARCH 目錄中;
既然扯到了這里,順便說一下,我在使用 Android Studio 1.5 構建 APK 的時候,發現 Gradle 插件只會默認打包 application 類型的 module 的 jniLibs 下面的 SO 庫文件,而不會打包 aar 依賴包的 SO 庫,所以會導致最終構建出來的 APK 里的 SO 庫文件缺失。暫時的解決方案是把所有的 SO 庫都放在 application 模塊中(這顯然不是很好的解決方案),不知道這是不是 Studio 的 BUG,同事的解決方案是通過修改 Gradle 插件來增加對 aar 依賴包的 SO 庫的打包支持(GitHub 有開源的第三方 Gradle 插件項目,使用 Java 和 Groovy 語言開發)。
2. 盡可能提供 CPU 支持的最優 SO 庫
當一個應用安裝在設備上,只有該設備支持的 CPU 架構對應的 SO 庫會被安裝。但是,有時候,設備支持的 SO 庫類型不止一種,比如大多的 X86 設備除了支持 X86 類型的 SO 庫,還兼容 ARM 類型的 SO 庫(目前應用市場上大部分的 APP 只適配了 ARM 類型的 SO 庫,X86 類型的設備如果不能兼容 ARM 類型的 SO 庫的話,大概要嗝屁了吧)。
所以如果你的 APK 只適配了 ARM 類型的 SO 庫的話,還是能以兼容的模式在 X86 類型的設備上運行(比如華碩的平板),但是這不意味着你就不用適配 X86 類型的 SO 庫了,因為 X86 的 CPU 使用兼容模式運行 ARM 類型的 SO 庫會異常卡頓(試着回想幾年前你開始學習 Android 開發的時候,在 PC 上使用 AVD 模擬器的那種感覺)。
3. 注意 SO 庫的編譯版本
除了要注意使用了正確 CPU 類型的 SO 庫,也要注意 SO 庫的編譯版本的問題。雖然現在的 Android Studio 支持在項目中直接編譯 SO 庫,但是更多的時候我們還是選擇使用事先編譯好的 SO 庫,這時就要注意了,編譯 APK 的時候,我們總是希望使用最新版本的 build-tools 來編譯,因為 Android SDK 最新版本會幫我們做出最優的向下兼容工作。
但是這對於編譯 SO 庫來說就不一樣了,因為 NDK 平台不是向下兼容的,而是向上兼容的。應該使用 app 的 minSdkVersion 對應的版本的 NDK 標本來編譯 SO 庫文件,如果使用了太高版本的 NDK,可能會導致 APP 性能低下,或者引發一些 SO 庫相關的運行時異常,比如 “UnsatisfiedLinkError”,“dlopen: failed” 以及其他類型的Crash。
一般情況下,我們都是使用編譯好的 SO 庫文件,所以當你引入一個預編譯好的 SO 庫時,你需要檢查它被編譯所用的平台版本。
4. 盡可能為每種 CPU 類型都提供對應的 SO 庫
比如有時候,因為業務的需求,我們的 APP 不需要支持 AMR64 的設備,但這不意味着我們就不用編譯 ARM64 對應的 SO 庫。舉個例子,我們的 APP 只支持 armeabi-v7a 和 x86 架構,然后我們的 APP 使用了一個第三方的 Library,而這個 Library 提供了 AMR64 等更多類型 CPU 架構的支持,構建 APK 的時候,這些 ARM64 的 SO 庫依然會被打包進 APK 里面,也就是說我們自己的 SO 庫沒有對應的 ARM64 的 SO 庫,而第三方的 Library 卻有。這時候,某些 ARM64 的設備安裝該 APK 的時候,發現我們的 APK 里帶有 ARM64 的 SO 庫,會誤以為我們的 APP 已經做好了 AMR64 的適配工作,所以只會選擇安裝 APK 里面 ARM64 類型的 SO 庫,這樣會導致我們自己項目的 SO 庫沒有被正確安裝(雖然 armeabi-v7a 和 x86 類型的 SO 庫確實存在 APK 包里面)。
這時正確的做法是,給我們自己的 SO 庫也提供 AMR64 支持,或者不打包第三方 Library 項目的 ARM64 的 SO 庫。使用第二種方案時,可以把 APK 里面不需要支持的 ABI 文件夾給刪除,然后重新打包,而在 Android Studio 下,則可以通過以下的構建方式指定需要類型的 SO 庫。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
|
productFlavors {
flavor1 {
ndk {
abiFilters
"armeabi-v7a"
abiFilters
"x86"
abiFilters
"armeabi"
}
}
flavor2 {
ndk {
abiFilters
"armeabi-v7a"
abiFilters
"x86"
abiFilters
"armeabi"
abiFilters
"arm64-v8a"
abiFilters
"x86_64"
}
}
}
|
需要說明的是,如果我們的項目是 SDK 項目,我們最好提供全平台類型的 SO 庫支持,因為 APP 能支持的設備 CPU 類型的數量,就是項目中所有 SO 庫支持的最少 CPU 類型的數量(使用我們 SDK 的 APP 能支持的 CPU 類型只能少於等於我們 SDK 支持的類型)。
5. 不要通過 “減少其他 CPU 類型支持的 SO 庫” 來減少 APK 的體積
確實,所有的 x86/x86_64/armeabi-v7a/arm64-v8a 設備都支持 armeabi 架構的 SO 庫,因此似乎移除其他 ABIs 的 SO 庫是一個減少 APK 大小的好辦法。但事實上並不是,這不只影響到函數庫的性能和兼容性。
X86 設備能夠很好的運行 ARM 類型函數庫,但並不保證 100% 不發生 crash,特別是對舊設備,兼容只是一種保底方案。64 位設備(arm64-v8a, x86_64, mips64)能夠運行 32 位的函數庫,但是以 32 位模式運行,在 64 位平台上運行 32 位版本的 ART 和 Android 組件,將丟失專為 64 位優化過的性能(ART,webview,media 等等)。
過減少其他 CPU 類型支持的 SO 庫來減少 APK 的體積不是很明智的做法,如果真的需要通過減少 SO 庫來做 APK 瘦身,我們也有其他辦法。
減少 SO 庫體積的正確姿勢
1. 構建特定 ABI 支持的 APK
我們可以構建一個 APK,它支持所有的 CPU 類型。但是反過來,我們可以為每個 CPU 類型都單獨構建一個 APK,然后不同 CPU 類型的設備安裝對應的 APK 即可,當然前提是應用市場得提供用戶設備 CPU 類型設別的支持,就目前來說,至少 PLAY 市場是支持的。
Gradle 可以通過以下配置生成不同 ABI 支持的 APK(引用自別的文章,沒實際使用過):
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
|
android {
...
splits {
abi {
enable
true
reset()
include
'x86', 'x86_64', 'armeabi-v7a', 'arm64-v8a' //select ABIs to build APKs for
universalApk
true //generate an additional APK that contains all the ABIs
}
}
// map for the version code
project.ext.versionCodes = [
'armeabi': 1, 'armeabi-v7a': 2, 'arm64-v8a': 3, 'mips': 5, 'mips64': 6, 'x86': 8, 'x86_64': 9]
android.applicationVariants.all { variant ->
// assign different version code for each output
variant.outputs.each { output ->
output.versionCodeOverride =
project.ext.versionCodes.get(output.getFilter(com.android.build.OutputFile.ABI),
0) * 1000000 + android.defaultConfig.versionCode
}
}
}
|
2. 從網絡下載當前設備支持的 SO 庫
說到這里,總算回到動態加載的主題了。⊙﹏⊙
使用 Android 的動態加載技術,可以加載外部的 SO 庫,所以我們可以從網絡下載 SO 庫文件並加載了。我們可以下載所有類型的 SO 庫文件,然后加載對應類型的 SO 庫,也可以下載對應類型的 SO 庫然后加載,不過無論哪種方式,我們最好都在加載 SO 庫前,對 SO 庫文件的類型做一下判斷。
我個人的方案是,存儲在服務器的 SO 庫依然按照 APK 包的壓縮方式打包,也就是,SO 庫存放在 APK 包的 libs/xxxabi 路徑下面,下載完帶有 SO 庫的 APK 包后,我們可以遍歷 libs 路徑下的所有 SO 庫,選擇加載對應類型的 SO 庫。
具體實現代碼看上去像是:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
|
/**
* 將一個SO庫復制到指定路徑,會先檢查改SO庫是否與當前CPU兼容
*
*
@param sourceDir SO庫所在目錄
*
@param so SO庫名字
*
@param destDir 目標根目錄
*
@param nativeLibName 目標SO庫目錄名
*
@return
*/
public static boolean copySoLib(File sourceDir, String so, String destDir, String nativeLibName) throws IOException {
boolean isSuccess = false;
try {
LogUtil.d(TAG,
"[copySo] 開始處理so文件");
if (Build.VERSION.SDK_INT >= 21) {
String[] abis = Build.SUPPORTED_ABIS;
if (abis != null) {
for (String abi : abis) {
LogUtil.d(TAG,
"[copySo] try supported abi:" + abi);
String name =
"lib" + File.separator + abi + File.separator + so;
File sourceFile =
new File(sourceDir, name);
if (sourceFile.exists()) {
LogUtil.i(TAG,
"[copySo] copy so: " + sourceFile.getAbsolutePath());
isSuccess = FileUtil.copyFile(sourceFile.getAbsolutePath(), destDir + File.separator + nativeLibName + File.separator + so);
//api21 64位系統的目錄可能有些不同
//copyFile(sourceFile.getAbsolutePath(), destDir + File.separator + name);
break;
}
}
}
else {
LogUtil.e(TAG,
"[copySo] get abis == null");
}
}
else {
LogUtil.d(TAG,
"[copySo] supported api:" + Build.CPU_ABI + " " + Build.CPU_ABI2);
String name =
"lib" + File.separator + Build.CPU_ABI + File.separator + so;
File sourceFile =
new File(sourceDir, name);
if (!sourceFile.exists() && Build.CPU_ABI2 != null) {
name =
"lib" + File.separator + Build.CPU_ABI2 + File.separator + so;
sourceFile =
new File(sourceDir, name);
if (!sourceFile.exists()) {
name =
"lib" + File.separator + "armeabi" + File.separator + so;
sourceFile =
new File(sourceDir, name);
}
}
if (sourceFile.exists()) {
LogUtil.i(TAG,
"[copySo] copy so: " + sourceFile.getAbsolutePath());
isSuccess = FileUtil.copyFile(sourceFile.getAbsolutePath(), destDir + File.separator + nativeLibName + File.separator + so);
}
}
if (!isSuccess) {
LogUtil.e(TAG,
"[copySo] 安裝 " + so + " 失敗 : NO_MATCHING_ABIS");
throw new IOException("install " + so + " fail : NO_MATCHING_ABIS");
}
}
catch (IOException e) {
e.printStackTrace();
throw e;
}
return true;
}
|
總結
- 一種 CPU 架構 = 一種 ABI = 一種對應的 SO 庫;
- 加載 SO 庫時,需要加載對應類型的 SO 庫;
- 盡量提供全平台 CPU 類型的 SO 庫支持;
題外話,SO 庫的使用本身就是一種最純粹的動態加載技術,SO 庫本身不參與 APK 的編譯過程,使用 JNI 調用 SO 庫里的 Native 方法的方式看上去也像是一種 “硬編程”,Native 方法看上去與一般的 Java 靜態方法沒什么區別,但是它的具體實現卻是可以隨時動態更換的(更換 SO 庫就好),這也可以用來實現熱修復的方案,與 Java 方法一旦加載進內存就無法再次更換不同,Native 方法不需要重啟 APP 就可以隨意更換。
出於安全和生態控制的原因,Google Play 市場不允許 APP 有加載外部可執行文件的行為,一旦你的 APK 里被檢查出有額外的可執行文件時就不好玩了,所以現在許多 APP 都偷偷把用於動態加載的可執行文件的后綴名換成 “.so”,這樣被發現的幾率就降低了,因為加載 SO 庫看上去就是官方合法版本的動態加載啊(不然 SO 庫怎么工作),雖然這么做看起來有點掩耳盜鈴。