NDK 入门(四)—— 静态缓存与 Native 异常

时间:2024-05-02 14:18:29

NDK 入门系列主要介绍 JNI 的相关内容,目录如下:

NDK 入门(一)—— JNI 初探
NDK 入门(二)—— 调音小项目
NDK 入门(三)—— JNI 注册与 JNI 线程
NDK 入门(四)—— 静态缓存与 Native 异常

1、静态缓存

我们后续会接触到的 OpenCV、WebRTC 都大量使用了全局静态缓存,这里我们先做一个了解。

1.1 局部静态缓存的弊端

extern "C"
JNIEXPORT void JNICALL
Java_com_jni_env_MainActivity_localCache(JNIEnv *env, jclass clazz, jstring name) {
    static jfieldID f_id = nullptr;
    // 局部缓存,当该函数被多次调用时,每次都要走 if 判断并调用方法获取 id,影响性能
    if (f_id == nullptr) {
        f_id = env->GetStaticFieldID(clazz, "name1", "Ljava/lang/String;");
    } else {
        LOGD("获取静态FieldID失败")
    }
    env->SetStaticObjectField(clazz, f_id, name);

    // 验证存入的结果
    auto result = static_cast<jstring>(env->GetStaticObjectField(clazz, f_id));
    const char *resultString = env->GetStringUTFChars(result, nullptr);
    LOGD("从局部静态缓存中取出的值为:%s", resultString)
}

弊端就是注释中写到的,每次都需要调用 GetStaticFieldID() 影响性能。

1.2 全局静态缓存使用方法

全局静态缓存的生命周期分三步:初始化、使用与清除。先在上层声明这三个步骤对应的 Native 方法:

	companion object {
    	// 初始化静态缓存
    	@JvmStatic
        external fun initStaticCache()

        // 使用静态缓存
        @JvmStatic
        external fun staticCache(name: String)

        // 清除静态缓存
        @JvmStatic
        external fun clearStaticCache()
	}

然后在类进行初始化时对全局静态缓存初始化,在点击按钮时给缓存存入值,在 Activity.onDestroy() 时清除全局静态缓存:

	companion object {
        init {
            System.loadLibrary("env")
            // 初始化
            initStaticCache()
        }
        
        // 采用 JNI 全局静态缓存的变量
        var name2 = "T2"
    }

	override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        binding.btnStaticCache.setOnClickListener {
            // 存入缓存
            staticCache("777")
        }
    }

	// 双击 Back 不会立即回调,在后台管理器中杀掉时才立即回调
	override fun onDestroy() {
        super.onDestroy()
        // 清除缓存
        clearStaticCache()
    }

Native 实现:

// 全局静态缓存的 ID
static jfieldID f_name2_id = nullptr;

extern "C"
JNIEXPORT void JNICALL
Java_com_jni_env_MainActivity_initStaticCache(JNIEnv *env, jclass clazz) {
    // 初始化全局静态缓存 ID
    f_name2_id = env->GetStaticFieldID(clazz, "name2", "Ljava/lang/String;");
}

extern "C"
JNIEXPORT void JNICALL
Java_com_jni_env_MainActivity_staticCache(JNIEnv *env, jclass clazz, jstring name) {
    env->SetStaticObjectField(clazz, f_name2_id, name);
    // 取出存入 f_name2_id 内的值加以验证
    auto nameString = static_cast<jstring>(env->GetStaticObjectField(clazz, f_name2_id));
    const char *result = env->GetStringUTFChars(nameString, nullptr);
    LOGD("f_name2_id 存的值是:%s", result)
}

extern "C"
JNIEXPORT void JNICALL
Java_com_jni_env_MainActivity_clearStaticCache(JNIEnv *env, jclass clazz) {
    f_name2_id = nullptr;
    LOGD("静态缓存清除完毕")
}

实际上全局和局部静态缓存只不过就是放在函数外或内的区别,造成了易用性上的区别。

2、Native 异常捕获

