概述
JNI(Java Native Interface)即 Java 本地接口,它设计出来的目的是让 Java 与 C/C++ 可以通信,JNI 是属于 Java 的,与 Android 没有直接关系,我们可以通过官网学习 JNI:Java Native Interface Specification Contents
JNI 与 NDK
JNI 是 Java 平台提供的一套非常强大的框架, NDK(Native Development Kit) 是 Android 平台提供的 Native 开发工具包,其作用是可以快速开发 C、 C++ 的 so 库,并自动将 so 和应用一起打包成 apk。
jni.h 在 JDK 和 NDK 中都有:
- 在 NDK 中的路径:Android\Sdk\ndk\25.1.8937393\toolchains\llvm\prebuilt\windows-x86_64\sysroot\usr\include\jni.h
- 在 JDK 中的路径:Java\corretto-17.0.10\include\jni.h
使用场景和优势
- 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 代码来调用这个库,这时候就需要使用 JNI 。
主要功能
使用 JNI 可以实现的功能包括:
- 创建、检查和更新 Java 对象(包括数组和字符串)。
- 调用 Java 方法。
- 捕获并抛出异常。
- 加载类并获取类信息。
- 执行运行时类型检查。
实现步骤
- 1.配置 NDK 环境
打开 Android Studio,打开 SDK Manager,切换到SDK Tools,下载 NDK 和 CMake。
- 2.新建项目
选择 File > New Project,选择 Native C++ 项目,这样一路 Next。在模拟器上运行后,模拟器上就可以看到 Hello from C++ 的字符串。
- 3.代码分析
在项目中可以看到比一般的 Android 项目多了一个 cpp 文件夹,cpp 文件夹下有 CMakeLists.txt 和 native-lib.cpp 两个文件。
MainActivity 中的代码如下:
public class MainActivity extends AppCompatActivity {
// 在 App 启动的时候加载 libjnitest.so
static {
System.loadLibrary("jnitest");
}
private ActivityMainBinding binding;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
binding = ActivityMainBinding.inflate(getLayoutInflater());
setContentView(binding.getRoot());
// 调用 native 方法的示例
TextView tv = binding.sampleText;
tv.setText(stringFromJNI());
}
// 由 jnitest native 库实现的本地方法,
// native 方法名需要与这个方法名对应上
public native String stringFromJNI();
}
可以看到 MainActivity 中有一段用 static{} 包裹的静态代码块,其作用是在 App 启动的时候去加载 libjnitest.so 库,可以在 build\intermediates\apk\debug\ 目录下找到 app-debug.apk,双击打开,在 lib\x86 目录下可以看到这个库:
这里 System.loadLibrary(String libname) 会去系统默认库路径中搜索该库文件,默认库路径可以通过 java.library.path 系统属性指定,在 Android 平台下会自动追加 lib 前缀和 .so 后缀。还有一种方法,可以通过 System.load(String filename) 来加载 so 库,这里参数需要传 so 库的绝对路径,如 System.load("/absolute/path/to/libnative-lib.so"),这种方式适用于加载非标准路径下的库文件。
Hello from C++ 字符串来自 stringFromJNI() 方法,该方法是一个 native 方法,其实现在 libjnitest.so 中,libjnitest.so 是由 native-lib.cpp 生成的。
JNI 代码解读
native-lib.cpp 代码如下:
#include <jni.h>
#include <string>
extern "C" JNIEXPORT jstring JNICALL
Java_com_example_jnitest_MainActivity_stringFromJNI(
JNIEnv* env,
jobject /* this */) {
std::string hello = "Hello from C++";
return env->NewStringUTF(hello.c_str());
}
- extern "C",这里是告诉 C++ 编译器用 C 语言的规则编译这个函数。
为什么要采用 C 语言的规则,因为 C++ 支持函数重载,会对函数名进行修饰(Name Mangling)。如果你去看 C++ 代码的汇编指令,会发现 void foo(int) 的名字可能变成了 _foo_i,void foo(double) 可能会变成 _foo_d,这样会导致函数名不匹配。采用 C 语言的规则编译可以保证 Java 在动态链接时可以找到对应的 native 方法。
- JNIEXPORT,这里点击可以直接跳转到 NDK 下的 jni.h 文件,对应的代码为:
#define JNIEXPORT __attribute__ ((visibility ("default")))
JNIEXPORT 和 JNICALL 是一些宏,确保该函数以正确的调用约定(calling convention)导出,以便 Java 运行时可以调用它。
-
jstring 是 JNI 类型,它是 Java 字符串在 Native(C/C++)代码中的对应类型。
-
Java_com_example_jnitest_MainActivity_stringFromJNI:这是从 Java 代码中调用的 native 方法的名称,这个名称遵循一个特定的命名约定:
Java_是所有 JNI 函数的前缀,com_example_jnitest_MainActivity_是对应的 Java 类的完整限定名(即com.example.jnitest.MainActivity),点(.)被下划线(_)替换,stringFromJNI对应 Java 方法的名称。 -
JNIEnv* env,这是一个指向 JNI 运行环境(JNIEnv)的指针,后面我们会用到,我们需要通过这个指针访问 JNI 函数。点进 JNIEnv,代码如下:
#if defined(__cplusplus) // C++ 环境
typedef _JNIEnv JNIEnv;
typedef _JavaVM JavaVM;
#else // C 环境
typedef const struct JNINativeInterface* JNIEnv;
typedef const struct JNIInvokeInterface* JavaVM;
#endif
可以看到,如果是 C++ 的环境, JNIEnv 是 _JNIEnv 的别名,_JNIEnv 中有一个结构体——JNINativeInterface,所有通过 JNIEnv 指针调用的函数都是调用 JNINativeInterface 中的 JNI 函数,后面我们会用到这些函数:
struct _JNIEnv {
/* do not rename this; it does not seem to be entirely opaque */
const struct JNINativeInterface* functions;
#if defined(__cplusplus)
jclass FindClass(const char* name)
{ return functions->FindClass(this, name); }
jmethodID GetMethodID(jclass clazz, const char* name, const char* sig)
{ return functions->GetMethodID(this, clazz, name, sig); }
void CallVoidMethod(jobject obj, jmethodID methodID, ...)
{
va_list args;
va_start(args, methodID);
functions->CallVoidMethodV(this, obj, methodID, args);
va_end(args);
}
jstring NewStringUTF(const char* bytes)
{ return functions->NewStringUTF(this, bytes); }
#endif /*__cplusplus*/
};
而在 C 的环境下,JNIEnv 最终也是指向 JNINativeInterface。
-
jobject:它是 Java 对象在 Native(C/C++)代码中对应的类型,这里代表调用该 JNI 方法的 Java 对象的实例,即 MainActivity 的实例。
-
std::string hello = "Hello from C++":创建一个 C++ 字符串,内容为“Hello from C++”。
-
return env->NewStringUTF(hello.c_str()):将 C++ 字符串转换为 UTF-8 编码的 Java 字符串(
jstring),并将其返回给 Java 调用者。
这样 Java 上层就可以拿到该字符串进行显示。
类型映射(Mapping of Types)
上面的代码中可以看到,不能直接返回 C++ 的字符串给 Java,而需要将其转换成 jstring 返回,jstring 是其中的一种 Native 类型。
JNI 定义了一套 Native 类型来对应 Java 中的类型。
基础数据类型
| Java Type | Native Type | Description |
|---|---|---|
| boolean | jboolean | unsigned 8 bits |
| byte | jbyte | signed 8 bits |
| char | jchar | unsigned 16 bits |
| short | jshort | signed 16 bits |
| int | jint | signed 32 bits |
| long | jlong | signed 64 bits |
| float | jfloat | 32 bits |
| double | jdouble | 64 bits |
| void | void | not applicable |
引用类型
-
jclass(java.lang.Classobjects) -
jstring(java.lang.Stringobjects) -
jarray(arrays)jobjectArray(object arrays)jbooleanArray(booleanarrays)jbyteArray(bytearrays)jcharArray(chararrays)jshortArray(shortarrays)jintArray(intarrays)jlongArray(longarrays)jfloatArray(floatarrays)jdoubleArray(doublearrays)
-
jthrowable(java.lang.Throwableobjects)
字段和方法的 id
使用 JNIEnv 操作 Java 中的字段(field)和方法(method)需要该字段和方法的 id,这些 id 都是指针类型的:
struct _jfieldID; /* opaque structure */
typedef struct _jfieldID *jfieldID; /* field IDs */
struct _jmethodID; /* opaque structure */
typedef struct _jmethodID *jmethodID; /* method IDs */
类型签名
类型签名是指 C/C++ 调用 Java 类对象的字段/方法的签名规则,签名是为了防止 C/C++ 不知道调用哪个 Java 的重载方法。
常用规则如下:
java类型----------属性类型符号
boolean----------Z
byte----------B
char----------C
short----------S
int----------I
long----------J
float----------F
double----------D
void----------V
object----------L完整的类名;
array[数组的数据类型 ---------- int[] [I ---double[][] [[D
method(参数类型)返回值类型 ---------- void name(int a,double b) (ID)V
示例
1.使用 JNI 修改 Java 中的字段
Java 中有一个 name 字段,初始化为 "Jim",我们想要把 name 字段修改成其他的字符串,该如何实现呢?
首先在 MainActivity 中添加一个 native 方法,方法名为 changeName(),用于修改 name 字段,代码如下:
public class MainActivity extends AppCompatActivity {
static {
System.loadLibrary("jnitest");
}
private ActivityMainBinding binding;
private String name = "Jim";
public native void changeName(); // native 方法
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
binding = ActivityMainBinding.inflate(getLayoutInflater());
setContentView(binding.getRoot());
System.out.println("修改前是:" + name);
changeName();
System.out.println("修改后是:" + name);
}
}
在该 native 方法上选择“Create JNI function for changeName”,Android Studio 就会自动帮我们在 native-lib.cpp 中生成对应的 JNI 方法。
我们在这里修改 name 字段的值,输入 env. 后,Android Studio 会自动弹出提示,告诉我们可以使用的 JNI 函数。
如果没有弹出提示,需要去下图中的位置修改配置:
这里我们要修改字段,选择 SetObjectField(),SetObjectField() 方法代码如下:
void SetObjectField(jobject obj, jfieldID fieldID, jobject value)
需要传递 3 个参数:对象(jobject)、字段id(jfieldID) 和修改的值(jobject)。
第 1 个参数已经有了,第 2 个参数我们通过同样的方式 env. 后,选择 GetFieldID() ,GetFieldID() 方法代码如下:
jfieldID GetFieldID(jclass clazz, const char* name, const char* sig)
同样需要 3 个参数:字段所在的类(jclass类型)、字段名和签名。
注意 jclass 与 jobject 的区别,jobject 表示类的实例;jclass 表示类本身(如 String.class),用于获取方法/字段 ID。
字段所在的类可以通过以下方式获取:
jclass clazz = env->FindClass("com/example/jnitest/MainActivity")
这样 JNI 的完整代码如下:
// native-lib.cpp 的代码
#include <jni.h>
#include <string>
extern "C"
JNIEXPORT void JNICALL
Java_com_example_jnitest_MainActivity_changeName(JNIEnv *env, jobject thiz) {
jclass clazz = env->FindClass("com/example/jnitest/MainActivity");
jfieldID nameFieldId = env->GetFieldID(clazz, "name", "Ljava/lang/String;");
jstring newName = env->NewStringUTF("Brian");
// newName 是 jstring 类型,而 jstring 是 jobject 的子类,所以可以直接用
env->SetObjectField(thiz, nameFieldId, newName);
}
运行后打印如下:
修改前是:Jim
修改后是:Brian
这样就成功地通过 JNI 把 name 字段从 "Jim" 改成了 "Brian",是不是很简单。
还有一种方法可以查看某个类中的字段和方法的签名,这里我进入 MainActivity.class 所在的目录,然后输入如下命令:
javap -s -p MainActivity.class
打印如下:
Compiled from "MainActivity.java"
public class com.example.jnitest.MainActivity extends androidx.appcompat.app.AppCompatActivity {
private com.example.jnitest.databinding.ActivityMainBinding binding;
descriptor: Lcom/example/jnitest/databinding/ActivityMainBinding;
private java.lang.String name;
descriptor: Ljava/lang/String;
public com.example.jnitest.MainActivity();
descriptor: ()V
public native void changeName();
descriptor: ()V
protected void onCreate(android.os.Bundle);
descriptor: (Landroid/os/Bundle;)V
static {};
descriptor: ()V
}
可以看到 name 字段的签名为:Ljava/lang/String;。
这里 native-lib 是一个 C++ 文件,属于 C++ 的环境,JNIEnv 是 _JNIEnv*,即指向 _JNIEnv 结构体的一级指针,所以使用 env-> 函数 来调用函数。如果是 C 文件,JNIEnv 是 const struct JNINativeInterface*(一级指针),函数参数是:JNIEnv* env,所以 env 是 const struct JNINativeInterface**(二级指针),需要使用 (*env)->函数 来调用函数。
2. 使用 JNI 修改 Java 中的静态字段
有一个静态变量 age ,使用 JNI 该怎么修改它呢?
public static int age = 28;
System.out.println("修改前是:"+ age);
changeAge();
System.out.println("修改后是:"+ age);
使用 changeAge() 方法来修改 age 字段,native-lib.cpp 代码如下:
#include <jni.h>
#include <string>
extern "C"
JNIEXPORT void JNICALL
Java_com_example_jnitest_MainActivity_changeAge(JNIEnv *env, jobject thiz) {
jclass clazz = env->FindClass("com/example/jnitest/MainActivity");
// jfieldID GetFieldID(jclass clazz, const char* name, const char* sig)
jfieldID ageFiledId = env->GetStaticFieldID(clazz, "age", "I");
// 获取修改前的 age
int age = env->GetStaticIntField(clazz, ageFiledId);
// void SetStaticIntField(jclass clazz, jfieldID fieldID, jint value)
// jint 就是 int,所以可以直接用
env->SetStaticIntField(clazz, ageFiledId, age - 10);
}
方法与上面类似,修改静态 int 字段需要调用 SetStaticIntField() 方法,传入的第一个参数为 clazz。
运行后打印如下:
修改前是:28
修改后是:18
可以看到 age 的值修改成功了。
思考:如果是 final 关键字修饰的变量,可以修改吗?
答案:在以前的 JNI 版本中不行,现在的版本可以。
3. 使用 JNI 调用 Java 中的方法
MainActivity 代码如下:
public class MainActivity extends AppCompatActivity {
static {
System.loadLibrary("jnitest");
}
private ActivityMainBinding binding;
public native void callAddMethod();
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
binding = ActivityMainBinding.inflate(getLayoutInflater());
setContentView(binding.getRoot());
callAddMethod();
}
public int add(int number1,int number2){
return number1 + number2;
}
}
这里有一个 add() 方法,我们通过 callAddMethod() 来调用这个方法。native-lib.cpp 代码如下:
#include <jni.h>
#include <string>
// 日志输出
#include <android/log.h>
#define TAG "jni"
// __VA_ARGS__ 代表可变参数
#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_example_jnitest_MainActivity_callAddMethod(JNIEnv *env, jobject thiz) {
jclass clazz = env->FindClass("com/example/jnitest/MainActivity");
// jmethodID GetMethodID(jclass clazz, const char* name, const char* sig)
jmethodID addMethodId = env->GetMethodID(clazz, "add", "(II)I");
// jint (*CallIntMethod)(JNIEnv*, jobject, jmethodID, ...);
jint result = env->CallIntMethod(thiz, addMethodId, 2, 3);
LOGD("result is %d", result);
}
运行后打印如下:
result is 5
可以看到成功地通过 JNI 调用了 Java 中的方法并打印了该方法的返回值。
总结
通过 JNI 操作 Java 层的字段/方法简单来说分为两步:
- 拿到字段/方法的 id;
- 使用 id 修改字段的值/调用方法;