JNI & NDK开发总结

提纲.png

一.JNI与NDK的关系

1.什么是JNI?

JNI(Java Native Interface,Java本地接口),用于打通Java层与Native层。 这不是Android系统所独有的,而是Java所有
  Java语言是跨平台的语言,而这跨平台的背后都是依靠Java虚拟机,虚拟机采用C/C++编写,适配各个系统,通过JNI为上层Java提供各种服务,保证跨平台性。通俗地说,JNI是一种技术,通过这种技术可以做到以下两点: Java程序中的函数可以调用Native语言写的函数; Native程序中的函数可以调用Java层的函数。

1.1.1 正常情况下的Android框架

最顶层是 Android的应用程序代码, 是纯Java代码, 中间有一层的Framework框架层, 通过Framework进行系统调用底层的库 和 linux 内核;

正常情况下的Android框架.jpg
1.1.2.使用JNI时的Android框架

绕过Framework提供的调用底层的代码, 直接调用自己写的C++代码,该代码最终会编译成为一个动态的“.so库(第二张图的Native Libs)”,该动态库可以通过NDK提供的函数等工具,调用底下的C层Native Lib(上图第三层)

使用JNI时的Android框架.png

2.什么是NDK?

NDK(英语:native development kit,原生开发工具包),是一种基于原生程序接口的软件开发工具。通过此工具开发的程序直接以本地语言运行,而非虚拟机。因此只有java等基于虚拟机运行的语言的程序才会有原生开发工具包
  上面我们说过,JNI是Java的一种特性,因此即便没有NDK,我们任然可以用C艹来写我们的应用,那么为什么还要NDK呢?因为在此之前,在Android SDK文档里,找不到任何JNI方面的帮助。即使第三方应用开发者使用JNI完成了自己的C++动态链接库开发,但是so如何和应用程序一起打包成apk并发布?这里面也存在技术障碍。

  • 因此,NDK提供了一系列的工具,帮助开发者快速开发C(或C++)的动态库,并能自动将so和java应用一起打包成apk。这些工具对开发者的帮助是巨大的。
  • NDK集成了交叉编译器,并提供了相应的mk文件隔离CPU、平台、ABI等差异,开发人员只需要简单修改mk文件(指出“哪些文件需要编译”、“编译特性要求”等),就可以创建出so。

二.JNI函数的动态注册方式

1.JNI_OnLoad

在JNI中有一组特殊的函数:

JNIEXPORT jint JNICALL JNI_OnLoad(JavaVM *vm, void *reserved);
JNIEXPORT void JNICALL JNI_OnUnload(JavaVM *vm, void *reserved);

这一组函数的作用就是负责Java方法和本地C函数的链接,其中JNI_OnLoad方法是在动态库被加载时调用(调用“System.loadLibrary("so库名字");的时候”),而JNI_OnUnload则是在本地库被卸载时调用。所以这两个函数就是一个本地库最重要的两个生命周期方法。

2.JNIEnv和JavaVM的区别

JNI_OnLoad方法在动态注册时的全部代码如下:

jint JNI_OnLoad(JavaVM* vm, void* resered){
    JNIEnv* env = NULL;
    if((*vm).GetEnv((void**) &env, JNI_VERSION_1_6)!=JNI_OK){
        return JNI_ERR;
    }

    if(!registerNatives(env)){
        return JNI_ERR;
    }
    return JNI_VERSION_1_6;
}

注意到其中两个类:JavaVM 和 JNIEnv

2.2.1 JavaVM 和 JNIEnv

JavaVM代表java的虚拟机。在java里,每一个process可以产生多个java vm对象,但是在android上,每一个process只有一个Dalvik虚拟机对象,也就是在android进程中是通过有且只有一个虚拟器对象来服务所有java和c/c++代码,这个对象是线程共享的
  JNIEnv(JNI Interface Pointer)是提供JNI Native函数的基础环境,线程相关,不同线程的JNIEnv相互独立

JNIEnv示意图.png

从上图可知,JNIEnv实际上就是提供了一些JNI系统函数。通过这些函数可以做到:

  • 调用Java的函数
  • 操作jobject对象等很多事情。

如果说到这里还不明白的话,没关系,我们之后会讲JNIEnv这个指针的具体用法。

2.2.2 Java和Android中JavaVM对象有区别

在java里,每一个process可以产生多个java vm对象,但是在android上,每一个process只有一个Dalvik虚拟机对象,也就是在android进程中是通过有且只有一个虚拟器对象来服务所有java和c/c++代码。

Android的dex字节码c/c++的.so同时运行Dalvik虚拟机之内,共同使用一个进程空间。之所以可以相互调用,也是因为有Dalvik虚拟机*,Dalvik虚拟机说白了也是一个Android定制版的JVM,谷歌对他做了很多优化和调整(比如将JVM的机制堆栈寻址改为了基于寄存器),因此它也是用C实现的,它的底层调的是第一张图的第三层(系统 Libs)。

