JNI常用开发技巧

923 阅读7分钟

前言

  Android现在要么越来越前端化-各种跨端框架,要么越来越底层化-framwork/驱动开发/音视频。我不太喜欢纯前端的东西,正好工作需要,我最近开始逐步学习音视频开发(其实更多的是音频),要做好做深Android音视频开发,JNI是必不可少的技能。下面我就把Android JNI开发所需要用到的一些技巧讲一讲。

一、基础知识

1.1、创建一个JNI项目

  其实在AndroidStudio中和我们创建一个普通的app没什么区别,到我们创建项目选择项目类型界面找到native c++项目创建即可

image.png  相较于普通的app项目,它多了一个cpp文件夹,里面有常用的头文件(不用管)和CMakeList.txt,它算是以前MK文件的替代,相对来说它更加的简单清晰,至于怎么编写官方有详细文档,内容比较多,CMAKE 官方手册不做过多赘述,以及native-lib.cpp这是官方给我们创建的一个helloworld,下面会详细讲到。

image.png 在它的MainActivity里多了一个stringFromJNI()方法,这是一个待实现的接口方法,就是给到Native层去实现,注意一点Kotlin中的标识符是external在Java中它是native

image.png

1.2、JNI中的数据类型

查看jni头文件会发现JNI层对应的Java层的基本数据类型如下图,从名称就可以看出 boolean byte等等
image.png
其它对象类型。例如object class string array等这些常用的对象是C++对象去实现的,定义的是这些对象指针。 image.png

1.3、两个重要的结构体

1.3.1 JNIEnv

  我们在Native层的大部分和Java层的操作都通过它,它定义了各种Java层Native层的基本数据类型和对象类型,类型转换,Java层的方法调用等。它的全部方法定义在jni.hstruct JNINativeInterface中,例如常见的我们在native层拿到Java层的String对象转为C语言的char*,完成字符串处理后在把char* 转为Java层能识别的jstring,我们都要用到JNIEnv结构体中的方法。下图是我截取其中部分方法。 image.png

1.3.2 JavaVM

JavaVM是Native层虚拟机栈的代表,它进程中唯一,主要作用是协调JNIEnv和线程间的关系,它的主要方法如下图,具体的用法会在第四节指出。 image.png

1.4、两个重要的方法

1.4.1、JNI_OnLoad

这个方法是在我们加载库的时候会调用的方法,也就是在调用

System.loadLibrary("native-lib")

在这里我们可以做一些初始化的工作,也可以获取到JavaVM并保存起来

JNIEXPORT jint JNICALL JNI_OnLoad(JavaVM* vm, void* reserved){
    JNIEnv* jniEnv= NULL;
    int result=(*vm)->GetEnv((vm),(void **)&jniEnv,JNI_VERSION_1_4);
    if (result!=JNI_OK){
        return -1;
    }
    javaVm = vm;//存起来可以后续使用
    return JNI_VERSION_1_4;
}

1.4.2、JNI_OnUnload

当我们的库卸载时会被调用,做一些收尾工作,具体怎么销毁看你的业务

二、Java Call Native

我们看看官方的helloworld

extern "C" JNIEXPORT jstring JNICALL
Java_com_example_myapplication3_MainActivity_stringFromJNI(
        JNIEnv* env,
        jobject /* this */) {
    std::string hello = "Hello from C++";
    return env->NewStringUTF(hello.c_str());
}

  extern "C"是C++代码需要标明的,主要是一个C和C++的兼容性,如果是C代码可不用该标识符。 JNIEXPORTJNICALL是固定标识符,jstring是返回参数类型,需要是jni层的类型Java_com_example_myapplication3_MainActivity_stringFromJNI是我们MainAtivity中的stringFromJNI的方法签名,AndroidStudio很人性的只要我们输入stringFromJNI后它就会自动补全方法签名,其实你仔细对比也能看出怎么个签名法:Java_包名_类名_方法名
在这个方法里,定义一个Hello from C++字符串,调用JNIEnv的NewStringUTF方法将native的字符指针转为Java层能识别的jstring类型返回出去。这里是用的C++,而NewStringUTF接受的是char* 类型,所以C++的string还得调用c_str()方法转char* ,如果你用C语言就没有这一步.

三、Native Call Java

3.1、接口回调

先看看我的Java层怎么写:

public interface IProgressListener{
    void onProgress(long recorderMs); 
}
public native void addProgressListener(IProgressListener listener);

Java层其实和普通的接口回调方法的写法一样,再结合native或者external标志符嘛。
再看看我们native层怎么写

JNIEXPORT void JNICALL Java_cn_zybwz_binmedia_OpenSLRecorder_addProgressListener(JNIEnv *env, jobject thiz, jobject listener) {                                                 
    jclass listenerClass=(*env)->GetObjectClass(env,listener);           
    progressListenerId=(*env)->GetMethodID(env,listenerClass,"onProgress", "(J)V"); 
    progressListenerObject=(*env)->NewGlobalRef(env,listener);//
    (*env)->CallVoidMethod(env,progressListenerObject,progressListenerId,(jlong)recorder_ms);
    (*env)->DeleteGlobalRef(env,progressListenerObject);
 }