主要有三种不同的异常出现的场景:

  1. Native 代码发生异常,直接在 Native 函数内补救
  2. Native 代码发生异常,抛给上层的 Java 或 Kotlin
  3. 上层方法发生异常,JNI 调用上层方法时,在 Native 函数中清除异常

先演示在 Native 发生异常的情况,上层代码:

	companion object {
        @JvmStatic
        external fun testException1()
        @JvmStatic
        external fun testException2()
    }

当在 Native 层尝试获取一个上层不存在的属性时,就会抛出异常:

extern "C"
JNIEXPORT void JNICALL
Java_com_jni_env_MainActivity_testException1(JNIEnv *env, jclass clazz) {
    // 上层并没有 name666 属性,因此会抛出异常
    jfieldID f_id = env->GetStaticFieldID(clazz, "name666", "Ljava/lang/String;");
}

异常信息跟 Java 的很像:

FATAL EXCEPTION: main                                                                              Process: com.jni.env, PID: 23090
java.lang.NoSuchFieldError: no "Ljava/lang/String;" field "name666" in class "Lcom/jni/env/MainActivity;" or its superclasses
at com.jni.env.MainActivity.testException1(Native Method)
at com.jni.env.MainActivity$Companion.testException1(Unknown Source:0)
at com.jni.env.MainActivity.onCreate$lambda$2(MainActivity.kt:26)

JNI 本身就是 JVM 的,异常信息很像 Java 是合理的。解决方法有两种,一是直接在 Native 层直接处理,二是向上层抛给 Java 或 Kotlin:

/**
* 直接在 Native 层清除异常状态并重新获取属性
*/
extern "C"
JNIEXPORT void JNICALL
Java_com_jni_env_MainActivity_testException1(JNIEnv *env, jclass clazz) {
    // 上层并没有 name666 属性,因此会抛出异常
    jfieldID f_id = env->GetStaticFieldID(clazz, "name666", "Ljava/lang/String;");
    // 检测本次函数执行,是否有异常
    jthrowable throwable = env->ExceptionOccurred();
    if (throwable) {
        LOGD("JNI 层发生异常")
        // 清除异常状态,运行时就不会再抛异常了
        env->ExceptionClear();
        // 接下来可以重新获取属性,比如获取 name1
        f_id = env->GetStaticFieldID(clazz, "name1", "Ljava/lang/String;");
    }
}

/**
* 在 Native 清除异常状态,但是把异常抛给上层
*/
extern "C"
JNIEXPORT void JNICALL
Java_com_jni_env_MainActivity_testException2(JNIEnv *env, jclass clazz) {
    jfieldID f_id = env->GetStaticFieldID(clazz, "name666", "Ljava/lang/String;");
    jthrowable throwable = env->ExceptionOccurred();
    if (throwable) {
        LOGD("JNI 层发生异常")
        // 清除异常状态,运行时就不会再抛异常了
        env->ExceptionClear();
        jclass noSuchFieldClass = env->FindClass("java/lang/NoSuchFieldException");
        env->ThrowNew(noSuchFieldClass, "Native 层获取属性失败,请检查属性名");
    }
}

上层在调用 testException2() 时需要通过 try-catch 捕获异常:

		binding.btnException.setOnClickListener {
            testException1()
            try {
                testException2()
            } catch (e: NoSuchFieldException) {
                e.printStackTrace()
                Log.d(TAG, "Native 抛给我的异常被捕获了")
            }
            testException3()
        }

最后来看 Native 通过 JNI 调用上层方法,但是上层方法运行出现异常的处理方式。其实还是通过 ExceptionClear() 清除异常状态。

示例代码,上层方法故意制造一个异常:

		@JvmStatic
        fun show() {
            Log.d(TAG, "show: 111")
            throw NullPointerException("上层方法抛出的异常")
        }

Native 调用并处理:

