NDK 开发之 JNI 概述

1. NDK 概述

1.1 NDK 定义

Android 官网对 NDK 的定义 (https://developer.android.com/ndk):

Android NDK (Native Development Kit) 是一个工具集,可以使用 C 和 C++ 等语言以原生代码实现应用的各个部分。对于特定类型的应用,这可以重复使用以这些语言编写的代码库。

1.2 NDK 优势

使用 NDK 主要有这几个好处:

(1)可以在平台之间移植其应用。

(2)可以重复使用现有库,或者提供其自己的库供重复使用。

(3)在某些情况下可以提高性能,特别是像游戏这种计算密集型应用。

(4)可以增强代码 C/C++ 库的安全性,因为 相比 Java 代码,C/C++ 反编译难度更大。

(5)可以使用优秀的由 C/C++ 编写的第三方库。

1.3 NDK 组件

在进行 NDK 开发之前,需要了解以下几个组件:

(1)Native shared library (原生共享库):NDK 可以将 C/C++ 代码编译成.so动态库文件。

(2)Native static library (原生静态库):NDK 可以将 C/C++ 代码编译成.a静态库文件。

(3)Application Binary Interface (ABI):ABI 可以精确地定义应用的机器代码在运行时应该如何与系统交互。不同的 ABI 对应不同的架构:NDK 为 32 位 ARM,AArch64,x86 及 x86-64 提供 ABI 支持。

(4)Manifest(清单):如果编写的应用不包含 Java 组件,则必须在清单中声明 NativeActivity 类。

(5)CMake:一款外部编译工具,可与 Gradle 搭配使用来编译 native 库。如果只计划使用 ndk-build,则不需要此组件。

(6)LLDB:Android Studio 用于调试 natvie 代码的调试程序。

(7)Java Native Interface (JNI):JNI 是 Java 和 C++ 组件互相通信的接口。它定义了 Android 从受管理代码(使用 Java 或 Kotlin 编程语言编写)编译的字节码与原生代码(使用 C/C++ 编写)互动的方式。JNI 不依赖于供应商,支持从动态共享库加载代码,虽然较为繁琐,但有时相当有效。Java 调用 C/C++ 并不是 Android 所独有的,只是 Java 语言原本就支持的,只是 Android 中的 JNI 使用更简单一些罢了。在 Oracle 官网 有对 JNI 规范有详细的说明(https://docs.oracle.com/javase/7/docs/technotes/guides/jni/spec/jniTOC.html)。

1.4 NDK 开发流程

NDK 的开发流程如下:

(1)创建一个 Android 应用项目。

(2)在 AndroidManifest.xml 中声明 NativeActivity 类。

(3)在“jni”目录中创建一个描述 native 库的 Android.mk 文件,包括名称、标记、关联库和要编译的源文件。也可以创建一个配置目标 ABI、工具链、发布/调试模式和 STL 的 Application.mk 文件。

(4)将 native 源码放在项目的 jni 目录下。

(5)使用 ndk-build 编译原生(.so、.a)库。

(6)编译 Java 组件,生成可执行 .dex 文件。

(7)将所有内容封装到一个 APK 文件中,包括 .so,.dex 以及应用运行所需的其他文件。

2. JNI 概述

2.1 JNI 类型

2.1.1 基本数据类型
JNI 类型 Java 类型  描述
jboolean boolean unsigned char (8 bits)
jbyte byte signed char (8 bits)
jchar char unsigned short (16 bits)
jshort short signed short (16 bits)
jint int signed int (32 bits)
jlong long signed long (64 bits)
jfloat float 32 bits
jdouble double 64 bits
void void -
2.1.2 引用类型
JNI 类型 Java 类型  描述
jobject Object all jave objects
jclass class java.lang.Class objects
jstring String java.lang.String objects
jobjectArray Object[] object arrays
jbooleanArray boolean[] boolean arrays
jbyteArray byte[] byte arrays
jcharArray char[] char arrays
jshortArray short[] short arrays
jintArray int[] int arrays
jlongArray long[] long arrays
jfloatArray float[] float arrays
jdouble double[] double arrays
jthrowable Throwable -

2.2 JNI 描述符

2.2.1 field 描述符 - jfieldID

基本数据类型描述符对应关系如下:

Field Descriptor Java 类型
Z boolean
B byte
C char
S short
I int
J long
F float
D double

引用类型描述符规则通常是:”L + class 描述符 + ;”,比较特殊的是数组域描述符,其规则是:”[ + 类型域描述符”,有多少级数组就有多少个”[“,只有当数组类型为class时才需要加上”;”,如:

Field Descriptor Java 类型
[I int[]
[D double[]
[[I int[][]
[[D double[][]
[Ljava/lang/String; String[]
[Ljava/lang/Object; Object[]

使用 GetFieldID 或 GetObjectField 获取字段;

1
2
3
4
5
6
7
8
9
10
// C
jfieldID (*GetFieldID)(JNIEnv*, jclass, const char*, const char*);
jobject (*GetObjectField)(JNIEnv*, jobject, jfieldID);
// C++
jfieldID GetFieldID(jclass clazz, const char* name, const char* sig) {
return functions->GetFieldID(this, clazz, name, sig);
}
jobject GetObjectField(jobject obj, jfieldID fieldID) {
return functions->GetObjectField(this, obj, fieldID);
}
2.2.2 class 描述符 - jclass

class 描述符是 class 的完整名称:包名 + 类名。与java中用”.”分割包名不同的是,在 JNI 中使用”/“分割包名。使用 FindClass 获取类的类对象引用;

1
2
3
4
5
6
// C
jclass (*FindClass)(JNIEnv*, const char*);
// C++
jclass FindClass(const char* name) {
return functions->FindClass(this, name);
}
2.2.3 method 描述符 - jmethodID

JNI 通过方法名和方法描述符关联 Java 中的方法,方法描述符由参数和返回值两部分组成,参数由”()”表示,括号里是参数的类型描述符,”()”后面接返回值的类型描述符,”V”表示返回值为空。

Method 描述 Java 方法
()Ljava/lang/String; String f();
(ILjava/lang/Class;)J long f(int i, Class c);
([B)V” void f(byte[] bytes);

使用 GetMethodID 获取 method:

1
2
3
4
5
6
// C
jmethodID (*GetMethodID)(JNIEnv*, jclass, const char*, const char*);
// C++
jmethodID GetMethodID(jclass clazz, const char* name, const char* sig) {
return functions->GetMethodID(this, clazz, name, sig);
}

由于每个进程只能包含一个 JavaVM,因此将这些数据存储在静态本地结构中是一种合理的做法。

在取消 load 类之前,类引用、字段 ID 和方法 ID 需保证有效。只有在与 ClassLoader 关联的所有类可以进行垃圾回收时,系统才会取消 load 类。需要注意的是,jclass 是类引用,必须通过调用 NewGlobalRef 来保护它。

若想在 load 类时缓存方法 ID,并在取消 load 类后重新 load 时自动重新缓存方法 ID,可参考下面的代码在查找 ID 时创建 nativeInit()

1
2
3
4
private static native void nativeInit();
static {
nativeInit();
}

2.3 JNI 引用

JNI 规范中定义了三种引用:局部引用(Local Reference)、全局引用(Global Reference)、弱全局引用(Weak Global Reference)。

2.3.1 局部引用(Local Reference)

局部引用,也称本地引用,通常是在函数中创建并使用,会阻止GC回收所有引用对象。传递给 native 方法的每个参数,以及 JNI 函数返回的几乎每个对象都属于”局部引用”。即局部引用在当前线程中的当前 native 方法运行期间有效。在 native 方法返回后,即使对象本身继续存在,该引用也无效。适用于 jobject 的所有子类,包括 jclass、jstring 和 jarray。

2.3.2 全局引用(Global Reference)

全局引用可以跨方法、跨线程使用,直到被开发者显式释放。全局引用在被释放前引用对象不被GC回收。如果希望长时间保留某个引用,则必须使用”全局引用”。NewGlobalRef 函数将局部引用作为参数,然后返回全局引用。在调用 DeleteGlobalRef 之前,全局引用保证有效。释放它需要使用 ReleaseGlobalRef 函数。

2.3.3 弱全局引用(Weak Global Reference)

与全局引用类似,创建跟删除都需要由编程人员来进行,不一样的是,弱引用不会阻止垃圾回收这个引用所指向的对象,所以在使用时需要多加小心,它所引用的对象可能是不存在的或者已经被回收。通过使用 NewWeakGlobalRef、ReleaseWeakGlobalRef 来产生和释放引用。

所有 JNI 方法都接受局部和全局引用作为参数。对同一对象的引用可能具有不同的值。对同一对象连续调用 NewGlobalRef 所返回的值可能有所不同。要了解两个引用是否引用同一对象,必须使用 IsSameObject 函数。 切勿在原生代码中使用 == 比较各个引用。

2.4 JNI 注册

在 Java 中执行 native 代码之前需注册 native 方法。JNI 支持静态和动态两种注册方式。

2.4.1 静态注册

静态注册就是根据函数名建立 Java 方法和 JNI 函数的一一对应关系。其大致流程如下:

(1)编写 Java 的 native 方法;
(2)用 javah 工具生成对应的头文件,执行命令”javah packag.class”可以生成由”包名+类名”的 jni 头文件;
(3)实现 JNI 函数,再在 Java 中通过 System.loadLibrary 加载”.so”库;

JNI 的调用函数的命名通常采用如下规则:

1
JNIEXPORT 返回值 JNICALL Java_全路径类名_方法名_参数(JNIEnv* , jclass, 其它参数);

其中,”Java_”是为了标识该函数来源于 Java。如果使用的是 C++,在函数前加上 extern “C”(表示按照 C 的方式编译),函数命名后面就不需要加上”参数”。

2.4.2 动态注册

动态注册是告诉 native 方法其在 JNI 中对应函数的指针。通过使用 JNINativeMethod 结构来保存 Java 的 native 方法和 JNI 函数对应关系。 大致流程如下:

(1)编写 Java 的 native 方法;
(2)编写 JNI 函数的实现;
(3)利用结构体 JNINativeMethod 保存 Java native 方法和 JNI 函数的对应关系;
(4)利用 registerNatives(JNIEnv* env) 注册类的本地方法;
(5)在 JNI_OnLoad 方法中调用注册方法;
(6)在 Java 中通过 System.loadLibrary 加载完 JNI 库之后,会调用 JNI_OnLoad 函数,完成动态注册。

2.5 JavaVM 和 JNIEnv

JNI 定义了两个关键数据结构:JavaVM 和 JNIEnv,两者都是指向函数表的二级指针。在 C++ 中,它们具有指向函数表的指针,并具有每个通过该函数表间接调用的 JNI 函数的成员函数。

2.5.1 JavaVM

Java 的 Runtime 环境是 Java 虚拟机 (JVM),每个 JVM 虚拟机进程在本地环境中都有一个 JavaVM 结构体,该结构体在创建 Java 虚拟机时被返回。JNI 中创建 JVM 的函数是”JNI_CreateJavaVM”。JavaVM 提供“调用接口”函数,允许创建和销毁 JavaVM。理论上每个进程可以有多个 JavaVM,但 Android 只允许有一个 JavaVM。

1
JNI_CreateJavaVM(JavaVM **pvm, void **penv, void*args);
2.5.2 JNIEnv

JNIEnv 是当前 Java 线程的 Runtime 环境,一个进程对应一个 JavaVM 结构, 一个 JVM 中可能存在多个 Java 线程,每个线程对应一个 JNIEnv 结构, 它们被保存在每个线程的本地存储TLS中,因此无法在线程之间共享 JNIEnv。如果一段代码无法通过其他方法获取自己的 JNIEnv,则应该共享相应 JavaVM,然后使用 GetEnv 查找线程的 JNIEnv。JNIEnv 提供了大部分 JNI 函数。native 函数会以 JNIEnv 作为第一个参数。

JNIEnv 和 JavaVM 的 C 声明与 C++ 声明不同。”jni.h” 文件会提供不同的类型定义typedef,因此不建议在这两种语言包含的头文件中添加 JNIEnv 参数。即:如果头文件需要 “#ifdef __cplusplus”,且该头文件中的所有内容都引用 JNIEnv,则需要进行一些额外操作。

JNIEnv 的创建与释放 - C

(1)创建: 调用结构体 JNIInvokeInterface 中的 “(AttachCurrentThread)(JavaVM, JNIEnv*, void)” 方法,能够获得 JNIEnv 结构体。

(2)释放:调用结构体 JNIInvokeInterface 中的 “(DetachCurrentThread)(JavaVM)” 方法,能够释放本线程的 JNIEnv 结构体。

JNIEnv 的创建与释放 - C++

(1)创建:调用结构体 _JavaVM 中的 “jint AttachCurrentThread(JNIEnv* p_env, void thr_args)” 方法,能够获得 JNIEnv 结构体。

(2)释放:调用结构体 _JavaVM 中的 “jint DetachCurrentThread(){ return functions->DetachCurrentThread(this); }” 方法,能够释放本线程的 JNIEnv 结构体。

线程通常使用 Thread.start启动,也可以在其他位置创建,然后附加到 JavaVM。在附加之前,线程不包含任何 JNIEnv,也无法调用 JNI。附加 native 创建的线程会构建 java.lang.Thread 对象并将其添加到“main” ThreadGroup,从而使 debugger 能够看到它。在已附加的线程上调用 AttachCurrentThread 属于空操作。Android 不会挂起执行 native 代码的线程。如果正在进行垃圾回收,或者debugger已发出挂起请求,则在线程下次调用 JNI 时,Android 会将其挂起。通过 JNI 附加的线程在退出之前必须调用 DetachCurrentThread。

2.6 JNIEnv 常用函数

2.6.1 创建 Object 对象

第1个参数”jclass”代表所创建类的对象,第2个参数”jmethodID”代表所使用构造方法的ID。只要有jclass和jmethodID,就可以在本地方法创建该Java类的对象。

1
2
3
jobject NewObject(JNIEnv *env, jclass clazz,jmethodID methodID, ...):
jobject NewObjectA(JNIEnv *env, jclass clazz,jmethodID methodID, const jvalue *args):
jobject NewObjectV(JNIEnv *env, jclass clazz,jmethodID methodID, va_list args):
2.6.2 创建 String 对象

通过 Unicode 字符的数组来创建一个新的 String 对象。env 是 JNI 接口指针,unicodeChars 是指向 Unicode 字符串的指针,len 是 Unicode 字符串的长度。返回值是 Java 字符串对象,如果无法构造该字符串,则为 null。

1
2
jstring NewString(JNIEnv *env, const jchar *unicodeChars,jsize len):
jstring NewStringUTF(JNIEnv *env, const char *bytes)
2.6.3 创建 PrimitiveType 数组

构造一个指定长度,返回相应的Java基本类型的数组:

1
ArrayType New<PrimitiveType>Array(JNIEnv *env, jsize length);

用于构造一个原始类型的数组对象:

方法 返回值
NewArray Routines Array Type
NewBooleanArray() jbooleanArray
NewByteArray() jbyteArray
NewCharArray() jcharArray
NewShortArray() jshortArray
NewIntArray() jintArray
NewLongArray() jlongArray
NewFloatArray() jfloatArray
NewDoubleArray() jdoubleArray
2.6.4 创建 elementClass 数组

构造一个类型是elementClass的数组,所有类型都被初始化为initialElement:

1
2
jobjectArray NewObjectArray(JNIEnv *env, jsize length,
jclass elementClass, jobject initialElement);
2.6.5 获取数组指定位置的元素
1
2
jobject GetObjectArrayElement(JNIEnv *env,
jobjectArray array, jsize index);
2.6.6 获取数组的长度
1
jsize GetArrayLength(JNIEnv *env, jarray array);