一篇文章教你完全掌握jni技术

14,581 阅读13分钟
jni介绍

jni全称java native interface,我把它分为三部分,java代表java语言,native代表当前程序运行的本地环境,一般指windows/linux,而这些操作系统都是通过C/C++实现的,所以native通常也指C/C++语言,interface代表java跟native两者之间的通信接口,jni可以实现java和C/C++通信。它是java生态的特征,所以定义在jdk标准当中。

使用场景和优势
  • java虽然跨平台,但仍然运行在具体平台(windows,linux)之上,对于需要操作硬件的功能,必须通过系统的C/C++方法对硬件进行直接操作,比如打开文件,java层必须调用系统的open方法(linux是open,windows是openFile)才能打开文件,这个时候就涉及到java代码如何调用C/C++代码的问题

  • 在一些拥有复杂算法的场景(音视频编解码,图像绘制等),java的执行效率远低于C/C++的执行效率,使用jni技术,在java层调用C/C++代码,可以提高程序的执行效率,最大化利用机器的硬件资源。

  • native层的代码往往更加安全,反编译so文件比反编译jar文件要难得多,所以,我们往往把涉及到密码密钥相关的功能用C/C++实现,然后java层通过jni调用

通信原理

java运行在jvm,jvm本身就是使用C/C++编写的,因此jni只需要在java代码、jvm、C/C++代码之间做切换即可

jni调用流程.png

使用步骤

基于ubuntu(之前基于windows,后来很多人对dll动态库感到很陌生,现在在linux平台也操作一遍,so动态库大家都熟),为了方便,我使用了idea+clion,读者需要能掌握这两个工具的基本使用,跟Android Studio差不多的。整个过程分为了十步,我称之为jni十步曲:

1.使用idea创建一个java工程,并创建JNIDemo.java文件

创建java工程.png

2.在JNIDemo.java文件中声明native方法helloJni()

public class JNIDemo {
    public static native String helloJni();
}

3.使用javac命令编译JNIDemo.java,生成JNIDemo.class文件

生成class文件.png

4.使用javah命令生成JNIDemo.h文件

生成头文件.png

5.使用clion创建C++ library项目,并复制刚刚生成的com_jason_jni_JNIDemo.h头文件到项目根目录

创建c++工程.png

库类型选择shared,表示编译生成动态库,static为静态库,动态库和静态库的最大区别就在于静态库会将目标代码以及所有需要依赖的库文件进行整体打包,执行时不再依赖外部环境。动态库则只会将目标代码打包,运行时需要依赖外部环境,所以一般来说,静态库往往比动态库要大。windows上的动态库为.dll文件,静态库为.lib文件。linux上的动态库为.so文件,静态库为.a文件。

复制头文件到c++工程根目录.png

6.创建JNIDemo.cpp文件,实现helloJni()方法

创建cpp文件,实现jni方法.png

这里我直接返回了I am from c++字符串,同时要将JNIDemo.cpp文件添加到CMakeList.txt

添加cpp文件到cmakelist文件中.png

这个时候我们看到com_jason_jni_JNIDemo.h文件中有报错

image-20230203141554307

这是因为无法从系统中找到jni.h头文件,这里我们可以手动导入jni.h到项目中,开头说了,jni是java的特征,所以jni.h文件在jdk当中

  • windows去本地jdk安装目中找<jdk安装目录>/include/jni.h<jdk安装目录>/include/win32/jni_md.h
  • ubuntu去本地jdk安装目录找<jdk安装目录>/include/jni.h<jdk安装目录>/include/linux/jni_md.h

将这两个文件拷贝到项目根目录中,然后将#include <jni.h>改为#include "jni.h",尖括号表示从系统中查找,双引号表示从当前项目中查找。

导入jni.h尖括号改为双引号.png

7.编译本地代码,生成libjnidemo.so文件

编译生成so文件.png

8.在刚刚的java项目的根目录中创建libs文件夹,并将其设置为资源文件夹,然后将生成的libjnidemo.so文件拷贝到该目录中

创建libs目录并设置为资源目录.png

注意libs目录的图标一定要是资源文件夹的样式,不是普通文件夹的样式,然后将libjnidemo.so文件拷贝到该目录下

导入so文件到java工程的libs目录.png

9.在java代码中通过System.loadLibrary()加载so文件

public class JNIDemo {
    static {
        System.loadLibrary("jnidemo");
    }
    public static native String helloJni();

    public static void main(String[] args) {
        System.out.println(helloJni());
    }
}

10.将该libjnidemo.so库添加到虚拟机运行环境,然后运行java程序

Edit Configurations.png

添加so文件到运行环境.png

值设置为-Djava.library.path=/home/q/IdeaProjects/JNIDemo/libs,等号后面为libjnidemo.so文件所在的路径

main()函数处右键,运行该程序

运行main方法.png