extern "C"
JNIEXPORT void JNICALL
Java_com_jni_env_MainActivity_testException3(JNIEnv *env, jclass clazz) {
    jmethodID showID = env->GetStaticMethodID(clazz, "show", "()V");
    env->CallStaticVoidMethod(clazz, showID);

    LOGD("native 层,异常后仍在执行1...")
    LOGD("native 层,异常后仍在执行2...")
    LOGD("native 层,异常后仍在执行3...")

    if (env->ExceptionCheck()) {
        env->ExceptionDescribe();
        env->ExceptionClear();
    }
}

上层调用 testException3() 运行的 Log 如下:

show: 111
native 层,异常后仍在执行1...
native 层,异常后仍在执行2...
native 层,异常后仍在执行3...
java.lang.NullPointerException: 上层方法抛出的异常
...

可以看到 Native 调用的上层方法发生异常后,Native 代码还是在继续运行了一段时间,没有立即抛出异常,这是因为:

当通过 JNI 调用 Java 方法时,如果 Java 方法运行出现异常,JNI 并不会立即停止运行。这是因为 JNI 提供了一种机制来处理 Java 异常,并允许继续执行 JNI 代码。

当发生 Java 异常时,JNI 将异常信息存储在 JNI 的环境中,并将控制权返回给 JNI 代码。JNI 代码可以检测异常状态并采取相应的处理措施。通常,在 JNI 代码中可以使用以下方法来处理 Java 异常:

  1. ExceptionCheck():这个方法用于检查当前 JNI 环境中是否有挂起的异常。你可以在适当的位置调用 ExceptionCheck() 方法来检查是否有异常,然后根据情况采取适当的处理措施。

  2. ExceptionOccurred():这个方法用于获取当前 JNI 环境中的挂起异常对象。如果 ExceptionCheck() 返回 true,你可以调用 ExceptionOccurred() 获取异常对象的引用,然后根据需要进行处理。

  3. ExceptionClear():这个方法用于清除当前 JNI 环境中的异常状态。如果你已经处理了异常或者决定不处理异常,可以调用 ExceptionClear() 来清除异常状态。

由于 JNI 允许在 Java 异常发生后继续执行 JNI 代码,因此在发生异常时,JNI 可能会尝试完成当前的 JNI 方法或代码段,以便在适当的时机处理异常并采取相应的措施。这样的设计使得 JNI 在异常处理方面更加灵活,可以根据具体的需求和策略进行异常处理。

3、其他知识点

都是一些小知识点或代码示例放在本节中。

3.1 借助 NDK 进行数组排序

上层:

	private fun sort() {
        val intArray = intArrayOf(11, 22, -3, 2, 4, 6, -15)
        jniSort(intArray)
        intArray.forEach { Log.d(TAG, it.toString()) }
    }

    private external fun jniSort(intArray: IntArray)

Native 层:

int compare(const jint *number1, const jint *number2) {
    return *number1 - *number2;
}

extern "C"
JNIEXPORT void JNICALL
Java_com_jni_env_MainActivity_jniSort(JNIEnv *env, jobject thiz, jintArray int_array) {
    int arrayLength = env->GetArrayLength(int_array);
    // 将 jintArray 这个数组对象转换成可被 qsort() 接收的类型 jint *
    jint *intArray = env->GetIntArrayElements(int_array, nullptr);

    // 参数依次为:数组首地址、数组长度、数组元素大小、比较器(负责比较的函数指针)
    qsort(intArray, arrayLength, sizeof(int),
          reinterpret_cast<int (*)(const void *, const void *)>(compare));
    env->ReleaseIntArrayElements(int_array, intArray, 0);
}

就是把上层的数组在 Native 层进行排序,然后刷新回上层。相关的知识前面已经有过介绍,不再赘述。

3.2 C++ 异常简介

两个简单例子,抛什么类型的异常,catch 中就捕获什么类型:

#include <iostream>
#include <string>

using namespace std;

void exceptionMethod01() {
    throw "我报废了";
}

