由于业务特殊,最近的需求都是藏代码,把业务逻辑代码藏到工程里的各个层面,这就离不开JNI开发了,这篇来具体说一说。
JNI(Java Native Interface),是Android开发中在原生java层调用c/c++ native方法的一种方式,也可以在native层调用java层的方法。JNI库一般是以扩展名为so(shared object)的文件存在,通过System.load
就可以将其加载到应用进程中,之后java层和native层便可以相互调用。
1. 基础数据类型
既然可以互相调用,那么二者在数据结构上的就有着对应关系,具体如下:
Java | JNI | Desc |
---|---|---|
boolean | jboolean | usigned 8 bit |
byte | jbyte | unsigned 8 bit |
char | jchar | unsigned 8 bit |
short | jshort | signed 16 bit |
int | jint | signed 32 bit |
long | jlong | signed 64 bit |
float | jfloat | signed 32 bit |
double | jdouble | signed 64 bit |
2. javah方式
在java文件NativeLib.java中,声明一个native修饰的方法,也可以声明多个,然后在这个文件所在目录执行
1 | javac -h . NativeLib.java |
于是,在这个目录下就会生成NativeLib.class文件,和一个.h的header文件,你会发现里面都是些又臭又长的函数声明,然后在cpp文件中实现header文件里的函数,将其打包成so文件,在程序运行时,jvm便可以根据名字找到native修饰的方法对应的jni中的函数,这是写在jvm介绍里面的,具体的这个Oracle文档里有介绍。
3. JNI_OnLoad方式
这是个特殊的函数,在调用System.load方法加载so库文件后,最终会调用库文件里的JNI_OnLoad方法,前提是已定义了这个方法
1 | System.load -> Runtime.loadLibrary -> nativeLoad(java_lang_Runtime.cpp) -> |
所以,我们可以在这个函数中注册函数,JNI也提供了注册函数的方法RegisterNatives。这种方式需要两步,首先找到目标Class,然后再调用Register方法即可,以下是Android官网示例代码
1 | JNIEXPORT |
注意,RegisterNatives做的事是把本地函数和一个java类方法关联起来,不管之前是否关联过,一律会把之前的替换掉,也就是多次给一个java类关联本地函数时,只有最后一次关联的是有效的。
4. 两种方式对比
如果没有调用Register方法提前注册,则无法把so中实现的函数在进程中的地址增加到ClassObject中的directMethods中,那么只有到了调用native方法时,才会解析这些又臭又长的javah风格的函数,这个事在dumResolveNativeMethod(dalvik/vm/Native.cpp)中进行,根据native方法签名,在所有打开的so中寻找函数实现,找到后并调用,所以从调用效率上来看,Register更高效。
5. 方法签名
上面提了好几次的方法签名,这来说一说。简单说,方法签名就是由方法返回值类型和参数类型组成的一个字符串:”(参数列表)返回值“,每种类型都有一个唯一的标记,如下
字符 | 数据类型 | 说明 |
---|---|---|
V | void | 方法返回值 |
Z | boolean | |
B | byte | |
C | char | |
S | short | |
I | int | |
J | long | |
F | float | |
D | double | |
[ | 数组 | 以[开头,配合其他的特殊字符,表示对应数据类型的数组,几个[表示几维数组 |
L全类名 | 引用类型 | 以L开头、;结尾,中间是引用类型的全类名 |
如果懒得写,还可以用javap命令,注意这里传入的是class文件,所以要先用javac编译一下
1 | javap -p -s NativeLib.class |
6. CMakeLists.txt
用Android Studio创建NativeLibrary时,会自动生成CMake文件。根据官网说,NDK构建一共有三种方式:基于Make的ndk-build、CMake和已经弃用的独立工具链,原文在这。可见AS默认使用的CMake方式,自动生成的CMakeLists.txt文件里写了很详细的介绍,这里直接拿过来看一看
1 | # For more information about using CMake with Android Studio, read the |
find_library就是从ndk里找,具体位置在
1 | ~/Library/Android/sdk/ndk/21.4.7075529/platforms/android-30/arch-x86_64/usr/lib64 |
名字掐头去尾,所以log库对应的就是这个目录下的liblog.so文件。同时,build.grade里也多了两个块,分别在android里,指定了版本和路径,和android的defaultConfig里,可以添加编译时的配置
1 | android { |
7. 几个关键字 JNIEXPORT JNICALL extern
JNIEXPORT和JNICALL
这是两个宏定义,在不同平台对应不同的定义。JNIEXPORT的作用是保证在本库中声明的函数能够在其他项目中可以调用,一般export的作用都是如此,而JNICALL则是用于定义函数入栈规则和堆栈清理规则,这篇文章有详细说明。extern
它有两个作用,一个是用来修饰变量或是函数,这时它的作用就是声明函数或全局变量的作用范围的关键字,其声明的函数和变量可以在本模块或是其他模块中使用。虽然它只是声明,但在编译阶段,找不到定义也不会报错,因为连接器在连接阶段会在生成的目标代码中找到定义。另个作用是和“C”一起连用,此时它的作用是告诉编译器用C的规则编译而不是C++的规则,因为C++支持重载,所以编译后的名字是不同的。例如,void foo(int x, int y);,C编译器编译后在符号库中的名字为_foo,而C++编译后的结果是_foo_int_int之类的名字,具体取决于编译器的类型,所以,如果把一个用C编译的目标代码和一个用C++编译器编译的目标代码进行链接,就会出现连接失败的错误。同时,只有C++编译器认识extern “C”,常用写法为1
2
3
4
5
6
7
8
extern "C"
{
// code here
}
8. 指针void*
指针是种常用的形式,提供了很多便利,如int*
为int型指针,char*
为字符型指针,经常还会遇到一种指针,void*
。指针存储的是地址,它本身的大小就是系统寻址的大小,不同类型的指针的所占用的内存大小都是一样的,那为什么还要区分类型呢?从某个角度来看,指针的类型可以理解为它的跳跃能力,比如一个int类型的指针在加1后,会指向紧邻的下一个int,一个int按照占用4个字节来计算的话,那么它的跳跃能力就是4个字节,同理,一个long型的指针在加1后,也会指向紧邻的下一个long,一个long占用8个字节,那么它的跳跃能力就是8个字节。这样来看的话,void类型的指针就是跳跃能力未知的指针,它需要我们手动强转之后,才具有跳跃能力,强转为int型指针后,跳跃能力为4字节,转为long则为8字节。多用于通用函数的参数,比如memcpy,不关心传入是什么类型的指针,我只管把src位置的🈯️数量的字节复制到dst,即可。void*
就是个这样的存在。
9. 反射调用
在JNI里调用java的方法,基本都是通过反射的方式。先获取jclass,通过jclass获取jmethod或者jfield,然后再调用方法或字段
1 | // 获取class,JNI提供两种方式,通过方法名,或者通过jobject |
附录
- https://blog.csdn.net/fireroll/article/details/50102009
- https://blog.csdn.net/kgdwbb/article/details/72810251
- https://www.jianshu.com/p/1229580b2356
- https://www.jianshu.com/p/6cbdda111570