JNI的引入和加载过程
一般情况下,我们不需要关心so。但是当APP使用的第三方SDK中包含了so文件,或者自己需要使用NDK开发某些功能,就有必要去好好了解下so的一些知识。
JNI开发
JNI全称是Java Native Interface(Java本地接口)单词首字母的缩写,本地接口就是指用C和C++开发的接口。由于JNI是JVM规范中的一部份,
因此可以将我们写的JNI程序在任何实现了JNI规范的Java虚拟机中运行。同时,这个特性使我们可以复用以前用C/C++写的大量代码。
开发JNI程序会受到系统环境的限制,因为用C/C++语言写出来的代码或模块,编译过程当中要依赖当前操作系统环境所提供的一些库函数,
并和本地库链接在一起。而且编译后生成的二进制代码只能在本地操作系统环境下运行,因为不同的操作系统环境,有自己的本地库和CPU指令集,
而且各个平台对标准C/C++的规范和标准库函数实现方式也有所区别。这就造成使用了JNI接口的JAVA程序,不再像以前那样自由的跨平台。如果要实现
跨平台,就必须将本地代码在不同的操作系统平台下编译出相应的动态库
什么是ABI和so
早期的Android设备只支持ARMv5的CPU架构,随着Android系统的快速发展,搭载Android的硬件平台也早已多样化了,又加入了ARMv7,x86,MIPS,ARMv8,MIPS64和x86_64。
每一种CPU架构,都定义了一种ABI(Application Binary Interface,应用二进制接口),ABI定义了其所对应的CPU架构能够执行的二进制文件(如.so文件)的格式规范,决定了二进制文件如何与系统
每一种ABI的详细介绍可以参见官方的介绍ABI Management。
so(shared object,共享库)是机器可以直接运行的二进制代码,是Android上的动态链接库,类似于Windows上的dll。每一个Android应用所支持的ABI是由其APK提供的.so文件决定的,这些so文件被打包在apk文件的lib/目录下,其中abi可以是上面表格中的一个或者多个。
例如,解压一个apk文件后,在lib目录下可以看到如下文件:
lib
|
├── armeabi
│ └── libmath.so
├── armeabi-v7a
│ └── libmath.so
├── mips
│ └── libmath.so
└── x86
└── libmath.so
说明该应用所支持的ABI为armeabi, armeabi-v7a, mips, 和x86。
~ aapt dump badging baidutieba.apk | grep abi
native-code: 'armeabi' 'mips' 'x86'
为什么使用so
- so机制让开发者最大化利用已有的C和C++代码,达到重用的效果,利用软件世界积累了几十年的优秀代码;
- so是二进制,没有解释编译的开消,用so实现的功能比纯java实现的功能要快;
- so内存分配不受Dalivik/ART的单个应用限制,减少OOM;
- 相对于java代码,二进制代码的反编译难度更大,一些核心代码可以考虑放在so中。
为指定的ABI生成so
默认情况下,NDK只会为armeabi生成.so文件,若需要生成支持其他ABI的.so文件,可以在Application.mk文件中指定APP_ABI
参数:
APP_ABI := armeabi-v7a
APP_ABI
参数可以被指定多个值以支持多个ABI
APP_ABI := all
查看Android系统的ABI支持
Android可以在运行期间确定当前系统所支持的ABI,这是由系统编译时的具体参数指定的:
primary ABI
(主ABI):对应当前系统中使用的机器码类型secondary ABI
(副ABI):表示当前系统支持的其他ABI类型
许多手机支持不止一个ABI,比如,一个基于ARMv7的设备会将armeabi-v7a定义为primary ABI,armeabi作为secondary ABI,意味着这台机器同时支持armeabi-v7a和armeabi。
许多基于x86的设备也可以运行armeabi-v7a和armeabi的so,对于这些机器,primary ABI是x86,secondary ABI则是armeabi-v7a.
但是,为了能得到更好的性能表现,我们应该尽可能的直接提供primary ABI所对应的so文件。比如,我们可以为x86手机直接提供x86的so文件,而不是仅提供arm的so让系统通过houdini去动态转换arm指令,避免转换过程中的性能损耗。
查看Android系统支持的ABI有以下两种方法:
使用adb命令
/system/build.prop
中指定了支持的ABI类型,在adb中,可使用如下命令查看
shell@NX529J:/ $ getprop | grep abilist
[ro.product.cpu.abi]: [arm64-v8a]
[ro.product.cpu.abilist32]: [armeabi-v7a,armeabi]
[ro.product.cpu.abilist64]: [arm64-v8a]
[ro.product.cpu.abilist]: [arm64-v8a,armeabi-v7a,armeabi]
使用API获取
使用Build.SUPPORTED_ABIS可以获取当前设备支持的ABI列表:
x86手机对arm的支持
值得注意的是原本x86架构的CPU是不支持运行arm架构的native代码的,但Intel和Google合作在x86机子的系统内核层之上加入了一个名为houdini的Binary Translator(二进制转换中间层),这个中间层会在运行期间动态的读取arm指令并将之转换为x86指令去执行。
apk安装过程中对so的选择
在Android上安装应用程序时,Package Manager会扫描整个apk文件,寻找符合下面文件路径格式的动态连接库:
lib/<primary-abi>/lib<name>.so
在这里,primary-abi
是上面表中的abi的值,name
对应的是我们在Android.mk中定义的LOCAL_MODULE的值,
如果在apk内并没有找到适合当前机器primary-abi的so,Package Manager会尝试寻找适合secondary-abi的so文件:
lib/<secondary-abi>/lib<name>.so
即安装应用时,系统会根据当前CPU架构选择最优ABI适配,如果找到了合适的so文件,包管理器会将该ABI文件夹下所有so库全部拷贝至应用的data目录下:data/data/<package_name>/lib/
注意:apk安装过程对so选择是基于整个ABI文件夹的,而非以单个so文件为粒度,也就是说把lib/armeabi 、lib/armeabi-v7a、lib/x86等等文件夹的其中一个文件夹内所有.so复制到应用的data目录下。
如果我们在代码中调用了某个so的功能,而最终拷贝的ABI文件夹下并没有提供这个文件,apk的安装过程中并不会报错,但是运行时会遇到java.lang.UnsatisfiedLinkError
。
so的加载
因为本文主要通过分析源码来分析so使用的知识点和问题总结,所以涉及到了很多的源码类,这里就现提供一下:
1、PackageManagerService.Java
+setNativeLibraryPaths:设置应用的native库路径
+scanPackageDirtyLI:扫描包内容初始化应用信息
2、ActivityManagerService.java
+startProcessLocked:发送命令给Zygote进程启动一个虚拟机
3、NativeLibraryHelper.java
底层实现类:com_android_internal_content_NativeLibraryHelper.cpp
+copyNativeBinariesWithOverride:释放apk中的so文件到本地目录
+findSupportedAbi:遍历apk中的so文件结合abiList值得到应用支持的abi类型索引值
4、LoadApk类和ApplicationLoaders类
5、VMRuntime.java
底层实现类:dalvik_system_VMRuntime.c
+getInstructionSet:获取虚拟机的指令集类型
+is64BitAbi:判断VM是否为64位
6、Runtime.java
底层实现类:dalvik/vm/native/java_lang_Runtime.cpp,dalvik/vm/Native.cpp
+nativeLoad:加载so文件
在Android中如果想使用so的话,首先得先加载,加载现在主要有两种方法,一种是直接System.loadLibrary方法加载工程中的libs目录下的默认so文件,这里的加载文件名是xxx,而整个so的文件名为:libxxx.so。还有一种是加载指定目录下的so文件,使用System.load方法,这里需要加载的文件名是全路径,比如:xxx/xxx/libxxx.so。
上面的两种加载方式,在大部分场景中用到的都是第一种方式,而第二种方式用的比较多的就是在插件中加载so文件了。
对于so的加载,Android在System
类中提供了两种方法:
/**
* See {@link Runtime#loadLibrary}.
*/
public static void loadLibrary(String libName) {
Runtime.getRuntime().loadLibrary(libName, VMStack.getCallingClassLoader());
}
/**
* See {@link Runtime#load}.
*/
public static void load(String pathName) {
Runtime.getRuntime().load(pathName, VMStack.getCallingClassLoader());
}
System.loadLibrary
这是我们最常用的一个方法,System.loadLibrary
只需要传入so在Android.mk中定义的LOCAL_MODULE的值即可,
系统会调用System.mapLibraryName
把这个libName转化成对应平台的so的全称并去尝试寻找这个so加载。
比如我们的so文件全名为libmath.so,加载该动态库只需要传入math
即可:
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>/
下。
private String doLoad(String name, ClassLoader loader) {
1.Android应用程序是叉从受精卵zygote,所以他们不能有一个自定义ld_library_path,这意味着默认情况下应用程序的共享库的目录不在ld_library_path。
2PathClassLoader建立的框架/库知道适当的路径,所以我们可以加载没有依赖的库很好,但是应用程序有多个库依赖于以最依赖的第一顺序加载它们。
3.我们将API添加到Android的动态链接器中,这样我们就可以更新所使用的库路径,当前正在运行的进程。我们把所需的路径从ClassLoader这里
,递给nativeload这样就可以调用私有API动态链接器
4.我们不只是改变框架/库更新ld_library_path一旦在开始因为多个程序可以运行在相同的进程和第三方代码使用自己的basedexclassloader。
5.我们不只是添加一个dlopen_with_custom_ld_library_path称因为我们想要什么dlopen,由一个电话。这样的jni_onload工作太。
// So, find out what the native library search path is for the ClassLoader in question...
String librarySearchPath = null;
if (loader != null && loader instanceof BaseDexClassLoader) {
BaseDexClassLoader dexClassLoader = (BaseDexClassLoader) loader;
librarySearchPath = dexClassLoader.getLdLibraryPath();
}
// nativeLoad should be synchronized so there's only one LD_LIBRARY_PATH in use regardless
// of how many ClassLoaders are in the system, but dalvik doesn't support synchronized
// internal natives.
synchronized (this) {
return nativeLoad(name, loader, librarySearchPath);
}
}
到这里我们就总结一下Android中加载so的流程:
1、调用System.loadLibrary和System.load方法进行加载so文件
2、通过Runtime.java类的nativeLoad方法进行最终调用,这里需要通过类加载器获取到nativeLib路径。
3、到底层之后,就开始使用dlopen方法加载so文件,然后使用dlsym方法调用JNI_OnLoad方法,最终开始了so的执行。
Android中类加载器关联so路径
上面分析so加载过程中可以发现有一个地方,就是通过类加载器来获取到so的路径,那么Android中的主要类加载器有两个,一个是PathClassLoader和DexClassLoader,关于这两个类加载不多说了,网上资料很多可以自行查找阅读。而PathClassLoader是我们Android中默认的类加载器,也就是apk文件就是由他来加载的,我们可以通过查看源码得知,Android中加载apk的类加载可以从LoadApk.java类查找到: private ClassLoader mClassLoader;
private Application mApplication;
mClassLoader = ApplicationLoaders.getDefault().getClassLoader(zip, lib,
mBaseClassLoader);
Android中so文件如何释放
我们在使用System.loadLibrary加载so的时候,传递的是so文件的libxxx.so中的xxx部分,那么系统是如何找到这个so文件然后进行加载的呢?这个就要先从apk文件安装时机说起。
我们如果还没有分析源码之前,大致能够猜想到的流程是:
在安装apk的时候,系统解析apk文件,因为so文件肯定是存放在libs下指定平台目录中的,而apk文件本身就是一个压缩文件,所以可以进行解压,然后读取libs目录下的so文件,进行本地释放解压到指定目录,然后在加载的时候就先拼接so文件的全路径,最后在进行加载工作即可。
通过猜想,下面就通过源码来分析一下流程,系统在安装apk的时候,是调用系统类:PackageManagerService.java类:
主要的核心方法是scanPackageDirtyLI:
现在去看看NativeLibraryHelper类的copyNativeBinariesForSupportedAbi方法:
public static int copyNativeBinariesForSupportedAbi(Handle handle, File libraryRoot,
String[] abiList, boolean useIsaSubdir) throws IOException {
createNativeLibrarySubdir(libraryRoot);
/*
* If this is an internal application or our nativeLibraryPath points to
* the app-lib directory, unpack the libraries if necessary.
*/
int abi = findSupportedAbi(handle, abiList);
if (abi >= 0) {
/*
* If we have a matching instruction set, construct a subdir under the native
* library root that corresponds to this instruction set.
*/
final String instructionSet = VMRuntime.getInstructionSet(abiList[abi]);
final File subDir;
if (useIsaSubdir) {
final File isaSubdir = new File(libraryRoot, instructionSet);
createNativeLibrarySubdir(isaSubdir);
subDir = isaSubdir;
} else {
subDir = libraryRoot;
}
int copyRet = copyNativeBinaries(handle, subDir, abiList[abi]);
if (copyRet != PackageManager.INSTALL_SUCCEEDED) {
return copyRet;
}
}
return abi;
}
这个方法中主要干了三件事:
第一件事是获取应用所支持的arch架构类型
第二件事是通过架构类型获取so释放的目录
第三件事是native层中释放apk中的指定架构的so到设备目录中
我们可以看到Android中是如何释放apk中的so文件到本地目录的:
1、通过遍历apk文件中的so文件的全路径,然后和系统的abiList中的类型值进行比较,如果匹配到了就返回arch类型的索引值
2、得到了应用所支持的arch类型之后,就开始获取创建本地释放so的目录
3、然后开始释放so文件
通过精简so来减小包大小
现在的apk动辄几十M或者更大,apk包大小的精简成为了开发过程中的重要一环。通过上面的介绍,我们知道x86、x86_64、armeabi-v7a、arm64-v8a设备都支持armeabi架构的so,因此,通过移除不必要的so来减小包大小是一个不错的选择。
按照ABI分别单独打包APK
我们可以选择在Google Play上传指定ABI版本的APK,生成不同ABI版本的APK可以在build.gradle中进行如下配置:
android {
// Some other configuration here...
splits {
abi {
enable true
reset()
include 'x86', 'armeabi', 'armeabi-v7a', 'mips' //select ABIs to build APKs for
universalApk false // generate an additional APK that contains all the ABIs
}
}
只提供armabi
的so
上面的方法需要应用市场提供用户设备CPU类型更识别的支持,在国内并不是一个十分适用的方案。常用的处理方式是利用gradle中的abiFilters配置。
首先配置修改主工程build.gradle
下的abiFilters
:
android {
// Some other configuration here...
defaultConfig {
ndk {
abiFilters 'armeabi'
}
}
}
abiFilters后面的ABI类型即为要打包进apk的ABI类型,除此以外都不打包进apk里。
然后在项目的根目录下的
gradle.properties
(没有的话新建一个)中加入下面这行:
ndroid.useDeprecatedNdk=true
通过上面方法减少的apk体积是十分可观的,也是目前比较主流的处理方案。
进阶版方案
如果进一步,会发现上面的方案并不完美。首先是性能问题:使用兼容模式去运行arm架构的so,会丢失专门为当前ABI优化过的性能;其次还有兼容性问题,虽然x86设备能兼容arm类型的函数库,但是并不意味着100%的兼容,某些情况下还是会发生crash,所以x86的arm兼容只是一个折中方案,为了最好的利用x86自身的性能和避免兼容性问题,我们最好的做法仍是专为x86
提供对应的so。
针对这些问题,我们可以采用一个相对更好的方案:让所有so都来自于网路,应用下载服务器上的so库后,利用System.load
方法动态加载当前设备对应的so.
需要注意的问题
不要把so放错地方
首先要注意的是不要把另一个ABI下的so文件放在另一个ABI文件夹下(每个ABI文件夹下的so文件名是相同的,有可能会搞错)。
尽可能为所有ABI提供so
理想状况下,应该尽可能为所有ABI都提供对应的so,这一点的好处我们已经在上面讨论过了:在可以发挥更好性能的同时,还能减少由于兼容带来的某些crash问题。当然,这一点要结合实际情况(如SDK提供的so不全、芯片市场占有率、apk包大小等)去考量,如果使用的so本身就很小,我们大可为尽可能多的ABI都提供so。
若是局限于包大小等因素,可以结合通过精简so来减小包大小一节中提供的第三个方案来调整so的使用策略。
所有ABI文件夹提供的so要保持一致
这是一个十分容易出现的错误。
如果我们的应用选择了支持多个ABI,要十分注意:对于每个ABI下的so,但要么全部支持,要么都不支持。不应该混合着使用,而应该为每个ABI目录提供对应的.so文件。
先举个例子,Bugtags的so支持所有的ABI:
ibs
|
├── 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
参考上面 apk安装过程中对so的选择 一节,假设当前设备是x86机器,包管理器会先去lib/x86下寻找,发现该文件夹是存在的,所以最终只有lib/x86下的so–即只有libBugtags.so会被安装。当尝试在运行期间加载
libImages.so
时,就会遇上下面常见的
UnsatisfiedLinkError
错误:
所以,我们需要遵循这样的准则:
- 对于so开发者:支持所有的平台,否则将会搞砸你的用户。
- 对于so使用者:要么支持所有平台,要么都不支持。
然而,因为种种原因(遗留so、芯片市场占有率、apk包大小等),并不是所有人都遵循这样的原则。
一种可行的处理方案是:取你所有的so库所支持的ABI的交集,移除其他(可以通过上面介绍的abiFilters
来实现)。
如上面的例子,最终生成的apk可以是:
lib
|
├── armeabi
│ ├── libBugtags.so
│ └── libImages.so
└── armeabi-v7a
├── libBugtags.so
└── libImages.so