// 更加简单的写法 自定义异常
class Student {
public:
    char *getInfo() {
        return "自定义";
    }
};

void exceptionMethod02() {
    Student s;
    throw s;
}

int main() {
    try {
        // 抛的是 const char *,因此捕获也是这种类型
        exceptionMethod01();
    } catch (const char *&msg) {
        cout << "捕获到异常信息:" << msg << endl;
    }
    try {
        // 抛出 Student,捕获也是 Student 类型
        exceptionMethod02();
    } catch (Student &s) {
        cout << "捕获到异常信息:" << s.getInfo() << endl;
    }
    return 0;
}

3.3 手写 JNIEnv

JNIEnv(Java Native Interface Environment)是 Java Native Interface(JNI)的一部分,它是用于在 Java 和本地代码之间进行通信的接口。

JNIEnv 的实现是由底层的 JNI 实现提供的,这个实现是与特定的 Java 虚拟机(JVM)和操作系统相关的。不同的 JVM 和操作系统可能有不同的实现方式,但它们都遵循 JNI 规范。

在大多数情况下,JNIEnv 的实现是由 JVM 在运行时动态创建的。当你调用 JNI 函数并传递 JNIEnv 指针时,实际上是将对应的实现指针传递给本地代码。本地代码可以使用 JNIEnv 指针来调用 JNI 提供的各种函数,以访问 Java 对象、调用 Java 方法、处理异常等。

JNIEnv 提供了一组函数,使本地代码能够与 Java 交互。这些函数包括创建和操作 Java 对象、调用 Java 方法、访问对象的字段、抛出异常等。JNIEnv 还提供了线程本地存储(Thread Local Storage)的机制,以确保针对每个线程的独立环境。

需要注意的是,JNIEnv 是一个抽象的接口,具体的实现由 JVM 和底层的 JNI 实现提供。因此,JNIEnv 的具体实现细节可能会因 JVM 和操作系统的不同而有所差异。如果你对特定 JVM 或操作系统的 JNIEnv 实现感兴趣,可以查阅相关文档或参考相关的 JNI 实现细节。

我们的目的是通过手写 JNIEnv 内一个方法的实现来了解它的实现原理。当然要先来看一下源码中它的实现机制。

JNIEnv 的声明

JNIEnv 实际上只是结构体的别名。它的本体在 C++ 中是 _JNIEnv,在 C 中是 JNINativeInterface*:

struct _JNIEnv;
struct _JavaVM;

#if defined(__cplusplus)
typedef _JNIEnv JNIEnv;
typedef _JavaVM JavaVM;
#else
typedef const struct JNINativeInterface* JNIEnv;
typedef const struct JNIInvokeInterface* JavaVM;
#endif

JNINativeInterface 内声明了 300+ 个函数指针,而 C++ 的 _JNIEnv 则是对 JNINativeInterface 的封装,通过 JNINativeInterface 调用 C 内的函数:

/*
 * Table of interface function pointers.
 */
struct JNINativeInterface {
	...
    jint        (*GetVersion)(JNIEnv *);

    jclass      (*DefineClass)(JNIEnv*, const char*, jobject, const jbyte*,
                        jsize);
    jclass      (*FindClass)(JNIEnv*, const char*);
    ...
}

/*
 * C++ object wrapper.
 *
 * This is usually overlaid on a C struct whose first element is a
 * JNINativeInterface*.  We rely somewhat on compiler behavior.
 */
struct _JNIEnv {
    /* do not rename this; it does not seem to be entirely opaque */
    const struct JNINativeInterface* functions;

#if defined(__cplusplus)

    jint GetVersion()
    { return functions->GetVersion(this); }

    jclass DefineClass(const char *name, jobject loader, const jbyte* buf,
        jsize bufLen)
    { return functions->DefineClass(this, name, loader, buf, bufLen); }

    jclass FindClass(const char* name)
    { return functions->FindClass(this, name); }
    ...
}

模拟实现

