oynix

于无声处听惊雷,于无色处见繁花

JNI使用总结

由于业务特殊,最近的需求都是藏代码,把业务逻辑代码藏到工程里的各个层面,这就离不开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
2
3
4
5
System.load -> Runtime.loadLibrary -> nativeLoad(java_lang_Runtime.cpp) -> 
Dalvik_java_lang_Runtime_nativeLoad -> dvmLoadNativeCode(dalvik/vm/Native.cpp) ->
- dlopen(pathName, RTLD_LAZY) (把so mmap到进程空间,并把func等相关信息填充到soinfo中)
- dlsym(handle, "JNI_OnLoad") (查找JNI_InLoad) 方法,找到则调用
- JNI_OnLoad

所以,我们可以在这个函数中注册函数,JNI也提供了注册函数的方法RegisterNatives。这种方式需要两步,首先找到目标Class,然后再调用Register方法即可,以下是Android官网示例代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
JNIEXPORT 
jint JNI_OnLoad(JavaVM* vm, void* reserved) {
JNIEnv* env;
if (vm->GetEnv(reinterpret_cast<void**>(&env), JNI_VERSION_1_6) != JNI_OK) {
return JNI_ERR;
}

// Find your class. JNI_OnLoad is called from the correct class loader context for this to work.
jclass c = env->FindClass("com/example/app/package/MyClass");
if (c == nullptr) return JNI_ERR;

// Register your class' native methods.
static const JNINativeMethod methods[] = {
{"nativeFoo", "()V", reinterpret_cast<void*>(nativeFoo)},
{"nativeBar", "(Ljava/lang/String;I)Z", reinterpret_cast<void*>(nativeBar)},
};
int rc = env->RegisterNatives(c, methods, sizeof(methods)/sizeof(JNINativeMethod));
if (rc != JNI_OK) return rc;

return JNI_VERSION_1_6;
}

注意,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
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
# For more information about using CMake with Android Studio, read the
# documentation: https://d.android.com/studio/projects/add-native-code.html

# Sets the minimum version of CMake required to build the native library.

cmake_minimum_required(VERSION 3.18.1)

# Declares and names the project.

project("nativelib")

# Creates and names a library, sets it as either STATIC
# or SHARED, and provides the relative paths to its source code.
# You can define multiple libraries, and CMake builds them for you.
# Gradle automatically packages shared libraries with your APK.

add_library( # Sets the name of the library.
nativelib

# Sets the library as a shared library.
SHARED

# Provides a relative path to your source file(s).
nativelib.cpp )

# Searches for a specified prebuilt library and stores the path as a
# variable. Because CMake includes system libraries in the search path by
# default, you only need to specify the name of the public NDK library
# you want to add. CMake verifies that the library exists before
# completing its build.

find_library( # Sets the name of the path variable.
log-lib

# Specifies the name of the NDK library that
# you want CMake to locate.
log )

# Specifies libraries CMake should link to your target library. You
# can link multiple libraries, such as libraries you define in this
# build script, prebuilt third-party libraries, or system libraries.

target_link_libraries( # Specifies the target library.
nativelib

# Links the target library to the log library
# included in the NDK.
${log-lib} )

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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
android {
defaultConfig {
externalNativeBuild {
cmake {
cppFlags ""
}
}
}

externalNativeBuild {
cmake {
path "src/main/cpp/CMakeLists.txt"
version "3.18.1"
}
}
}

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
    #ifdef __cplusplus
    extern "C"
    {
    #endif
    // code here
    #ifdef __cplusplus
    }
    #endif

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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// 获取class,JNI提供两种方式,通过方法名,或者通过jobject
jclass jc = env->FindClass("java/lang/String");
jclass jc = env->GetObjectClass(jo);

// 获取method
jmethodID jm = env->GetMethodID(jc, "method name", "method signature");
jmethodID jm = env->GetStaticMethodID(jc, "method name", "method signature");

// 获取字段
jfieldID jf = env->GetFieldID(jc, "field name", "field signature");
jfieldID jf = env->GetStaticFieldID(jc, "field name", "field signature");

// 方法调用 每种返回值类型JNI都有一个对应的方法
env->CallVoidMethod(jo, jm);
jobject jo = env->CallObjectMethod(jo, jm, methodArg);
// 同时,每种类型都有一个静态方法对应的方法
env->CallStaticObjectMethod(jc, jm, methodArg);

// 字段调用,同方法
jint i = env->GetIntField(jo, jf);
// 静态字段同理
jint i = env->GetStaticIntField(jc, jf);

附录

  • 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
------------- (完) -------------
  • 本文作者: oynix
  • 本文链接: https://oynix.com/2022/03/aea0472175e5/
  • 版权声明: 本博客所有文章除特别声明外,均采用 BY-NC-SA 许可协议。转载请注明出处!

欢迎关注我的其它发布渠道