NDK 入门系列主要介绍 JNI 的相关内容,目录如下:
NDK 入门(一)—— JNI 初探
NDK 入门(二)—— 调音小项目
NDK 入门(三)—— JNI 注册与 JNI 线程
NDK 入门(四)—— 静态缓存与 Native 异常
因为 JNI 中大量使用宏,因此在正式进入 JNI 之前,先了解 C++ 的宏。
1、预处理器
C++ 的预处理器是一个在编译过程中执行的特殊程序,用于在实际编译之前对源代码进行预处理。预处理器指令以 #
开头,并在编译之前被解析和替换。
预处理器支持很多指令,如条件编译指令 #if
、#ifdef
、#ifndef
、#else
、#endif
,宏定义指令 #define
以及包含文件指令 #include
等。这些指令可以控制代码的编译过程和包含其他文件。
需要注意预处理器的工作在实际的编译过程之前,它只是对源代码进行文本替换和处理,不参与语法分析和类型检查。
1.1 条件编译指令
#if
、#else
、elif
、#endif
与我们常用的 if 相关关键字类似:
void sample1(int value) {
#if value == 1
cout << "真" << endl;
#elif value == 0
cout << "假" << endl;
#else
cout << "都不满足" << endl;
#endif
cout << "结束 if" << endl;
}
当 #if
使用完毕后,必须要显式通过 #endif
结束其作用域。
#ifdef
、#define
、#ifndef
可以用于头文件保护(header guards,或称为 include guards)。在 CLion 中创建头文件 test.h 时,会自动生成如下内容:
#ifndef JNI_TEST_H
#define JNI_TEST_H
#endif //JNI_TEST_H
三句代码的含义如下:
#ifndef
(如果未定义):检查是否已经定义了一个名为JNI_TEST_H
的宏。如果未定义,则执行下面的代码块#define
:定义JNI_TEST_H
宏,防止再次进入相同的代码块#endif
:表示条件编译的结束,结束头文件保护的代码块
它们的作用是在编译过程中只包含一次头文件,避免重复定义问题。当多个源文件引用同一个头文件时,如果没有头文件保护,编译器会将头文件的内容多次包含进每个源文件中,导致重定义错误。
通过这样的头文件保护机制,当多个源文件同时包含同一个头文件时,只会在第一次包含时定义 JNI_TEST_H
宏,并执行头文件的内容,后续的包含都会被忽略。这样既避免了重复定义的错误,同时也提高了编译的效率。
在 VS 中创建头文件时,默认会为头文件添加 #pragma once,它类似于我们上面提到的 #ifndef, #define, #endif 这种传统的头文件保护措施,但是由于其不是 C++ 标准的一部分,在某些特定的编译器或开发环境中,可能存在一些兼容性问题或不一致的行为。因此我们还是常用传统方式进行头文件保护。
1.2 宏定义指令
宏是预处理器的一个重要概念,它是一种简单的文本替换机制。宏定义使用 #define
关键字,将一个标识符与一个文本片段关联起来。当源代码中出现宏标识符时,预处理器会将其替换为与之关联的文本片段。
以下是一些常见的宏使用示例:
-
宏定义:
// 定义 PI 为常量 #define PI 3.14159 // 定义 MAX(a, b) 为宏函数 #define MAX(a, b) ((a) > (b) ? (a) : (b))
-
宏的使用:
double circleArea = PI * radius * radius; int maxVal = MAX(a, b);
PI
被直接替换为其定义的值,MAX(a, b)
在编译时被展开为(a) > (b) ? (a) : (b)
。
宏的定义与取消
使用 #define
定义宏,使用 #undef
取消宏的定义:
void sample2() {
#ifndef TEST
#define TEST // 定义 TEST 宏
#ifdef TEST
cout << "定义了 TEST 宏" << endl;
#endif
#undef TEST // 取消 TEST 宏
#ifdef TEST
cout << "再次确认定义了 TEST 宏"<<endl;
#else
cout << "没有定义 TEST 宏" << endl;
#endif
#endif
}
示例代码演示了先用 #define
定义 TEST 宏,随后再用 #undef
取消 TEST 宏,输出如下:
定义了 TEST 宏
没有定义 TEST 宏
宏函数
宏函数的定义:
// 宏函数定义
#define LOGIN(V) \
if(V==1) { \
cout << "满足条件 你输入的是:" << V << endl; \
} else { \
cout << "不满足条件 你输入的是:" << V << endl; \
} // 这个是结尾,不需要加 \
// 宏函数定义演示
void sample4() {
LOGIN(1);
}
定义宏函数时,除了最后一行,每行的结尾都要放一个 \
表示宏定义的延续。当编译器遇到 \
符号时,它会将当前行和下一行的内容合并为一行,作为宏定义的一部分。这使得宏定义可以在多行上进行,以提高可读性和维护性。
注意,\ 符号必须位于行的末尾,并且前面不能有任何字符,包括空格。否则,\ 将被视为普通的转义字符而不是行延续符。
宏的优点与缺点
宏的优点是简单且易于使用,可以提高代码的可读性和灵活性。并且,宏不会造成函数的调用开销(如在栈上开辟空间、形参压栈、函数弹栈释放等)。
然而,宏也有一些潜在的问题,如宏展开可能会导致意外的副作用,宏参数可能会被多次计算,可能会导致代码体积增大等。
以宏展开导致意外的副作用为例:
#define MULTI(a, b) a*b
// 演示宏展开导致的意外副作用
void sample3() {
// 输出结果为 5 而不是 8
cout << MULTI(1 + 1, 2 + 2) << endl;
}
编译时将 MULTI 这个宏展开就变成:1 + 1 * 2 + 2
,这样先计算乘法结果就是 5,而不是我们通常认为的 2 * 4 = 8
。
再比如代码体积增大。宏是在预处理阶段被字符串替换,如果多次调用宏函数,每次调用的地方都会被替换为函数体。例如多次调用宏函数 LOGIN:
void sample4() {
LOGIN(1);
LOGIN(1);
LOGIN(1);
}
预处理后展开就变成:
void sample4() {
#define LOGIN(V) \
if(V==1) { \
cout << "满足条件 你输入的是:" << V << endl; \
} else { \
cout << "不满足条件 你输入的是:" << V << endl; \
}
#define LOGIN(V) \
if(V==1) { \
cout << "满足条件 你输入的是:" << V << endl; \
} else { \
cout << "不满足条件 你输入的是:" << V << endl; \
}
#define LOGIN(V) \
if(V==1) { \
cout << "满足条件 你输入的是:" << V << endl; \
} else { \
cout << "不满足条件 你输入的是:" << V << endl; \
}
}
很明显,代码的体积增大了。
2、JNI 初探
JNI(Java Native Interface)是 Java 层与 C/C++ 的 Native 层进行沟通的桥梁,类似于一个翻译官,进行双向的代码转换与通信工作。
2.1 概述
JNI 是 Java 提供的一种编程机制,用于在 Java 代码中调用和使用本地(Native)代码,从而扩展了 Java 的功能和能力。它允许 Java 应用程序通过 JNI 接口与本地代码进行交互,实现 Java 与其他编程语言(如 C、C++ 等)之间的互操作性。
JNI 的主要目的是允许 Java 程序访问底层系统资源、使用特定硬件功能或调用本地库。通过 JNI,Java 程序可以调用本地代码中的函数,传递参数,获取返回值,并与本地代码进行数据交换。
使用 JNI 的一般流程如下:
-
编写本地代码:使用 C、C++ 等编程语言编写实现所需功能的本地代码。本地代码通常以动态链接库(DLL 或 SO 文件)的形式存在
-
定义 JNI 接口:在 Java 代码中,使用特定的语法和注解来定义 JNI 接口。这些接口描述了 Java 代码和本地代码之间的函数调用规则、参数传递方式等
-
生成本地方法:通过 Java 的 native 关键字标记需要调用本地代码的方法,并在编译 Java 代码时生成本地方法的桩(stub)
-
编译和链接:将 Java 源代码和本地代码分别编译为字节码和本地可执行代码,并将它们链接在一起。这一步通常由编译器和链接器完成
-
运行 Java 程序:在 Java 虚拟机(JVM)中运行 Java 程序,在需要调用本地方法时,JVM 通过 JNI 接口与本地代码进行交互
JNI 提供了一组丰富的函数和数据类型,用于在 Java 代码和本地代码之间进行数据传递和转换。它还提供了异常处理机制,使得 Java 代码能够捕获和处理本地代码中抛出的异常。
2.2 相关概念辨析
主要是 JNI 与 NDK 的关系:
- JNI 是 Java 语言自带的特性,在 Java 1.1 版本中正式引入,包含在 JDK 中
- NDK(Native Development Kit)是 Android 平台提供的,用于开发和构建使用本地语言(如 C、C++)编写的 Android 应用程序。它集成了 JNI,因此程序员可通过 NDK 直接使用 JNI。但是需要注意,NDK 中有一份独有的 jni.h 文件,而不是直接使用 JDK 中提供的 jni.h
- SDK(Software Development Kit)是一组工具、库和文档,用于开发特定软件平台的应用程序。在移动应用开发中,SDK 通常指的是针对特定移动操作系统(如 Android 或 iOS)的开发工具包。Android SDK 提供了用于开发 Android 应用程序的工具、API 和库,其中包括与 JNI 相关的部分资源
2.3 JNI 文件基本结构
在 AS 中新建一个 Native C++ 项目,会生成 Java(Kotlin)源文件和 cpp 源文件两个文件:
public class NDKActivity extends AppCompatActivity {
// 增加一个常量的声明
public static final int NUMBER = 100;
static {
System.loadLibrary("ndk");
}
// 增加一个 native 方法声明
native String getString();
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_ndk);
}
}
#include <jni.h>
#include <string>
extern "C" JNIEXPORT jstring JNICALL
Java_com_sample_ndk_MainActivity_stringFromJNI(
JNIEnv* env,
jobject /* this */) {
std::string hello = "Hello from C++";
return env->NewStringUTF(hello.c_str());
}
下面来解释为什么 cpp 文件会这样生成。
我们先来看原始开发 NDK 的过程。在 Java 源集目录下(app/src/main/java)运行 javah 命令根据 NDKActivity.java 生成头文件(没用 kt 的原因是对 kt 源文件执行该命令失败):
F:\Code\Android\FirstNDK\app\src\main\java>javah com.sample.ndk.NDKActivity
然后在该目录下就会生成 com_sample_ndk_NDKActivity.h 文件:
/* DO NOT EDIT THIS FILE - it is machine generated */
#include <jni.h>
/* Header for class com_sample_ndk_NDKActivity */
// 如果没有定义 _Included_com_sample_ndk_NDKActivity
// 这个宏,就定义它,进行头文件保护
#ifndef _Included_com_sample_ndk_NDKActivity
#define _Included_com_sample_ndk_NDKActivity
// 如果定义了 __cplusplus 宏,就写出字符串 extern "C"
#ifdef __cplusplus
extern "C" {
#endif
// 定义 java 源文件中声明的 NUMBER 常量
#undef com_sample_ndk_NDKActivity_NUMBER
#define com_sample_ndk_NDKActivity_NUMBER 100L
// 写出 java 源文件中声明的 native 方法 getString 在 c++ 中的声明
/*
* Class: com_sample_ndk_NDKActivity
* Method: getString
* Signature: ()Ljava/lang/String;
*/
JNIEXPORT jstring JNICALL Java_com_sample_ndk_NDKActivity_getString
(JNIEnv *, jobject);
#ifdef __cplusplus
}
#endif
#endif
函数签名有几点要说明的:
- extern “C”:如果定义了 __cplusplus 宏,就要在函数签名前加上 extern “C”,目的是让 C++ 编译器以 C 语言的方式处理该函数。原因下面会详解
- JNIEXPORT:允许函数被外部调用。不同的平台对该关键字要求不同,Linux 不加也可以,但 Windows 下,VS 不加会报错,而 AS 不加不会报错
- jstring:函数的返回值类型。字符串类型在 Java 中是 String,在 C++ 中是 char * 或 string,当两个平台的字符串要进行转换时,需要借助 JNI 层的 jstring。不论是从 Java 到 native,还是 native 到 Java 都可以用 jstring
- JNICALL:关键字,表示是 JNI 的函数
- Java_com_sample_ndk_NDKActivity_getString:函数名称为固定格式
Java_包名_Java 方法名
- JNIEnv:JNI 环境,内部有很多函数(300 多个),是 Java 与 Native 的桥梁
- jobject:调用该函数的对象,比如这个函数是 NDKActivity 的成员函数,由 NDKActivity 对象调用该函数,那么 jobject 就是 NDKActivity 对象;而当 native 方法声明为 static 时,由于静态方法属于类而不是某一个对象,这里的 jobject 就会变为 jclass,在这个例子中也就是 NDKActivity 这个类对象了
为何要使用 extern “C”
为何定义了 __cplusplus 宏,就要在函数声明前加 extern “C” 呢?是为了告诉 C++ 编译器以 C 语言的方式处理函数,以确保 JNI 函数能够在 C++ 环境中正确链接和使用。
根本原因是在 C++ 中,函数名和函数签名会经过名称修饰(name mangling)以支持函数的重载和命名空间。这意味着 C++ 编译器会对函数名进行修改,以包含更多的信息,以便能够在编译时进行函数重载和命名空间的解析。
然而,在 JNI 中,我们需要与 C 语言的函数进行交互,而 C 语言不支持函数重载和命名空间。因此,为了确保 JNI 函数的名称在 C++ 中具有正确的链接和可用性,我们需要告诉 C++ 编译器以 C 语言的方式处理这些函数。
extern "C"
是 C++ 语言提供的一个特性,用于指示编译器以 C 语言的方式处理函数的声明和定义。它会告诉 C++ 编译器不要对函数名进行名称修饰,使得函数名在编译后的目标文件中与 C 语言的函数名保持一致。
在 JNI 方法声明前面加上 extern "C"
可以确保在 C++ 环境中正确链接和使用这些 JNI 函数。如果不加上 extern "C"
,在 C++ 编译器的名称修饰下,JNI 函数的名称会发生变化,导致无法与 C 语言的函数进行匹配,从而无法正确调用 JNI 函数。
2.4 简单示例
JNI 中获取 Java 层的对象、方法的方式非常像 Java 反射,会通过 JNIEnv 执行相应的 get 函数获取到表示类、对象和方法在 JNI 的对象,然后通过这些对象执行需求操作。
在 JNI 层修改上层变量值
上层声明一个成员变量和一个静态变量,并提供修改这两个变量的方法:
private var name = "Init name";
private external fun changeName()
companion object {
init {
System.loadLibrary("ndk")
}
var age = 10
@JvmStatic
external fun changeAge()
}
}
JNI 层实现 changeName() 和 changeAge() 这两个方法:
#include <jni.h>
#include <string>
#include <android/log.h> // 引入 Android 的 log 库
#define TAG "Frank"
// 借助 JNI 里面的宏来自动帮我填充
#define LOGD(...) __android_log_print(ANDROID_LOG_DEBUG, TAG, __VA_ARGS__)
#define LOGE(...) __android_log_print(ANDROID_LOG_ERROR, TAG, __VA_ARGS__)
#define LOGI(...) __android_log_print(ANDROID_LOG_INFO, TAG, __VA_ARGS__)
extern "C" JNIEXPORT void JNICALL
Java_com_sample_ndk_MainActivity_changeName(
JNIEnv *env,
jobject obj) {
// 获取 obj 的 class 对象
jclass clazz = env->GetObjectClass(obj);
// 获取 clazz 内的属性 Id
jfieldID jfieldId = env->GetFieldID(clazz, "name", "Ljava/lang/String;");
// 获取属性 Id 对应的属性并转换为 jstring
jstring jName = static_cast<jstring>(env->GetObjectField(obj, jfieldId));
// 打印字符串
char *c_str = const_cast<char *>(env->GetStringUTFChars(jName, nullptr));
LOGD("native : %s\n", c_str);
// 修改字符串
jstring newName = env->NewStringUTF("Beyond");
env->SetObjectField(obj, jfieldId, newName);
}
extern "C"
JNIEXPORT void JNICALL
Java_com_sample_ndk_MainActivity_changeAge(JNIEnv *env, jclass clazz) {
// 获取属性 ID
jfieldID jfieldId = env->GetStaticFieldID(clazz, "age", "I");
// 获取静态属性对象
jint age = env->GetStaticIntField(clazz, jfieldId);
// 修改静态属性的值
env->SetStaticIntField(clazz, jfieldId, age += 10);
}
先说 Android Log 的用法。导入 android/log.h 头文件,然后定义一个宏 TAG,作为 LOGD(…) 宏的第二个参数,而第一个和第三个参数分别是 Log 的内容以及填充 Log 内容中占位符的参数。
再看 Java_com_sample_ndk_MainActivity_changeName()
:
- 参数:
- 第一个参数是 JNIEnv,是 JNI 环境,通过它可以获取 JNI 层的类对象、属性对象等信息
- 第二个参数是 jobject,是 native 方法所在的类的实例在 JNI 层的表现形式,可以通过其获取对应的类对象 jclass。这个位置,对于成员 native 方法就是 jobject,如果是静态的 native 方法,这个参数就是类对象 jclass(见
Java_com_sample_ndk_MainActivity_changeAge()
)
- 函数体的内容,跟 Java 反射的套路很像:
jclass clazz = env->GetObjectClass(obj)
:利用 JNIEnv,获取 obj 对应的类对象jfieldID jfieldId = env->GetFieldID(clazz, "name", "Ljava/lang/String;");
:获取属性 ID,三个参数分别是类对象、属性名称和属性签名jstring jName = static_cast<jstring>(env->GetObjectField(obj, jfieldId));
:根据属性 ID 获取属性值,GetObjectField() 返回值类型是 jobject,而由于 name 属性是 String 类型,在 JNI 对应的类型是 jstring,因此可以通过静态转换获取 name 的值char *c_str = const_cast<char *>(env->GetStringUTFChars(jName, nullptr))
:jstring 是 Java 的 String 在 JNI 层的表示,但是由于需要通过 LOGD 宏,即 __android_log_print() 输出,其第三个参数要求是 char * 类型的,因此要将 jstring 转换为 char *jstring newName = env->NewStringUTF("Beyond");
:创建一个 UTF 格式的 jstring 对象 Beyond,用于修改 name 属性值env->SetObjectField(obj, jfieldId, newName);
:修改 name 属性值,三个参数依次为属性所在的类的实例、属性 ID 和新的 jstring 对象
静态属性的修改我们就不再赘述了,结合注释已经上述的解释应该很轻易就能弄懂。
最后我们来关注 JNI 中属性和方法的签名类型。上面例子中出现过的,String 类型的 JNI 类型是 Ljava/lang/String;
,Int 类型在 JNI 的表示是 I
,常用的类型表示如下:
在 JNI 层调用上层方法
用 kt 声明一个方法,然后用 native 方法调用该方法:
// 在 native 调用 add()
private external fun callAddMethod()
// 专门写一个函数,给 native 层调用
fun add(number1: Int, number2: Int): Int {
return number1 + number2 + 8
}
native 代码:
extern "C"
JNIEXPORT void JNICALL
Java_com_sample_ndk_MainActivity_callAddMethod(JNIEnv *env, jobject obj) {
// 获取 obj 所在的类对象
jclass klass = env->GetObjectClass(obj);
// 获取方法 ID,第三个参数是方法的类型签名
jmethodID methodId = env->GetMethodID(klass, "add", "(II)I");
// 调用方法
jint result = env->CallIntMethod(obj, methodId, 6, 6);
LOGI("result = %d", result);
}
解释一下方法的签名类型为什么是 (II)I。括号内是方法参数类型,最后是方法返回值类型。由于 add() 的两个参数和返回值类型均为 Int,所以方法签名就是 (II)I。
3、在 JNI 层操作上层对象
本节主要通过一些代码示例说明如何在 JNI 层操作上层对象。
3.1 接收上层参数
主要指上层声明的 native 方法传来的基本类型参数、数组类型参数的处理:
// 在 JNI 接收 Java 层传来的参数并对数据进行处理
external fun handleParamInJNI(
count: Int,
textInfo: String,
ints: IntArray,
string: Array<String>
)
JNI 层:
/**
* 对参数类型进行处理
* jintArray 是对应 kt 的 IntArray,jobjectArray 表示是引用类型的数组,如
* String[] Student[] Person[] 等
*/
extern "C"
JNIEXPORT void JNICALL
Java_com_jni_lesson2_MainActivity_handleParamInJNI(JNIEnv *env, jobject obj, jint count,
jstring text_info, jintArray ints,
jobjectArray string) {
// 1.基本数据类型的处理
// 由于 jint 的本质是 int,因此可以使用 int 接收
int intCount = count;
LOGD("参数一 intCount = %d\n", intCount)
// jstring 要转换成 C 中的 const char *
// const char* GetStringUTFChars(jstring string, jboolean* isCopy)
const char *textInfo = env->GetStringUTFChars(text_info, nullptr);
LOGD("参数二 textInfo = %s\n", textInfo)
// 2.基本类型的数组,将 jintArray 转换为 int *
// jint* GetIntArrayElements(jintArray array, jboolean* isCopy)
jint *intArray = env->GetIntArrayElements(ints, nullptr);
// 获取数组长度
// jsize GetArrayLength(jarray array)
jsize arraySize = env->GetArrayLength(ints);
// 试图直接修改 Java 数组内的元素,但修改无效
for (int i = 0; i < arraySize; ++i) {
intArray[i] += 100;
LOGD("参数三 int[]:%d\n", *intArray + i)
}
/**
* 需要通过 env 的相关方法才能修改 Java 层的数组元素
* ReleaseIntArrayElements() 主要工作还是释放数组元素,
* 有三个参数,依次为 Java 数组在 JNI 层的对象、
* 修改后的数组以及修改模式。最后的修改模式有三个值可以选择:
* 0 表示刷新 Java 数组并释放 C++ 数组
* JNI_COMMIT 只刷新 Java 数组,不释放 C++ 层数组
* JNI_ABORT 只释放 C++ 层数组
*/
env->ReleaseIntArrayElements(ints, intArray, 0);
// 4.引用类型数组,拿到的是 jobject,静态转换成对应类型
jsize stringArrayLen = env->GetArrayLength(string);
for (int i = 0; i < stringArrayLen; ++i) {
// 根据索引获取数组中的每一个元素 jobject,转换成实际类型 jstring
jstring jString = static_cast<jstring>(env->GetObjectArrayElement(string, i));
// 将 jstring 转换成 LOGD 要求的 char *
const char *arrayStr = env->GetStringUTFChars(jString, nullptr);
LOGD("参数四 引用类型 String 的数组元素:%s\n", arrayStr);
// 释放 jstring,这个释放不是强制的,只是为了优化 JNI 层的空间使用
// 因为方法结束后会自动释放,但是及时手动释放是一个好习惯
env->ReleaseStringUTFChars(jString, arrayStr);
}
}
总结几点:
- jint 的本质就是 int,因此前者可以直接转换为后者
- String 类型在 JNI 层是 jstring,需要通过 GetStringUTFChars() 转换成 char * 才能在 Log 中输出
- 直接修改数组中的元素是不会同步到 Java 的数组对象上的,因为不是同一个对象。对于 Int 类型的数组而言,在通过 ReleaseIntArrayElements() 释放数组元素时,可以通过修改第三个参数实现将修改刷新到 Java 数组的目的,参数详情参考上方注释
- 对于引用类型数组只能在遍历时通过 GetObjectArrayElement() 获取到 jobject 对象,再转换成实际类型
3.2 调用上层方法
接收上层的引用类型对象,并调用这个类中的方法。
kt 代码:
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = ActivityMainBinding.inflate(layoutInflater)
setContentView(binding.root)
binding.button2.setOnClickListener {
receiveAnObject(Student("Frank", 30))
}
}
// 在 JNI 接收一个引用类型的对象,并调用其方法
external fun receiveAnObject(student: Student)
JNI 代码:
/**
* 调用 Kotlin Student 的方法
*/
extern "C"
JNIEXPORT void JNICALL
Java_com_jni_lesson2_MainActivity_receiveAnObject(JNIEnv *env, jobject thiz, jobject student) {
// 1.获取 Student 的类对象,有两种方式
// jclass studentClass = env->FindClass("com/jni/lesson2/Student");
jclass studentClass = env->GetObjectClass(student);
// 2.获取方法的 ID,注意 kt 的属性不能设置为 private 的,否则找不到这些方法
// 并且静态方法上要加 @JvmStatic 注解
jmethodID getNameID = env->GetMethodID(studentClass, "getName", "()Ljava/lang/String;");
jmethodID setNameID = env->GetMethodID(studentClass, "setName", "(Ljava/lang/String;)V");
jmethodID showInfoID = env->GetStaticMethodID(studentClass, "showInfo",
"(Ljava/lang/String;)V");
// 3.调用第 2 步中的三个方法
// setName(),返回值是 void 因此调用 CallVoidMethod(),
// setNameID 之后的参数就是 setName() 需要的参数
jstring value = env->NewStringUTF("AAA");
env->CallVoidMethod(student, setNameID, value);
// getName(),返回值类型是 String 因此使用返回引用类型的方法
// CallObjectMethod(),返回值 jobject 直接转换为 jstring
jstring newName = static_cast<jstring>(env->CallObjectMethod(student, getNameID));
const char *nameString = env->GetStringUTFChars(newName, nullptr);
LOGD("new name is %s\n", nameString)
// 静态的 showInfo()
jstring message = env->NewStringUTF("This is a message from JNI.");
env->CallStaticVoidMethod(studentClass, showInfoID, message);
}
总结:
- 获取类对象 jclass 有两种方式,一是通过传入的对象调用 GetObjectClass(jobject) 获取,二是没有提供对象的情况下,通过 FindClass() 传入包名 + 类名获取
- 调用上层方法需要先获取其方法 ID,在调用 GetMethodID() 时,传入第二个参数时可以根据 AS 的补全提示选择一个上层方法,第三个参数会根据该方法自动补全方法签名;或者可以在第三个参数上通过 alt + enter => Fix type specifier… 自动生成
- 获取上层的成员方法用 GetMethodID(),调用上层的静态方法用 GetStaticMethodID()。如果上层代码是用 Kotlin 写的,那么这个静态方法必须是在 companion object 中声明的 @JvmStatic 方法
3.3 创建上层对象
在获取了类对象 jclass 之后,可以通过 AllocObject() 创建一个对象,注意 AllocObject() 并没有调用构造方法,而 NewObject() 才会通过调用构造方法创建一个对象:
/**
* 在 JNI 层创建一个对象
*/
extern "C"
JNIEXPORT void JNICALL
Java_com_jni_lesson2_MainActivity_createAnObjectInJNI(JNIEnv *env, jobject obj) {
// 1.通过包名 + 类名拿到 jclass 对象(因为 Java 层这次没有传对象)
jclass studentClass = env->FindClass("com/jni/lesson2/Student");
// 2.通过 Student 的类对象实例化 Student 对象
// 注意 AllocObject() 仅做了实例化,不会调用构造函数
// 而 NewObject() 才会调用构造函数实例化
jobject studentObj = env->AllocObject(studentClass);
// 3.获取 Student 内的方法并调用 setter 方法
// 第三个参数的方法签名类型,可以在 IDE 中通过 alt + enter => Fix type specifier... 自动生成
// 或者在输入第二个参数,即函数名称时选择提示的方法,也会自动补全第三个参数
jmethodID setAgeID = env->GetMethodID(studentClass, "setAge", "(I)V");
jmethodID setNameID = env->GetMethodID(studentClass, "setName", "(Ljava/lang/String;)V");
env->CallVoidMethod(studentObj, setAgeID, 18);
env->CallVoidMethod(studentObj, setNameID, env->NewStringUTF("Frank"));
// 4.调用 Person.setStudent(),将 Student 对象作为参数传入
jclass personClass = env->FindClass("com/jni/lesson2/Person");
jobject personObj = env->AllocObject(personClass);
jmethodID setStudentID = env->GetMethodID(personClass, "setStudent",
"(Lcom/jni/lesson2/Student;)V");
env->CallVoidMethod(personObj, setStudentID, studentObj);
// 5.手动释放局部变量(函数执行完出栈时会自动释放,手动释放在复杂的 JNI 代码中会减少
// 函数执行期间占用的内存,被视为一种好习惯)
env->DeleteLocalRef(studentClass);
env->DeleteLocalRef(personClass);
env->DeleteLocalRef(studentObj);
env->DeleteLocalRef(personObj);
// 除了 DeleteLocalRef,还有 ReleaseXXX 释放的,比如:
// env->ReleaseStringUTFChars()
}
注意第 5 点,虽然 JNI 函数执行完出栈时会释放函数内声明的局部引用,但是及时释放不再使用的局部引用会降低函数在运行时所占用的内存,是一个比较好的习惯。当然,这个例子是在函数最后释放的,实际上应该是随用随删的(确定不用了立即回收)。
3.4 局部引用与全局引用
并不是声明在函数内部的就是局部引用,声明在函数外部的就是全局引用,要看这个引用是否是通过 NewGlobalRef() 创建的。如果是,那就是全局引用,否则就是局部引用:
// 看似是一个全局引用,但如果只通过 FindClass() 构造,
// 实际上还是局部的,需要将 FindClass() 的返回值再传入
// NewGlobalRef() 才会变为全局引用
jclass dogClass;
/**
* 测试 JNI 的局部引用,调用第二次时会抛出异常:
* jclass is an invalid local reference
* 因为 dogClass 实际上是一个局部引用,在第一次执行完函数后,
* 函数出栈它也就被回收了,但是回收时只是将堆上的对象回收了,没有
* 将 dogClass 这个对象指针置为 nullprt,因此它是一个悬空指针,
* 跳过了 if (dogClass == nullptr) 条件,在一个空的 dogClass
* 上调用构造函数,因此抛出了上述异常
* 并且还要注意不能通过 dogClass = nullprt 去试图解决这个问题,
* 因为这样做,在函数出栈时,无法通过 dogClass 知晓具体要释放哪个对象
*/
extern "C"
JNIEXPORT void JNICALL
Java_com_jni_lesson2_MainActivity_localReference(JNIEnv *env, jobject obj) {
if (dogClass == nullptr) {
dogClass = env->FindClass("com/jni/lesson2/Dog");
}
// 使用 NewObject(),调用 Dog 的无参构造方法创建 Dog 对象
jmethodID initID1 = env->GetMethodID(dogClass, "<init>", "()V");
jobject dog = env->NewObject(dogClass, initID1);
// 使用 Dog 的有参构造方法创建 Dog 对象
jmethodID initID2 = env->GetMethodID(dogClass, "<init>", "(I)V");
dog = env->NewObject(dogClass, initID2, 3);
// 释放 dog 对象
env->DeleteLocalRef(dog);
}
上例中 dogClass 没有通过 NewGlobalRef() 构造,它就是一个局部引用,多次调用上述函数,会抛出异常:jclass is an invalid local reference
。因为局部引用在方法执行之后就被回收,并且不是回收为 nullptr,而是悬空指针,致使第二次调用函数时,没有重新构造 dog Class 就去调用 GetMethodID() 而产生异常。详情见注释的解释。
接下来看全局引用:
/**
* 创建一个全局的对象引用,解决 localReference 重复执行会抛出异常的问题
* 而全局引用就需要手动释放并置为 nullptr 了,因为函数出栈不会自动释放
* 全局引用了
*/
extern "C"
JNIEXPORT void JNICALL
Java_com_jni_lesson2_MainActivity_globalReference(JNIEnv *env, jobject thiz) {
if (dogClass == nullptr) {
jclass tempClass = env->FindClass("com/jni/lesson2/Dog");
dogClass = static_cast<jclass>(env->NewGlobalRef(tempClass));
// 及时释放不再使用的临时引用
env->DeleteLocalRef(tempClass);
}
// 还是调用 Dog 的两个构造方法
jmethodID initID1 = env->GetMethodID(dogClass, "<init>", "()V");
jobject dog = env->NewObject(dogClass, initID1);
jmethodID initID2 = env->GetMethodID(dogClass, "<init>", "(I)V");
dog = env->NewObject(dogClass, initID2, 3);
// 释放全局引用
if (dogClass) {
env->DeleteGlobalRef(dogClass);
dogClass = nullptr;
}
}
将 FindClass 得到的 jclass 对象传入 NewGlobalRef 生成全局引用。由于全局引用在函数出栈时不会被自动回收,因此要在不用时手动回收,这个例子中我们就是在函数尾部回收了。
3.5 小总结
最后总结一些小知识点:
-
AS 写 JNI 代码默认是没有代码提示和补全的,需要安装 Android NDK Support 这个插件,AS 内置的 MarketPlace 就可以找到
-
System.loadLibrary() 与 System.load() 都用于加载本地库(Native Library),但二者有些区别:
System.loadLibrary(libraryName)
用于加载已经在系统路径中的本地库。libraryName
参数是本地库的名称,不需要包含文件扩展名(如.dll
、.so
等)。该方法会在系统路径中查找与libraryName
匹配的本地库文件,并加载它。如果找不到对应的本地库文件,会抛出UnsatisfiedLinkError
异常System.load(path)
用于加载指定路径下的本地库。path
参数是本地库文件的完整路径,包括文件名和扩展名。该方法会直接加载指定路径下的本地库文件。如果文件不存在或加载失败,会抛出UnsatisfiedLinkError
异常
-
JNI 是属于 JVM 的一个小技术,编程时使用局部引用与全局引用的思想,而不是堆栈……
-
JNI 在函数内的局部变量如果不用了可以立即手动释放,这样可以减少该函数在运行期间所占用的内存。虽然函数执行完出栈时诸如 jobject jclass jstring 这些局部引用会被自动释放,但手动释放内存会被视为一种 JNI 编程的好习惯
-
extern 关键字:声明一个外部变量或函数,以便在其他文件中使用或调用。它用于在不同的文件间共享变量或函数的定义。比如在当前文件中声明的
extern int age;
和extern void show();
是可以在其他文件中实现和使用的