/**
 * 模拟 JNIEnv 的实现
 */

#include <iostream>
#include <string>

using namespace std;

// 以 C 语言结构体指针的方式实现
typedef const struct JNINativeInterface *JNIEnv;

struct JNINativeInterface {
    // 以其中一个函数指针为例
    char *(*NewStringUTF)(JNIEnv *, char *);
};

// 简化的类型声明
typedef char *jstring;
typedef char *jobject;

// 函数指针实现,极简写法
jstring NewStringUTF(JNIEnv *env, char *c_str) {
    return c_str;
}

extern "C" jstring Java_com_jni_env_MainActivity_getStringPwd(JNIEnv *env, jobject thiz) {
    return (*env)->NewStringUTF(env, "9527");
}

// 测试,模拟 JNIEnv 内部执行的过程
int main() {
    // 构建 JNIEnv* 对象
    struct JNINativeInterface nativeInterface;
    // 给结构方法指针进行赋值
    nativeInterface.NewStringUTF = NewStringUTF;
    // 传给 getStringPwd() 的参数是 JNIEnv*
    JNIEnv env = &nativeInterface;
    JNIEnv *jniEnv = &env;

    //  把 jniEnv 对象传给 getStringPwd() 的 Java 层
    char *jstring = Java_com_jni_env_MainActivity_getStringPwd(jniEnv, "com/jni/env/MainActivity");
    // jstring 通过 JNIEnv 桥梁传给 Java 层 (这个过程也省略了... 直接打印了)
    printf("Java层就拿到了 C++ 给我们的 jstring = %s", jstring);
    return 0;
}

3.4 上层获取 Native 对象

这里介绍一种上层获取 Native 对象的常用方式,是借鉴自 OpenCV。

在 OpenCV 中,在 Java 层通常会保存 Native 对象的指针,这样在需要使用 Native 函数时,可以直接通过这个指针进行操作。

OpenCV 源码参考 GitHub,我看的是 4.8 版本。

比如在 modules/core/misc/java/src/java/core+Mat.java 文件中:

public class Mat {

    // Native 对象的地址
    public final long nativeObj;

    // 构造方法直接赋予地址
    public Mat(long addr) {
        if (addr == 0)
            throw new UnsupportedOperationException("Native object address is NULL");
        nativeObj = addr;
    }
    
    // 空参构造方法通过 Native 的 n_Mat() 获取 nativeObj
    public Mat() {
        nativeObj = n_Mat();
    }

    private static native long n_Mat();
    ...
}

n_Mat() 这个 Native 方法在 modules/java/generator/src/cpp/Mat.cpp 中:

JNIEXPORT jlong JNICALL Java_org_opencv_core_Mat_n_1Mat__DDI
  (JNIEnv* env, jclass, jdouble size_width, jdouble size_height, jint type);

JNIEXPORT jlong JNICALL Java_org_opencv_core_Mat_n_1Mat__DDI
  (JNIEnv* env, jclass, jdouble size_width, jdouble size_height, jint type)
{
    static const char method_name[] = "Mat::n_1Mat__DDI()";
    try {
        LOGD("%s", method_name);
        Size size((int)size_width, (int)size_height);
        // new 出一个 Native 的 Mat 对象,然后强转成地址返回给 Java 层
        return (jlong) new Mat( size, type );
    } catch(const std::exception &e) {
        throwJavaException(env, &e, method_name);
    } catch (...) {
        throwJavaException(env, 0, method_name);
    }

    return 0;
}

因此我们能看到,上层拿到的 nativeObj 就是 Native 层对象的指针,也就是对象的地址,后续想使用 Native 层的功能时,就可以使用该地址转换成 Native 对象,并执行相应操作。

3.5 Parcel 源码简析

Parcelable 要比 Serializable 性能高,因为它是直接在 C++ 层写入/读取数据,而 Serializable 是通过 IO 流读写数据的。

初始化

先从 Java 层开始看(API 33):