C++库封装JNI接口——实现java调用c++
1. JNI原理概述
通常为了更加灵活高效地实现计算逻辑,我们一般使用C/C++实现,编译为动态库,并为其设置C接口和C++接口。用C++实现的一个库其实是一个或多个类的简单编译链接产物。然后暴露其实现类构造方法和纯虚接口类。这样就可以通过多态调用到库内部的实现类及其成员方法。进一步地,为了让不同库之间调用兼容,可以将C++接口进一步封装为一组C接口函数,C接口编译时不会添加复杂的函数签名,也不支持函数重载,可以方便其他C或C++客户程序调用。C接口的封装需要有"extern C{}"标识,以告诉编译器请使用C编译方式编译这些函数。
进一步地,为了方便上层应用调用C/C++库, 如Android应用,可以为C++库封装Java接口。jdk中地jni组件可以方便地实现在java中调用c++库函数。基本调用原理如下:
- Java客户代码实现和native方法声明属于java层,使用java编译器编译;
- JNI接口实现代码和c++库属于c++层,使用G++编译。
这里假定C++类库已经预编译好了,有现成的so库和c接口使用。首先明确一点就是,我们要为C++库封装一个java接口,也即在java层使用C++库暴露的所有函数,那么:
- 第一步就是创建一个java类,并按照c++库的接口函数声明,创建所有的native本地接口函数声明(可以是static的)。
- 第二步,将这些本地接口声明映射为C++ JNI接口声明,这一步是通过java提供的工具按照既定的映射机制自动生成。这也就保证了java层能正确找到c++实现。
- 第三步,实现第二步自动生成的c++ JNI接口函数,在这些接口实现中,按照需要调用c++类库的接口函数,以使用特定的功能并拿到需要的结果。所以,这里要注意的一点是,c++ JNI接口函数实现会编译为一个单独的动态库,并且该动态库动态链接C++类动态库。(这里没有尝试过静态库,按道理应该也是可以的)。此外,在c++ JNI函数实现中按照类型签名规则,我们可以获取到从java层传入的参数,也可以返回特定的数据到Java层。
- 第四步,在java应用层使用
system.loadLibrary("libname.so");
加载第三步编译生成的jni so库,即可间接调用到c++库函数。
PS:
-
jni层类型和java类型的对应关系,基本数据类型只是简单地加了前缀
j
,如int<=>jint, double<=>jdouble
,下面是一些对象类型(包含数组)的类型映射关系:
-
签名规则对应表
-
String 字符串函数操作
// 在jni实现函数中把jstring类型的字符串转换为C风格的字符串,会额外申请内存
const char *str = env->GetStringUTFChars(string,0);
// 做检查判断
if (str == NULL){
return NULL;
}
// do something;
// 使用完之后释放申请的内存
env->ReleaseStringUTFChars(string,str);
-
JNI 支持将 jstring 转换成 UTF 编码和 Unicode 编码两种。因为 Java 默认使用 Unicode 编码,而 C/C++ 默认使用 UTF 编码。所以使用
GetStringUTFChars(jstring string, jboolean* isCopy)
将 jstring 转换成 UTF 编码的字符串。其中,jstring 类型参数就是我们需要转换的字符串,而 isCopy 参数的值在实际开发中,直接填 0或NULL就好了,表示深拷贝。 -
当调用完
GetStringUTFChars
方法时别忘了做完全检查。因为 JVM 需要为产生的新字符串分配内存空间,如果分配失败就会返回 NULL,并且会抛出 OutOfMemoryError 异常,所以要对 GetStringUTFChars 结果进行判断。 -
当使用完 UTF 编码的字符串时,还不能忘了释放所申请的内存空间。调用
ReleaseStringUTFChars
方法进行释放。 -
除了将 jstring 转换为 C 风格字符串,JNI 还提供了将 C 风格字符串转换为 jstring 类型。
-
通过
NewStringUTF
函数可以将 UTF 编码的 C 风格字符串转换为 jstring 类型,通过NewString
函数可以将 Unicode 编码的 C 风格字符串转换为 jstring 类型。这个 jstring 类型会自动转换成 Java 支持的 Unicode 编码格式。 -
除了 jstring 和 C 风格字符串的相互转换之外,JNI 还提供了其他的函数。
参考:https://blog.csdn.net/TLuffy/article/details/123994246
2. JNI封装示例
实践出真知,分别建立一个c++工程和java工程,源码github地址。
结构目录如下:
├── cpp_project
│ ├── build.sh
│ ├── CMakeLists.txt
│ ├── include
│ │ ├── c_api.h
│ │ ├── com_Student.h
│ │ └── student.h
│ ├── jni_impl
│ │ └── jni_impl.cpp
│ ├── src
│ │ ├── c_api.cpp
│ │ └── student.cpp
│ └── test
│ └── main.cpp
└── java_project
├── com
│ ├── Student.java
│ └── Test.java
├── com_Student.h
└── run.sh
整体构建流程如下:
- 在java工程下创建和C++类库同名(非必须)的java类源文件,并声明和c++工程接口统一的native成员函数;
使用javac -encoding utf8 -h ./ com/Student.java
命令生成naive本地接口.h头文件。将其拷贝到c++工程下。 - 在c++工程下实现jni接口头文件中的函数声明,实现中调用c接口间接完成特定能力调用,编译为libjnilib.so,并链接原始c++库的动态库。
- 回到java工程中,在native接口所在的那个类中,添加jni库加载代码:
// 加载jni库
static {
try {
System.loadLibrary("jnilib");
}
catch(UnsatisfiedLinkError e) {
System.err.println(">>> Can not load library: " + e.toString());
}
}
- java 测试代码调用,使用如下脚本:
# 编译java文件
javac -encoding utf8 com/Test.java -d bin
# 运行java文件
java -Djava.library.path=/root/project/lzq/jni_demo/cpp_project/build/bin -cp bin com.Test
PS: 编译脚本分别在cpp工程和java工程目录下
3. 思考
- 目前即使编译debug版本,调试还是无法进入到jni实现层。有博客说可以通过attach进程可以进入,我尝试并没有成功。
- JNI接口传参和返回数据到java层要注意数据类型匹配,签名要一致,否则会直接崩溃掉。
- 类似的为C++库封装Python接口,并生成一个安装包可以直接使用pip安装也是常见的封装方式,有时间也可以尝试一下。
- 为C++库实现JNI接口可以用Android studio,IDEA,更加方便。也可以直接在Linux上进行,只要有jdk和gcc就可以,但正常人一般不会在linux上写JAVA代码。