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++库暴露的所有函数,那么:

  1. 第一步就是创建一个java类,并按照c++库的接口函数声明,创建所有的native本地接口函数声明(可以是static的)。
  2. 第二步,将这些本地接口声明映射为C++ JNI接口声明,这一步是通过java提供的工具按照既定的映射机制自动生成。这也就保证了java层能正确找到c++实现。
  3. 第三步,实现第二步自动生成的c++ JNI接口函数,在这些接口实现中,按照需要调用c++类库的接口函数,以使用特定的功能并拿到需要的结果。所以,这里要注意的一点是,c++ JNI接口函数实现会编译为一个单独的动态库,并且该动态库动态链接C++类动态库。(这里没有尝试过静态库,按道理应该也是可以的)。此外,在c++ JNI函数实现中按照类型签名规则,我们可以获取到从java层传入的参数,也可以返回特定的数据到Java层。
  4. 第四步,在java应用层使用system.loadLibrary("libname.so");加载第三步编译生成的jni so库,即可间接调用到c++库函数。

PS:

  1. jni层类型和java类型的对应关系,基本数据类型只是简单地加了前缀j,如int<=>jint, double<=>jdouble,下面是一些对象类型(包含数组)的类型映射关系:

  2. 签名规则对应表

  3. 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

整体构建流程如下:

  1. 在java工程下创建和C++类库同名(非必须)的java类源文件,并声明和c++工程接口统一的native成员函数;
    使用javac -encoding utf8 -h ./ com/Student.java命令生成naive本地接口.h头文件。将其拷贝到c++工程下。
  2. 在c++工程下实现jni接口头文件中的函数声明,实现中调用c接口间接完成特定能力调用,编译为libjnilib.so,并链接原始c++库的动态库。
  3. 回到java工程中,在native接口所在的那个类中,添加jni库加载代码:
    // 加载jni库
    static {
        try {
            System.loadLibrary("jnilib");
        }
        catch(UnsatisfiedLinkError e) {
			System.err.println(">>> Can not load library: " + e.toString());
		}
    }
  1. 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. 思考

  1. 目前即使编译debug版本,调试还是无法进入到jni实现层。有博客说可以通过attach进程可以进入,我尝试并没有成功。
  2. JNI接口传参和返回数据到java层要注意数据类型匹配,签名要一致,否则会直接崩溃掉。
  3. 类似的为C++库封装Python接口,并生成一个安装包可以直接使用pip安装也是常见的封装方式,有时间也可以尝试一下。
  4. 为C++库实现JNI接口可以用Android studio,IDEA,更加方便。也可以直接在Linux上进行,只要有jdk和gcc就可以,但正常人一般不会在linux上写JAVA代码。
posted @ 2023-04-05 15:59  Lee-zq  阅读(4826)  评论(0编辑  收藏  举报