成功输出I am from c++

上面通过一个简单的案例讲解了jni的使用流程,从中不难看出,大部分步骤都是固定的,唯一不固定的是JNIDemo.cpp的内容,这个取决于实际的需求。而在新版的Android Studio当中已经把这些固定流程封装成了模板操作,我们可以一键生成头文件和源文件,开发者只需要关注源文件的功能实现即可。

image-20230203145849108

只需要在新建项目时选择Native C++即可,这里我就不做具体演示了,有兴趣的读者可以自行尝试。

API详解

刚刚我只是简单的返回了一个字符串,实际上我们还可以做很多事情,jni.h都给我们定义好了标准,我们按照它的标准来即可。

开头提到,java和C/C++通信是通过jni来完成的,那么在jni方法中就涉及到对java变量的访问(变量类型包括基本数据类型和引用数据类型),对java方法的调用,java对象的创建等,而java语法跟jni语法不一定是一 一对应的,比如,java中叫boolean,jni中叫jboolean,那怎么解决这个问题呢,jni给我们提供了若干个映射表,将java中的类型与jni中的类型进行了一 一映射,其中包括基本数据类型映射,引用数据类型映射,方法签名(包含参数和返回值)映射,以下是这三个映射表:

表1-基本数据类型映射表

基本数据类型映射表.png

表2-引用数据类型映射表

引用数据类型映射表.png

表3-方法签名

映射表-方法签名

以上面Demo来分析

//Java方法
public static native String helloJni();
public static native float helloJni2(int age, boolean isChild);


//jni方法
extern "C"
JNIEXPORT jstring JNICALL Java_com_jason_jni_JNIDemo_helloJni
        (JNIEnv *env, jclass clazz){
    return env->NewStringUTF("I am from c++");
}

extern "C"
JNIEXPORT jfloat JNICALL Java_com_jason_jni_JNIDemo_helloJni2
        (JNIEnv *env, jclass clazz, jint age, jboolean isChild){
    
}

java方法helloJni()的返回值为String,映射到jni方法中的返回值即为jstring,我们新增一个方法helloJni2(int age, boolean isChild),增加了两个参数intboolean,对应的映射为jintjboolean,同时返回值float映射为jfloat

解决了数据类型不一致的问题之后,接下来就可以在jni方法中访问java成员了,同样的,jni给我们提供了一系列访问java成员的API,具体如下:

jni访问调用对象

方法名作用
GetObjectClass获取调用对象的类,我们称其为target
FindClass根据类名获取某个类,我们称其为target
IsInstanceOf判断一个类是否为某个类型
IsSameObject是否指向同一个对象

jni访问java成员变量的值

方法名作用
GetFieldId根据变量名获取target中成员变量的ID
GetIntField根据变量ID获取int变量的值,对应的还有byte,boolean,long等
SetIntField修改int变量的值,对应的还有byte,boolean,long等

jni访问java静态变量的值

方法名作用
GetStaticFieldId根据变量名获取target中静态变量的ID
GetStaticIntField根据变量ID获取int静态变量的值,对应的还有byte,boolean,long等
SetStaticIntField修改int静态变量的值,对应的还有byte,boolean,long等

jni访问java成员方法

方法名作用
GetMethodID根据方法名获取target中成员方法的ID
CallVoidMethod执行无返回值成员方法
CallIntMethod执行int返回值成员方法,对应的还有byte,boolean,long等

jni访问java静态方法

方法名作用
GetStaticMethodID根据方法名获取target中静态方法的ID
CallStaticVoidMethod执行无返回值静态方法
CallStaticIntMethod执行int返回值静态方法,对应的还有byte,boolean,long等

jni访问java构造方法

方法名作用
GetMethodID根据方法名获取target中构造方法的ID,注意,方法名传<init>
NewObject创建对象

jni创建引用

方法名作用
NewGlobalRef创建全局引用
NewWeakGlobalRef创建弱全局引用
NewLocalRef创建局部引用
DeleteGlobalRef释放全局对象,引用不主动释放会导致内存泄漏
DeleteLocalRef释放局部对象,引用不主动释放会导致内存泄漏

除此之外,jni还提供了异常处理机制,处理方式跟java一样有两种,要么往上(java层)抛,要么自己捕获处理

方法名作用
ExceptionOccurred判断是否有异常发生
ExceptionClear清除异常
Throw往上(java层)抛出异常
ThrowNew往上(java层)抛出自定义异常

API有很多,上述只是列出了一些常用的,其他的可以自行到jni.h文件里去查看。

案例实战

以一个完整的demo来进行综合实战,在实战中感受jni的使用姿势,为了方便,我直接在Android Studio里面创建了一个Native工程。

需求:统计按钮的点击次数

代码如下:

public class MainActivity extends AppCompatActivity {
    private static final String TAG = "jasonwan";
    private TextView tv;