java代码需要c/c++代码时,在Dalvik虚拟机加载进.so库时,会先调用JNI_Onload(),此时就会 把JAVA VM对象的指针存储于c层jni组件的全局环境中,在Java层调用C层的本地函数时,调用c本地函数的线程必然通过Dalvik虚拟机来调用c层的本地函数,此时,Dalvik虚拟机会为本地的C组件实例化一个JNIEnv指针,该指针指向Dalvik虚拟机的具体的函数列表

JNI的c组件调用Java层的方法或者属性时,也需要通过JNIEnv指针来进行调用。当本地c/c++想获得当前线程所要使用的JNIEnv时,可以使用Dalvik虚拟机对象的JavaVM jvm->GetEnv()返回当前线程所在的JNIEnv*(上面代码也展示了):

if((*vm).GetEnv((void**) &env, JNI_VERSION_1_6)!=JNI_OK){

其中JNI_VERSION_1_6为JNI版本,这个值可以通过jint GetVersion(JNIEnv *env);来获取当前的JNI版本,返回值是宏定义的常量,我们可以使用获取到的值与下列宏进行匹配来知道当前的版本:

#define JNI_VERSION_1_1 0x00010001
#define JNI_VERSION_1_2 0x00010002
#define JNI_VERSION_1_4 0x00010004
#define JNI_VERSION_1_6 0x00010006

3.动态注册

2.3.1 JNINativeMethod

首先我们来说说JNINativeMethod,就是我们动态注册时建立的函数映射表,将Java代码中的native函数名和native函数对应起来:

static JNINativeMethod methods[] = {
        {
                "nativeBaseDataType",  //Java代码中的native函数的名字
                "(SIJFDCZ)V",   //方法的签名信息,主要是参数+返回值,V表示返回类型为void
                (void*)baseDataType //函数指针,指向一个native中定义的C++函数
        }
}

上面建立了两个函数对应关系,也就是JNINativeMethod数组中的两个结构体,每个结构体有三个成员,第一个为Java代码中的native函数的名字,第三个为native中对应被调用的函数名字,我们来看看这两个函数:

java代码中:
public static native void nativeBaseDataType(
                short s, int i, long l, float f, double d, char c, boolean b );
C++代码中:
void baseDataType(JNIEnv* jniEnv,jobject jobj,jshort s,
                    jint i, jlong l, jfloat f, jdouble d, jchar c, jboolean b){
    ELOG("LearnJNI.cpp --> baseDataType:%d,%f,%c,%d",i,d,c,b);
}

这个方法展示了Java层向C++层传递基本类型数据。可以看到JNINativeMethod数组中结构体的第一个元素和第三个元素就是这两个地方方法的名字(其中第三个元素前面必须加上(void*))

我们再来看看结构体中第二个元素,(SIJFDCZ)V,这就是Java代码中对应的JNI函数的签名,也就是参数类型+返回值类型,只是这个类型有一定的对应关系:

因此public static native void nativeBaseDataType(short s, int i, long l, float f, double d, char c, boolean b );对应(SIJFDCZ)Vpublic static native String nativeReturnString();对应()Ljava/lang/String;
  同时应当注意,jni函数签名中。参数类型之间不用“;”隔开(除了String类型的标识为“ Ljava/lang/String; ”自带“;”),直接连在一起就可以了,同时数组标识是在元素类型前面加“[”,如int[]标识为“[I”.

2.3.2 RegisterNatives

接着2中的那段代码,看看“registerNatives()”:

static int registerNatives(JNIEnv *env) {
    const char *className = "com/example/dell/growup/LearnJNI"; //指定注册的类
    return registerNativeMethods(env, className, methods, sizeof(methods) / sizeof(methods[0]));
}
static int registerNativeMethods(JNIEnv *env, const char *className, JNINativeMethod *gMethods,
                                 int numMethods) {
    jclass clazz;
    clazz = (*env).FindClass(className);
    if (clazz == NULL) {
        return JNI_FALSE;
    }

    if ((*env).RegisterNatives(clazz, gMethods, numMethods) < 0) {
        return JNI_FALSE;
    }
    return JNI_TRUE;
}

可以看到,这里JNIEnv env排上用场了,首先className中我们指定了注册native函数的包名+类名,接着我们调用JNIEnv的FindClass*方法clazz = (*env).FindClass(className);来获取注册native函数的类,之后通过JNIEnv调用JNI函数 (*env).RegisterNatives(clazz, gMethods, numMethods) 来注册上面JNINativeMethod中定义的对应函数。

当Java层通过System.loadLibrary加载完JNI动态库后,紧接着会查找该库中一个叫JNI_OnLoad的函数,如果有,就调用它,而动态注册的工作就是在这里完成的。
  所以,如果想使用动态注册方法,就必须要实现JNI_OnLoad函数,在这个函数中会完成动态注册的工作。静态注册则没有这个要求,一般我们可以自己实现这个JNI_OnLoad函数,并在其中做一些初始化工作(即使是静态注册)。

我们再贴一遍JNI_OnLoad()函数代码:

//该函数的第一个参数类型为JavaVM,是虚拟机在JNI层的代表,每个Java进程只有一个
jint JNI_OnLoad(JavaVM* vm, void* resered){
    JNIEnv* env = NULL;
    if((*vm).GetEnv((void**) &env, JNI_VERSION_1_6)!=JNI_OK){
        return JNI_ERR;
    }

    if(!registerNatives(env)){
        return JNI_ERR;
    }
    return JNI_VERSION_1_6;
}

三.JNIEnv的各种操作

1.基本类型数据处理

其实基本类型处理上面我们已经展示过了,这里再贴一遍代码:

java代码中:
public static native void nativeBaseDataType(
                                short s, int i, long l, float f, double d, char c, boolean b);
C++代码中:
void baseDataType(JNIEnv* jniEnv,jobject jobj,
                            jshort s,jint i, jlong l, jfloat f, jdouble d, jchar c, jboolean b){
    ELOG("LearnJNI.cpp --> baseDataType:%d,%f,%c,%d",i,d,c,b);
}

可以看到,java中的基本数据类型,在native中基本上就是在前面加了个“j”,实际上也就是在“jni.h”(NDK提供的jni开发类)中给java中的基本类型用结构体包装类一下:

typedef uint8_t  jboolean; /* unsigned 8 bits */
typedef int8_t   jbyte;    /* signed 8 bits */
typedef uint16_t jchar;    /* unsigned 16 bits */
typedef int16_t  jshort;   /* signed 16 bits */
typedef int32_t  jint;     /* signed 32 bits */
typedef int64_t  jlong;    /* signed 64 bits */
typedef float    jfloat;   /* 32-bit IEEE 754 */
typedef double   jdouble;  /* 64-bit IEEE 754 */

从Java层往C++层传递基本类型数据是很容易的,只要上面函数的映射关系建立好了,就跟普通函数传参一样。

2.String字符串处理

这里我们展示一个从java向C++传基本类型、String、数组,并从C++中像向Java中传递String的例子:

Java代码中:
void returnString(){
        int i =3;
        char[] c = {'J','N','I'};
        String s = "learn";

        String string = nativeReturnJavaString(i,s,c);
        Log.e("returnString",string);
    }
public static native String nativeReturnJavaString(int i, String s, char[] c);
C++代码:
static JNINativeMethod methods[] = {
    {
                "nativeReturnJavaString",
                "(ILjava/lang/String;[C)Ljava/lang/String;",  //注意多种数据类型签名时中间没有分号或逗号
                (void*)returnJavaString
        }
}

jstring returnJavaString(JNIEnv *jniEnv, jobject jobj, jint i, jstring j_str, jcharArray j_char){
    const char* c_str = NULL;
    jchar* j_charArray = NULL;
    jint arr_len;
    jint str_len;
    char buff[120] = {0};
    jboolean isCopy;

    arr_len = (*jniEnv).GetArrayLength(j_char);// 获取char数组长度
    str_len = (*jniEnv).GetStringLength(j_str);// 获取String长度

    j_charArray = (jchar*)malloc(sizeof(jchar)* arr_len);   // 根据数组长度和数组元素的数据类型申请存放java数组元素的缓冲区
    memset(j_charArray, 0,sizeof(jchar)* arr_len ); // 初始化缓冲区
    (*jniEnv).GetCharArrayRegion(j_char, 0, arr_len, j_charArray);  // 拷贝Java数组中的所有元素到缓冲区中

    c_str = (*jniEnv).GetStringUTFChars(j_str, &isCopy);
    sprintf(buff, "%s", c_str);   //sprintf(s, "%d", 123);  //把整数123打印成一个字符串保存在s中
    for(int j=0; j<i; j++){
        buff[str_len+j] = (char) j_charArray[j];
        //ELOG("LearnJNI.cpp --> returnJavaString:%c",buff[str_len+j]);
    }

    free(j_charArray);  // 释放存储数组元素的缓冲区
    (*jniEnv).ReleaseStringUTFChars(j_str,c_str);

    return jniEnv->NewStringUTF(buff);
}

我们知道,Java中String是一个对象,他申明的对象是存放在JVM内部数据结构中的(堆,栈,静态区)。 JNI把Java中的所有对象当作一个C指针(对象的引用)传递到本地方法中,这个指针指向JVM中的内部数据结构(即这个对象存放的地址),而内部的数据结构在内存中的存储方式是不可见的。只能从JNIEnv指针指向的函数表中选择合适的JNI函数来操作JVM中的数据结构。

访问java.lang.String对应的JNI类型jstring时,没有像访问基本数据类型一样直接使用,因为它在Java是一个引用类型,所以在本地代码中只能通过GetStringUTFChars这样的JNI函数来访问字符串的内容。

3.2.1 const char* GetStringUTFChars(env, j_str, &isCopy)
  env:JNIEnv函数表指针
  j_str:jstring类型(Java传递给本地代码的字符串指针)
  isCopy:取值JNI_TRUE和JNI_FALSE,如果值为JNI_TRUE,表示返回JVM内部源字符串的一份拷贝,并为新产生的字符串分配内存空间。如果值为JNI_FALSE,表示返回JVM内部源字符串的指针,意味着可以通过指针修改源字符串的内容,不推荐这么做,因为这样做就打破了Java字符串不能修改的规定。但我们在开发当中,并不关心这个值是多少,通常情况下这个参数填NULL即可。

因为Java默认使用Unicode编码,而C/C++默认使用UTF编码,所以在本地代码中操作字符串的时候,必须使用合适的JNI函数把jstring转换成C风格的字符串。JNI支持字符串在Unicode和UTF-8两种编码之间转换,GetStringUTFChars可以把一个jstring指针(指向JVM内部的Unicode字符序列)转换成一个UTF-8格式的C字符串

3.2.2 jstring NewStringUTF(const char* bytes)

通过调用NewStringUTF函数,会构建一个新的java.lang.String字符串对象。这个新创建的字符串会自动转换成Java支持的Unicode编码。

3.2.3 void ReleaseStringUTFChars(jstring string, const char* utf)

在调用GetStringUTFChars函数从JVM内部获取一个字符串之后,JVM内部会分配一块新的内存,用于存储源字符串的拷贝,以便本地代码访问和修改。即然有内存分配,用完之后马上释放.通过调用ReleaseStringUTFChars函数通知JVM这块内存已经不使用了,你可以清除了。注意:这两个函数是配对使用的,用了GetXXX就必须调用ReleaseXXX,而且这两个函数的命名也有规律,除了前面的Get和Release之外,后面的都一样。

3.访问数组

  访问数组的例子上面已经展示过了,这里主要说明一下几个访问数组相关的函数的用法。

3.3.1 jsize GetArrayLength(jarray array)

  获取数组的长度,返回值为jint类型,我们获取数组的类型之后就可以调用malloc函数动态的分配内存:

jint arr_len;
arr_len = (*jniEnv).GetArrayLength(j_char);

jchar* j_charArray = NULL;
j_charArray = (jchar*)malloc(sizeof(jchar)* arr_len);
memset(j_charArray, 0,sizeof(jchar)* arr_len );

  我们需要一个jchar* 类型的指针指向我们刚分配的内存(的首地址),以便之后调用 memset函数的时候找到这块内存并将其中的字节初始化为0(申请的内存中可能有之前残余的值),我们来看看这个memset函数:

memset() 函数用来将指定内存的前n个字节设置为特定的值(经常用于初始化刚刚申请的内存),其原型为:
    void * memset( void * ptr, int value, size_t num );

参数说明:
ptr 为要操作的内存的指针。
value 为要设置的值。你既可以向 value 传递 int 类型的值,也可以传递 char 类型的值,
                int 和 char 可以根据 ASCII 码相互转换。
num 为 ptr 的前 num 个字节,size_t 就是unsigned int。
3.3.2 void GetCharArrayRegion(jcharArray array, jsize start, jsize len,jchar* buf)
...
j_charArray = (jchar*)malloc(sizeof(jchar)* arr_len);
memset(j_charArray, 0,sizeof(jchar)* arr_len ); // 初始化缓冲区
(*jniEnv).GetCharArrayRegion(j_char, 0, arr_len, j_charArray);  // 拷贝Java数组中的所有元素到缓冲区中

c_str = (*jniEnv).GetStringUTFChars(j_str, &isCopy);
sprintf(buff, "%s", c_str);     //sprintf(s, "%d", 123);  //把整数123打印成一个字符串保存在s中

  GetCharArrayRegion将Java数组j_char,拷贝到我们刚刚申请的内存j_charArray中,之后我们调用c_str = (*jniEnv).GetStringUTFChars(j_str, &isCopy);将java层传来的 j_str(String类型)转换成一个C风格字符串(c_str),然后sprintf(buff, "%s", c_str);是将作为C风格的字符串储存在buff数组中。

推荐阅读更多精彩内容