JNI——简单入门

641 阅读10分钟

概述

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 目录下可以看到这个库:

image.png

这里 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 TypeNative TypeDescription
booleanjbooleanunsigned 8 bits
bytejbytesigned 8 bits
charjcharunsigned 16 bits
shortjshortsigned 16 bits
intjintsigned 32 bits
longjlongsigned 64 bits
floatjfloat32 bits
doublejdouble64 bits
voidvoidnot applicable

引用类型

  • jclass (java.lang.Class objects)

  • jstring (java.lang.String objects)

  • jarray (arrays)

    • jobjectArray (object arrays)
    • jbooleanArray (boolean arrays)
    • jbyteArray (byte arrays)
    • jcharArray (char arrays)
    • jshortArray (short arrays)
    • jintArray (int arrays)
    • jlongArray (long arrays)
    • jfloatArray (float arrays)
    • jdoubleArray (double arrays)
  • jthrowable (java.lang.Throwable objects)

字段和方法的 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 函数。

如果没有弹出提示,需要去下图中的位置修改配置: JNINativeInterface

这里我们要修改字段,选择 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 修改字段的值/调用方法;