    static {
        System.loadLibrary("jni");
    }

    private int num = 1;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);

        tv = findViewById(R.id.sample_text);
        tv.setOnClickListener(v -> {
            jnitTest()
        });
    }

    //jni测试代码主要在这个方法里面
    public native void jniTest();
}
#include <jni.h>
#include <string>
#include <android/log.h>

#define TAG    "jasonwan" // 这个是自定义的LOG的标识
#define LOGD(...)  __android_log_print(ANDROID_LOG_DEBUG,TAG,__VA_ARGS__) // 定义LOGD类型

/**
 * 尽管java中的jniTest()方法没有参数,但cpp中仍然有两个参数,
 * 参数一:JNIEnv* env表示指向可用JNI函数表的接口指针,所有跟jni相关的操作都需要通过env来完成
 * 参数二:jobject是调用该方法的java对象,这里是MainActivity调用的,所以thiz代表MainActivity
 * 方法名:Java_包名_类名_方法名
 */
extern "C"
JNIEXPORT void JNICALL
Java_com_jason_jni_MainActivity_jniTest(JNIEnv *env, jobject thiz) {
    //获取MainActivity的class对象
    jclass clazz = env->GetObjectClass(thiz);
    //获取MainActivity中num变量id
    /**
    参数1:MainActivity的class对象
    参数2:变量名称
    参数3:变量类型,具体见上《表3-方法签名》
    **/
    jfieldID numFieldId = env->GetFieldID(clazz, "num", "I");
    //根据变量id获取num的值
    jint oldValue = env->GetIntField(thiz, numFieldId);
    //将num变量的值+1
    env->SetIntField(thiz, numFieldId, oldValue + 1);
    //重新获取num的值
    jint num = env->GetIntField(thiz, numFieldId);
    //先获取tv变量id
    jfieldID tvFieldId = env->GetFieldID(clazz, "tv", "Landroid/widget/TextView;");
    //根据变量id获取textview对象
    jobject tvObject = env->GetObjectField(thiz, tvFieldId);
    //获取textview的class对象
    jclass tvClass = env->GetObjectClass(tvObject);
    //获取setText方法ID
    /**
    参数1:textview的class对象
    参数2:方法名称
    参数3:方法参数类型和返回值类型,具体见上《表3-方法签名》
    **/
    jmethodID methodId = env->GetMethodID(tvClass, "setText", "([CII)V");
    //获取setText所需的参数
    //先将num转化为jstring
    char buf[64];
    sprintf(buf, "%d", num);
    jstring pJstring = env->NewStringUTF(buf);
    const char *value = env->GetStringUTFChars(pJstring, JNI_FALSE);
    //创建char数组,长度为字符串num的长度
    jcharArray charArray = env->NewCharArray(strlen(value));
    //开辟jchar内存空间
    jchar *pArray = (jchar *) calloc(strlen(value), sizeof(jchar));
    //将num字符串缓冲到内存空间中
    for (int i = 0; i < strlen(value); ++i) {
        *(pArray + i) = *(value + i);
    }
    //将缓冲的值写入到上面创建的char数组中
    env->SetCharArrayRegion(charArray, 0, strlen(value), pArray);
    //调用setText方法
    env->CallVoidMethod(tvObject, methodId, charArray, 0, env->GetArrayLength(charArray));
    //释放资源
    env->ReleaseCharArrayElements(charArray, env->GetCharArrayElements(charArray, JNI_FALSE), 0);
    free(pArray);
    pArray = NULL;
}

最后的效果是这样的

最终效果

通过这样一个简单的案例,将大部分jni相关的API都练习了一遍,不难看出,java层能实现的功能,在native层一样可以实现,但这里仅仅是为了练习jni,实际项目中不会把一些无关紧要的功能写在native层,比如UI操作,因为同样的功能,java代码要简洁得太多。

上面我们在实现jniTest()时,可以看到c++里面的方法名很长Java_com_jason_jni_MainActivity_jniTest,这是jni静态注册的方式,按照jni规范的命名规则进行查找,格式为Java_类路径_方法名,这种方式在应用层开发用的比较广泛,因为Android Studio默认就是用这种方式,而在framework当中几乎都是采用动态注册的方式来实现java和c/c++的通信。比如之前研究过的《Android MediaPlayer源码分析》,里面就是采用的动态注册的方式。

在Android中,当程序在Java层运行System.loadLibrary("jnitest");这行代码后,程序会去载入libjnitset.so文件。于此同时,产生一个Load事件,这个事件触发后,程序默认会在载入的.so文件的函数列表中查找JNI_OnLoad函数并执行,与Load事件相对,在载入的.so文件被卸载时,Unload事件被触发。此时,程序默认会去载入的.so文件的函数列表中查找JNI_OnLoad函数并执行,然后卸载.so文件。因此开发者经常会在JNI_OnLoad中做一些初始化操作,动态注册就是在这里进行的,使用env->RegisterNatives(clazz, gMethods, numMethods)

  • 参数1:Java对应的类
  • 参数2:JNINativeMethod数组
  • 参数3:JNINativeMethod数组的长度,也就是要注册的方法的个数

