Android使用 SO 库时要注意的一些问题

缥帙各舒散,前后互相逾。这篇文章主要讲述Android使用 SO 库时要注意的一些问题相关的知识,希望能为你提供帮助。
常和 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.allvariant ->
// assign different version code for each output
variant.outputs.eachoutput ->
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;



总结

  1. 一种 CPU 架构 = 一种 ABI = 一种对应的 SO 库;
  2. 加载 SO 库时,需要加载对应类型的 SO 库;
  3. 尽量提供全平台 CPU 类型的 SO 库支持;

题外话,SO 库的使用本身就是一种最纯粹的动态加载技术,SO 库本身不参与 APK 的编译过程,使用 JNI 调用 SO 库里的 Native 方法的方式看上去也像是一种 “硬编程”,Native 方法看上去与一般的 Java 静态方法没什么区别,但是它的具体实现却是可以随时动态更换的(更换 SO 库就好),这也可以用来实现热修复的方案,与 Java 方法一旦加载进内存就无法再次更换不同,Native 方法不需要重启 APP 就可以随意更换。
【Android使用 SO 库时要注意的一些问题】出于安全和生态控制的原因,Google Play 市场不允许 APP 有加载外部可执行文件的行为,一旦你的 APK 里被检查出有额外的可执行文件时就不好玩了,所以现在许多 APP 都偷偷把用于动态加载的可执行文件的后缀名换成 “.so”,这样被发现的几率就降低了,因为加载 SO 库看上去就是官方合法版本的动态加载啊(不然 SO 库怎么工作),虽然这么做看起来有点掩耳盗铃。

    推荐阅读