首先流程上来讲

graph TD
Start --> 通过jobject获得jclass对象 --> 通过jclass获得MethodID --> 通过CallXXXMethod调用对应MethodID的方法 -->Stop

  其中最主要的是怎么正确的获得MethodID,其实主要是通过GetMethodID后面的两个参数决定的,首先是方法名onProgress,其次是入参和出参列表,这个(J)V我们可能不是很习惯,(J)是入参类型代表long,V是出参类型代表void,具体的对照表在这
你会发现我流程中少说了两行代码,因为这两行代码可以拿出来讲,这是特别注意的地方,不然你在调用时程序很有可能崩溃。我们传进来的jobjec listener整个生命周期就只在这个方法内,这个方法结束这个listenr对象也就销毁了,哪怕我们用外部的变量接收也不行,因为这个对象所指向的地址已经被清空了,所以需要通过NewGlobalRef来实例化一个全局变量,同时相应的通过DeleteGlobalRef来手动销毁这个全局变量。毕竟我们的回调很多时候都不是在一个方法内,那不然我们还不如return方便。

3.2、调对象方法

我们在这里通过native层调用Java层的方法打印一条log
先看看我的Java层怎么写的

public class C2J {
    public void LogError(String error){
        Log.e("C2J", "LogError: "+error );//简单的打印一条错误信息
    }
}

再看看我的native层怎么写

JNIEXPORT jstring JNICALL Java_com_example_jnidemo_MainActivity_stringFromJNI(
        JNIEnv* env,
        jobject thiz) {
    jclass clazz=(*env)->FindClass(env,"com/example/jnidemo/C2J");
    jmethodID cmethodId=(*env)->GetMethodID(env,clazz,"<init>", "()V");
    jobject j=(*env)->NewObject(env,clazz,cmethodId);
    jmethodID methodId=(*env)->GetMethodID(env,clazz,"LogError", "(Ljava/lang/String;)V");
    (*env)->CallVoidMethod(env,j,methodId,(*env)->NewStringUTF(env,"err"));
}

同样分析它的流程

graph TD
Start --> FindClass --> 获取构造方法MethodId --> NewObject通过构造方法Id实例化对象 --> 获取我们要调用的方法的MethodId --> 通过对象和待调用的方法Id调用Java层方法 --> Stop

3.3 小总结一点

其实不只CallVoidMethod能调用方法,根据被调用方法的入参类型和返回值类型有对应的调用方法。

  • 调用Java非静态方法

image.png

  • 调用Java静态方法

image.png   由此可见Native和Java层的接口回调在这里我们至少掌握了两种方法,一种是保存接口对象在合适的时候和地方调用方法,也可以直接拿到Java层的静态方法进行通知,静态方法内我们能做的使很多,例如发送一个广播,调用一个观察者的通知方法等等。

四、Native层的线程注意事项

 本节重点不在C/C++的线程知识点,而是讲在线程中调用JNIEnv会遇到的问题和需要怎么规避。首先从原理上来讲,JNIEnv是每个JNI方法都会传入的一个对象,它是Java和Native联系的一个上下文,它是线程相关的,或者说它是依附于线程上的,所以在不同的线程去操作JNIEnv其实是不可见的,甚至可能该线程并没有一个JNIEnv,所以在调用时会崩溃。例如我们普通的C/C++创建一个线程,那我们怎么在子线程去获得一个JNIEnv呢?这时就需要用到我们的JavaVM了,JavaVM从它名字就可以看出它是虚拟机栈相关的,每个进程都只有一个虚拟机栈,它存有线程和JNIEnv的信息,所以我们需要通过它拿到JNIEnv并使它依附于当前线程上才能使得JNIEnv在当前线程可用。具体方法如下:

JNIEnv* jniEnv=NULL;
javaVm->GetEnv((void **)jniEnv,JNI_VERSION_1_4);
javaVm->AttachCurrentThread(&jniEnv,NULL);
//使用jniEnv DO some thing
javaVm->DetachCurrentThread();

  首先这个javaVm对象就是我们前面在两个重要方法之一的JNI_OnLoad方法处存起来的全局变量。GetEnv获得JNIEnv,AttachCurrentThread将当前线程和JNIEnv绑定,此时JNIEnv就可以正常使用了,用完之后记得把它从当前线程取消绑定。

五、最后

  上面的代码一部分来自我的GitHub的准备做的一个音频项目,想看更具体的代码也可以在上面看。这也是我学习音视频的练手项目,正在一步步搭建中,有兴趣的可以一起探讨。
项目地址
以上是我个人的理解和总结,不尽正确,希望大家批评指正。