JNINativeMethod是jni中定义的一个结构体

typedef struct {
    const char* name; //java中要注册的native方法名
    const char* signature;//方法签名
    void*       fnPtr;//对应映射到C/C++中的函数指针
} JNINativeMethod;

相比静态注册,动态注册的灵活性更高,如果修改了native函数所在类的包名或类名,仅调整native函数的签名信息即可。上述案例改为动态注册,java代码不需要更改,只需要更改native代码

#include <jni.h>
#include <string>
#include <android/log.h>

#define TAG    "jasonwan" // 这个是自定义的LOG的标识
#define LOGD(...)  __android_log_print(ANDROID_LOG_DEBUG,TAG,__VA_ARGS__) // 定义LOGD类型

void native_jniTest(JNIEnv *env, jobject thiz) {
    //获取java类的实例对象
    jclass clazz = env->GetObjectClass(thiz);
    //获取MainActivity中num变量
    jfieldID numFieldId = env->GetFieldID(clazz, "num", "I");
    jint oldValue = env->GetIntField(thiz, numFieldId);
    //将num变量的值+1
    env->SetIntField(thiz, numFieldId, oldValue + 1);
    //重新获取num
    jint num = env->GetIntField(thiz, numFieldId);
    //获取tv控件对象
    jfieldID tvFieldId = env->GetFieldID(clazz, "tv", "Landroid/widget/TextView;");
    jobject tvObject = env->GetObjectField(thiz, tvFieldId);
    jclass tvClass = env->GetObjectClass(tvObject);
    //获取setText方法ID
    jmethodID methodId = env->GetMethodID(tvClass, "setText", "([CII)V");
    //获取setText所需的参数
    //先将num转化为jstring
    char buf[64];
    sprintf(buf, "%d", num);
    jstring pJstring = env->NewStringUTF(buf);
    const char *value = env->GetStringUTFChars(pJstring, JNI_FALSE);
    //创建char数组,长度为字符串num的长度
    jcharArray charArray = env->NewCharArray(strlen(value));
    //开辟jchar内存空间
    jchar *pArray = (jchar *) calloc(strlen(value), sizeof(jchar));
    //将num的值缓冲到内存空间中
    for (int i = 0; i < strlen(value); ++i) {
        *(pArray + i) = *(value + i);
    }
    //将缓冲的值写入到char数组中
    env->SetCharArrayRegion(charArray, 0, strlen(value), pArray);
    //调用setText方法
    env->CallVoidMethod(tvObject, methodId, charArray, 0, env->GetArrayLength(charArray));
    //释放资源
    env->ReleaseCharArrayElements(charArray, env->GetCharArrayElements(charArray, JNI_FALSE), 0);
    free(pArray);
    pArray = NULL;
}

static const JNINativeMethod nativeMethod[] = {
    /*
    参数1:java中要注册的native方法名
    参数2:方法签名
    参数3:对应映射到C/C++中的函数指针
    */
        {"jniTest", "()V", (void *) native_jniTest},
};

//System.loadLibrary()执行时会调用此方法
extern "C"
JNIEXPORT jint JNICALL JNI_OnLoad(JavaVM *vm, void *reversed) {
    JNIEnv *env = NULL;
    // 初始化JNIEnv
    if (vm->GetEnv((void **) &env, JNI_VERSION_1_4) != JNI_OK) {
        return JNI_FALSE;
    }
    // 找到需要动态注册的java类
    jclass jniClass = env->FindClass("com/jason/jni/MainActivity");
    if (nullptr == jniClass) {
        return JNI_FALSE;
    }
    // 动态注册
    if (env->RegisterNatives(jniClass, nativeMethod, sizeof(nativeMethod) / sizeof(nativeMethod[0])) != JNI_OK) {
        return JNI_FALSE;
    }
    // 返回JNI使用的版本
    return JNI_VERSION_1_4;
}

注意,在Android工程中要排除对native方法以及所在类的混淆(java工程不需要),否则要注册的java类和java函数会找不到。proguard-rules.pro中添加

# 设置所有 native 方法不被混淆
-keepclasseswithmembernames class * {
    native <methods>;
}
# 不混淆类
-keep class com.jason.jni.** { *; }

到这里,你应该了解jni的基本使用姿势了,剩下的就是不断的实践来巩固技能。附上Demo源码:gitee.com/jasonwan/JN…

参考文章

JNI方法注册源码分析

NDK 系列(5):JNI 从入门到实践,爆肝万字详解!

基础JNI语法和常见使用