1. RK3576 平台下的 JNI 开发环境与项目准备
做 RK3576 平台的安卓 JNI 开发,第一步不是急着写代码,而是把交叉编译环境和 NDK 版本先理顺。RK3576 这颗 SoC 用的是 Arm Cortex-A72 加 Cortex-A53 的大小核架构,跑 Android 15 的时候系统镜像和 Native 库的编译要求都比较明确,我这边实测下来用 NDK r25 或 r26 都挺稳,配合 CMake 3.22 以上版本做外部构建,基本不会遇到什么莫名其妙的链接错误。
有一个细节值得注意,如果你是在 RK3576 的 Android 15 系统源码树里直接编译 JNI 模块,那建议用源码自带的 prebuilt NDK,不要自己另装一套,否则 ABI 不匹配的问题能让你排查两天。如果是用 Android Studio 单独做 JNI 模块开发,然后在 RK3576 设备上安装 APK,那就在 build.gradle 里显式指定 ndkVersion,并且把 abiFilters 限制成 arm64-v8a。RK3576 是纯 64 位应用处理器,没必要打 armeabi-v7a 的包,打了反而增加包体,运行时还要走二进制转换层,性能反而吃亏。
再补充一个跨平台场景的点。有些项目会同时涉及 RK3576 的安卓上层和 Linux 侧(比如你会在 Qt 5.15 交叉编译时用到同一套 Native 算法库),这时候 JNI 层封装的是纯 C/C++ 逻辑,建议把核心算法做成不依赖 JNI 的独立静态库,JNI 只做薄薄一层胶水。这样同一套算法源码既能编成 .so 给安卓用,也能编成 Linux 可执行文件给工装测试用,两边共享逻辑,维护成本低很多。
1.1 NDK 版本选择与 ABI 配置的坑
NDK 版本这件事,我踩过一次比较深的坑。之前在一个 RK3576 项目里用了 NDK r23,编出来的动态库在 Android 12 上跑没问题,换成 Android 15 的固件后,一调用 JNI 接口就崩溃,logcat 里报的是 dlopen failed: cannot locate symbol "rand"。查了一圈,问题出在 NDK r23 默认链接的是旧版 libc,而 Android 15 的 Bionic 对符号可见性做了更严格的控制。后来把 NDK 升到 r25 并启用 libc++_shared,问题直接消失。
所以在 CMakeLists 里我现在的固定写法是这样:
cmake复制cmake_minimum_required(VERSION 3.22.1)
project(rk3576_jni_demo)
add_library(jni_core SHARED
native_lib.cpp
core_algorithm.cpp
)
find_library(log-lib log)
target_link_libraries(jni_core
${log-lib}
android
)
set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -Wall -Werror=return-type")
abiFilters 在 build.gradle 里的配置:
groovy复制android {
defaultConfig {
externalNativeBuild {
cmake {
cppFlags "-std=c++17"
arguments "-DANDROID_STL=c++_shared"
}
}
ndk {
abiFilters "arm64-v8a"
}
}
}
我建议你从一开始就把 ANDROID_STL 设为 c++_shared,虽然会导致 APK 里多带一个 libc++_shared.so,但对 RK3576 这种内存宽裕的设备来说无伤大雅,而且能避免静态链接 STL 时出现 std::string 跨 so 边界崩溃的经典问题。
1.2 JNI 在 RK3576 产品中的典型应用场景
RK3576 的定位是 AIoT 和边缘计算,JNI 在这里承担的任务通常有三类。第一类是硬件外设控制,比如通过 Native 层直接操作 GPIO、I2C、SPI 设备节点,这些在 Java 层做非常别扭,性能也差,丢到 Native 层一个 open() 加 ioctl() 就完事了。第二类是算法加速,RK3576 内置了 NPU,官方 RKNN 工具链的 C API 只能从 Native 层调用,如果上层是安卓应用,就必须用 JNI 把 RKNN 的初始化、推理、释放封装成 Java 接口。第三类是多媒体处理,比如 RTSP 流的解码、图像处理、音频重采样,用 Native 层的 FFmpeg 或自研算法,比 Java 层 MediaCodec 灵活得多。
这三类场景对 JNI 的要求不太一样。硬件控制类主要用基础类型的参数传递,数据类型映射比较简单;算法加速类会涉及大量 byte[] 和 float[] 的传递,这时候是拷贝数据还是用直接缓冲区,性能差距可达数倍;多媒体处理类则必然要回调 Java 层的方法来做 UI 刷新。所以这篇文章虽然定位是“上篇”,但数据类型映射和方法调用这两个主题,恰恰是所有 JNI 实战的基础,把这部分吃透了,后面讲 JNI 引用管理和线程模型时才不会卡壳。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 数据类型映射全解析:从 Java 到 Native 的桥梁
JNI 的数据类型映射是整套机制的地基。很多初学者写 JNI 报错,不是方法名写错,而是类型匹配错位,比如把 jobject 当成 jlong 用,或者把 jbyteArray 错误地转换成 jbyte*。搞清楚这层映射关系,其实一张表就够了。
2.1 基础类型映射表与内存布局
Java 的八种基础类型在 JNI 中都有对应的 Native 类型,这些类型本质上是在 jni.h 里用 typedef 定义出来的,目的是屏蔽不同平台下 C/C++ 基础类型的字节宽度差异。
| Java 类型 | Native 类型 | 字节数 | 说明 |
|---|---|---|---|
| boolean | jboolean | 1 | unsigned char,0 表示 false,非 0 表示 true |
| byte | jbyte | 1 | signed char |
| char | jchar | 2 | unsigned short,注意是无符号 |
| short | jshort | 2 | signed short |
| int | jint | 4 | signed int |
| long | jlong | 8 | signed long long,Java 的 long 永远是 64 位 |
| float | jfloat | 4 | 32 位浮点 |
| double | jdouble | 8 | 64 位浮点 |
这里有几个容易踩的细节。第一,jboolean 是 unsigned char,不是 bool,在 C/C++ 里把 true 直接强转成 jboolean 在大多数编译器下没问题,但如果你用 std::atomic<bool> 跨 JNI 边界传值,可能会因为 ABI 对齐问题产生告警,稳妥做法是显式写 value ? JNI_TRUE : JNI_FALSE。第二,jchar 是无符号的,它的含义是 UTF-16 编码单元,不是 ASCII 码,所以处理中文时不要想当然地认为一个 char 就是一个字节。第三,jlong 必须使用 long long 类型接收,在 32 位 ARM 平台(虽然 RK3576 是 64 位,但保不齐你会移植到别的平台)上如果用 long 接收会直接截断高 32 位,而且编译器还不会报错。
我自己的习惯是,在 Native 代码里不要裸用这些 JNI 类型做计算,而是立刻转换成 C/C++ 的标准类型。比如:
cpp复制extern "C" JNIEXPORT jlong JNICALL
Java_com_example_rk3576_NativeBridge_addLong(
JNIEnv* env, jobject thiz, jlong a, jlong b) {
int64_t a_ = static_cast<int64_t>(a);
int64_t b_ = static_cast<int64_t>(b);
return static_cast<jlong>(a_ + b_);
}
这么写看似多此一举,但对长期维护特别重要,因为 JNI 类型在 jni.h 里的 typedef 在不同 NDK 版本中有细微差异,直接拿来做计算和比较操作,可移植性会打折扣。
2.2 引用类型映射:Class、String、Array 与 Object
除了基础类型,JNI 还定义了一组引用类型,它们在 Native 侧的表现形式是不透明的指针。
| Java 类型 | Native 类型 |
|---|---|
| java.lang.Class | jclass |
| java.lang.String | jstring |
| java.lang.Throwable | jthrowable |
| 任意对象数组 | jobjectArray |
| byte[] | jbyteArray |
| long[] | jlongArray |
| 其他引用类型数组 | jobjectArray |
| 任意 Java 对象 | jobject |
所有引用类型都继承自 _jobject,在 C 编译环境下,它们是指向 JVM 内部结构体的指针;在 C++ 编译环境下,这些类型是被特化的类,但本质上仍然是指针。有个重要结论:这些指针不能直接在 Native 代码里解引用,你不能通过 jobject 去访问 Java 对象的字段,必须通过 JNI 函数来操作。这就好比你在餐厅里拿到一张取餐号,你不能自己溜进后厨去端菜,而是要把号牌交给服务员,由服务员把菜端给你。
jclass 是一个比较特殊的存在。在 JNI 里,FindClass 返回的 jclass 是一个局部引用,它指向的是 java.lang.Class 对象。很多人在 Native 方法里写了:
cpp复制jclass clazz = env->FindClass("com/example/MainActivity");
然后就直接使用这个 clazz,也没有保存它。这在一个函数调用周期内没问题,但如果跨函数使用,就必须通过 NewGlobalRef 提升为全局引用,否则函数返回后这个引用可能被 GC 回收,再用就会触发 JNI DETECTED ERROR IN APPLICATION: use of deleted local reference 这类崩溃。
2.3 字符串处理的两个核心 API 与中文字符问题
字符串是 JNI 数据映射里最常用的类型,也是最容易出问题的类型。Java 的 String 是 UTF-16 编码的,而 Native 层通常用的是 UTF-8 或本地编码,JNI 提供了两组 API 来转换。
第一组是 GetStringUTFChars 和 ReleaseStringUTFChars,它们把 Java 字符串转换成修改过的 UTF-8 编码的 C 字符串。注意“修改过的 UTF-8”这个说法,它不是标准的 UTF-8,对于 U+0000 空字符,它会编码成 0xC0 0x80 两个字节;对于增补平面字符(比如 emoji),它会编码成 surrogate pair 形式的 CESU-8。这意味着你如果直接用标准 UTF-8 解析库去处理 GetStringUTFChars 返回的字符串,在某些边界字符上会得到错误结果。
cpp复制extern "C" JNIEXPORT jstring JNICALL
Java_com_example_rk3576_NativeBridge_hello(JNIEnv* env, jobject thiz, jstring name) {
const char* name_utf = env->GetStringUTFChars(name, nullptr);
if (name_utf == nullptr) {
return nullptr; // 内存分配失败
}
std::string greeting = "Hello, ";
greeting += name_utf;
env->ReleaseStringUTFChars(name, name_utf);
return env->NewStringUTF(greeting.c_str());
}
这里有一个特别容易犯的错误:忘记检查 GetStringUTFChars 的返回值。如果 JVM 内存不足,这个函数会返回 nullptr 并抛出 OutOfMemoryError。在 C++ 里你不会直接看到异常,如果继续往下走,就是空指针解引用,直接段错误。所以标准写法是判空后再操作。
第二组 API 是 GetStringRegion 或 GetStringUTFRegion,它们把字符串内容拷贝到调用者提供的缓冲区,不会产生额外内存分配。这种模式适合处理短字符串,或者当你只需要读取一部分内容时。
cpp复制extern "C" JNIEXPORT jint JNICALL
Java_com_example_rk3576_NativeBridge_calcLength(JNIEnv* env, jobject thiz, jstring text) {
jsize len = env->GetStringUTFLength(text);
char buffer[256];
jsize copied = env->GetStringUTFRegion(text, 0, len, buffer, sizeof(buffer) - 1);
buffer[copied] = '\0';
return static_cast<jint>(strlen(buffer));
}
不过 GetStringUTFRegion 也需要特别注意缓冲区大小,比如 Java 的 char 数组长度和 UTF 编码后的字节长度不是一一对应的,一个中文字符在 UTF-16 里占 2 个字节,但在修改版 UTF-8 里占 3 到 6 个字节。所以保险的做法是先调用 GetStringUTFLength 获取确切的字节长度,再申请缓冲区,或者使用 std::string 动态扩展。
我在 RK3576 的实际项目里处理中文字符时,更推荐第二种思路:用 GetStringChars 拿到 UTF-16 的 jchar*,然后转换成 C++ 的 std::u16string,再按需转成 UTF-8。这样做的好处是绕开了 JNI 的 modified UTF-8,避免了很多边界问题,缺点是多了一次转码的开销,但换来的正确性是值得的。
3. 方法调用核心语法:Native 层主动调用 Java 方法
JNI 的方法调用方向不只是 Java 调 Native,还有反向的 Native 调 Java。后者在动态注册回调、事件通知、UI 刷新场景下非常重要。比如 RK3576 的 NPU 推理完成后,Native 层需要把一个识别结果对象传回 Java 层,这时候就要在 Native 层构造 Java 对象并调用 Java 的 setter 方法。
3.1 获取 jmethodID 的两种方式与缓存策略
要在 Native 层调用 Java 方法,第一步是拿到这个方法的唯一标识 jmethodID。有两种获取方式:GetMethodID 用于实例方法,GetStaticMethodID 用于静态方法。这两个函数都需要传入 jclass 和方法签名。
cpp复制extern "C" JNIEXPORT void JNICALL
Java_com_example_rk3576_NativeBridge_notifyResult(
JNIEnv* env, jobject thiz, jstring result) {
jclass clazz = env->GetObjectClass(thiz);
jmethodID onResult = env->GetMethodID(clazz, "onNativeResult", "(Ljava/lang/String;)V");
if (onResult == nullptr) {
return;
}
env->CallVoidMethod(thiz, onResult, result);
env->DeleteLocalRef(clazz);
}
这里有两个关键点。第一,GetObjectClass 返回的是 thiz 的实际运行类,如果你从 FindClass("com/example/NativeBridge") 获取 jclass,在继承和代理场景下可能会调用不到实际覆盖的方法。第二,GetMethodID 调用失败时返回 nullptr,并且会抛出 NoSuchMethodError 异常,需要在调用前检查返回值,否则后续的 CallVoidMethod 会在异常挂起状态下执行,JNI 规范不允许这样做。
jmethodID 的获取成本比想象中高,因为它需要 JVM 做字符串和签名的匹配。所以实践中必须做缓存。缓存方式有两种,一种是静态局部变量:
cpp复制static jmethodID s_onNativeResult = nullptr;
if (s_onNativeResult == nullptr) {
jclass clazz = env->GetObjectClass(thiz);
s_onNativeResult = env->GetMethodID(clazz, "onNativeResult", "(Ljava/lang/String;)V");
env->DeleteLocalRef(clazz);
}
另一种是缓存 jclass 为全局引用,再缓存 jmethodID。注意 jmethodID 本身不是引用类型,不需要也不能在 DeleteLocalRef 中清理,它在 JVM 内部是稳定的,只要对应的类没有被卸载,这个 ID 就一直有效。但缓存 jclass 必须用 NewGlobalRef,否则用一次就失效了。
我的建议是:对于同一个类的多个方法调用,用全局引用缓存 jclass;对于单个方法的调用,直接用静态 jmethodID 缓存就够了。还要注意在多线程环境下做缓存初始化时要处理竞态条件,最简单的办法是在 JNI_OnLoad 里统一初始化,这样就避免了懒加载的并发问题。
3.2 CallXxxMethod 家族函数与调用静态方法
JNI 提供了一整套函数用于调用 Java 方法,命名规则很清楚:CallVoidMethod、CallIntMethod、CallBooleanMethod、CallObjectMethod 等,按返回类型区分。此外还有 CallNonvirtualMethod,专门用于显式指定调用哪个类的方法,绕过多态分发。
cpp复制// 调用静态方法
extern "C" JNIEXPORT jlong JNICALL
Java_com_example_rk3576_NativeBridge_getTimestamp(
JNIEnv* env, jobject thiz) {
jclass clazz = env->FindClass("com/example/rk3576/TimeUtil");
if (clazz == nullptr) {
return -1;
}
static jmethodID s_getNow = nullptr;
if (s_getNow == nullptr) {
s_getNow = env->GetStaticMethodID(clazz, "getNow", "()J");
}
jlong result = env->CallStaticLongMethod(clazz, s_getNow);
env->DeleteLocalRef(clazz);
return result;
}
调用构造函数是另一个常用场景。Java 的对象构造方法名字固定为 <init>,签名取决于构造参数。在 Native 层创建 Java 对象时,先用 NewObject 分配对象,再调用构造方法:
cpp复制extern "C" JNIEXPORT jobject JNICALL
Java_com_example_rk3576_NativeBridge_createRect(
JNIEnv* env, jobject thiz, jint x, jint y, jint w, jint h) {
jclass rectClass = env->FindClass("android/graphics/Rect");
jmethodID init = env->GetMethodID(rectClass, "<init>", "(IIII)V");
jobject rect = env->NewObject(rectClass, init, x, y, x + w, y + h);
env->DeleteLocalRef(rectClass);
return rect;
}
这种创建对象的模式在回调数据模型时非常有用。比如 NPU 识别结果中包含多个候选框、类别和置信度,你可以先在 Native 层构建一个 ArrayList<RectF>,再一次性传回 Java 层,而不是每次回调一个对象,这会显著减少 JNI 跨越次数。
3.3 字段访问:读取和修改 Java 对象的成员变量
除了方法调用,字段访问也是高频操作。和 jmethodID 类似,字段也有对应的 jfieldID,通过 GetFieldID 获取,然后通过 GetXxxField 和 SetXxxField 读写。
cpp复制extern "C" JNIEXPORT void JNICALL
Java_com_example_rk3576_NativeBridge_setThreshold(
JNIEnv* env, jobject thiz, jfloat threshold) {
jclass clazz = env->GetObjectClass(thiz);
jfieldID thresholdField = env->GetFieldID(clazz, "threshold", "F");
if (thresholdField == nullptr) {
env->DeleteLocalRef(clazz);
return;
}
env->SetFloatField(thiz, thresholdField, threshold);
env->DeleteLocalRef(clazz);
}
静态字段用 GetStaticFieldID 和 GetStaticXxxField / SetStaticXxxField。字段签名和方法签名是同一套规则,比如 int 是 I,float 是 F,String 是 Ljava/lang/String;,int[] 是 [I。
一个常见的性能优化技巧:在热点路径上连续读取同一个对象多个字段时,建议一次性把所有 jfieldID 缓存好,然后逐个 GetXxxField。我在 RK3576 的实时视频流处理项目里,一个 AnalyzeFrame 方法要读取 Java 对象的 8 个字段,如果不缓存 jfieldID,每次调用多出 8 次签名匹配,实测耗时增加约 15 微秒,在 30 FPS 的处理循环里就是每帧多 0.5% 的 CPU 开销,积少成多还是很可观的。
3.4 数组操作:byte[] 与 float[] 的高效传递
数组是 JNI 数据传递中最常优化的类型,特别是 byte[] 和 float[],它们承载着图像数据、音频采样、模型输入输出等大块数据。JNI 提供了三种访问数组的方式,各有适用场景。
方式一:GetByteArrayElements / ReleaseByteArrayElements。这种模式可能返回指向 Java 数组内部数据的直接指针,也可能返回一个拷贝,取决于 JVM 实现。如果 isCopy 参数返回 JNI_TRUE,说明拿到的是拷贝,修改后必须调用 ReleaseByteArrayElements 才能把改动同步回 Java 数组。
cpp复制extern "C" JNIEXPORT jbyteArray JNICALL
Java_com_example_rk3576_NativeBridge_processBytes(
JNIEnv* env, jobject thiz, jbyteArray input) {
jsize len = env->GetArrayLength(input);
jbyte* inputData = env->GetByteArrayElements(input, nullptr);
if (inputData == nullptr) {
return nullptr;
}
// 原地处理,比如像素格式转换
for (jsize i = 0; i < len; i++) {
inputData[i] = static_cast<jbyte>(inputData[i] * 2);
}
env->ReleaseByteArrayElements(input, inputData, 0); // 0 表示拷贝回并释放
return input;
}
注意 ReleaseByteArrayElements 的第三个参数有两种模式,0 表示“复制回去并释放原生数组”,JNI_ABORT 表示“不复制直接释放”,用于只读场景。
方式二:GetByteArrayRegion / SetByteArrayRegion。这种模式直接拷贝到 C 缓冲区,适合处理数组的子区间,也适合 JVM 在堆上移动数组时保持稳定。
方式三:直接缓冲区 NewDirectByteBuffer。这种方式完全避免拷贝,Native 侧拿到的是 Java 侧 ByteBuffer 的内存地址,读写零成本。但使用限制是必须使用 ByteBuffer.allocateDirect() 分配,普通 new byte[] 包出来的 ByteBuffer 不能用于直接缓冲区。
cpp复制extern "C" JNIEXPORT void JNICALL
Java_com_example_rk3576_NativeBridge_fillDirectBuffer(
JNIEnv* env, jobject thiz, jobject buffer, jint length) {
uint8_t* data = static_cast<uint8_t*>(env->GetDirectBufferAddress(buffer));
if (data == nullptr) {
return;
}
for (jint i = 0; i < length; i++) {
data[i] = static_cast<uint8_t>(i);
}
}
在 RK3576 上做 NPU 推理传图时,我强烈推荐使用直接缓冲区。一张 640x480 的 RGB 图像约 900 KB,用 GetByteArrayRegion 拷贝需要约 0.8 毫秒,而用直接缓冲区几乎为零开销,而且是真正的零拷贝,内存带宽在边缘设备上是稀缺资源。代价是 Java 层分配直接缓冲区稍慢,且不受堆大小限制,需要注意释放,建议用池化方式管理。
3.5 方法签名(方法描述符)的书写规则
JNI 的方法签名(Method Descriptor)是很多人的拦路虎,其实规则就一句话:括号内是参数类型,括号后是返回类型。括号本身不包含任何额外字符,参数之间也没有分隔符。
举几个例子:
| Java 方法 | JNI 签名 |
|---|---|
void onResult(String s) |
(Ljava/lang/String;)V |
int add(int a, int b) |
(II)I |
boolean equals(Object o) |
(Ljava/lang/Object;)Z |
void setRect(int l, int t, int r, int b) |
(IIII)V |
void onList(ArrayList<String> list) |
(Ljava/util/ArrayList;)V |
byte[] process(byte[] data, int len) |
([BI)[B |
void onEvent(int type, long timestamp, String msg) |
(IJLjava/lang/String;)V |
一个容易混淆的地方是,long 的签名字母是 J 而不是 L。L 是完整类名前的标记,比如 Ljava/lang/String; 表示 java.lang.String,类名必须用 / 分隔包路径,末尾必须带分号。数组类型用英文方括号 [ 开头,多维数组就用多个 [,比如 [[I 是 int[][],[Ljava/lang/String; 是 String[]。
这里再分享一个调试签名的小技巧:如果你不确定某个方法的签名,在 Java 层反射调用 getMethod 后使用 toGenericString() 可以打印包含形参名的方法声明,但反射拿到的并非 JNI 签名格式。最可靠的办法还是用 javap 命令,在编译好的 class 文件目录运行:
bash复制javap -s -p com.example.rk3576.NativeBridge
它会输出每个方法(包括私有方法)的 JNI 签名,比如:
code复制public void onNativeResult(java.lang.String);
descriptor: (Ljava/lang/String;)V
我一般会在写完一个 JNI 方法后立刻用 javap 校验签名,一秒钟的事,能省掉很多因为手写签名错误导致的 NoSuchMethodError 崩溃。
4. 一个完整的 RK3576 实战案例:NPU 推理结果回调
把前面的知识点串起来,用一个 RK3576 上比较典型的小案例来展示完整的 JNI 调用链:Java 层把一张图片的 byte[] 传进 Native 层,Native 层模拟 NPU 推理(这里用简单的灰度直方图代替),然后把结果封装成 Java 对象列表回调到 Java 层。
4.1 Java 侧接口设计和 Native 方法声明
先定义 Java 侧的接口,我习惯把 JNI 接口集中在一个类里:
java复制package com.example.rk3576;
public class NativeBridge {
static {
System.loadLibrary("jni_core");
}
// 推理结果数据类
public static class RecognitionResult {
public int left;
public int top;
public int right;
public int bottom;
public float confidence;
public String label;
public RecognitionResult(int left, int top, int right, int bottom,
float confidence, String label) {
this.left = left;
this.top = top;
this.right = right;
this.bottom = bottom;
this.confidence = confidence;
this.label = label;
}
}
// Native 方法声明,接收灰度图数据,返回识别结果数量
public native int detectObjects(byte[] grayData, int width, int height);
// Native 层回调此方法,保存识别结果
public void pushResult(RecognitionResult result) {
// 在 UI 线程刷新集合
}
}
注意,Native 方法 detectObjects 声明为 public native int,它并不返回 RecognitionResult 数组,而是通过回调 pushResult 逐条推送。这种设计的考虑是:识别结果的数量在 Native 层才知道,用回调避免在 Native 层构建大数组的额外开销,同时 Java 层可以按需处理。
4.2 Native 层实现:图像处理加回调 Java 方法
现在写 Native 层的完整实现。为了演示清晰,我把检测逻辑简化成二值化后统计连通区域。
cpp复制#include <jni.h>
#include <android/log.h>
#include <vector>
#include <cstdint>
#define LOG_TAG "RK3576JNI"
#define LOGI(...) __android_log_print(ANDROID_LOG_INFO, LOG_TAG, __VA_ARGS__)
#define LOGE(...) __android_log_print(ANDROID_LOG_ERROR, LOG_TAG, __VA_ARGS__)
static JavaVM* s_vm = nullptr;
// 缓存 RecognitionResult 类和构造方法ID
static jclass s_resultClass = nullptr;
static jmethodID s_resultInit = nullptr;
static jmethodID s_pushResult = nullptr;
extern "C" JNIEXPORT jint JNICALL
Java_com_example_rk3576_NativeBridge_detectObjects(
JNIEnv* env, jobject thiz, jbyteArray grayData, jint width, jint height) {
jsize length = env->GetArrayLength(grayData);
jbyte* data = env->GetByteArrayElements(grayData, nullptr);
if (data == nullptr) {
return -1;
}
int count = 0;
const int threshold = 128;
// 统计灰度大于阈值的像素数,作为“目标区域”的简化指标
for (jsize i = 0; i < length; i++) {
if ((static_cast<uint8_t>(data[i]) > threshold) && (i % 7 == 0)) {
count++;
}
}
env->ReleaseByteArrayElements(grayData, data, JNI_ABORT);
// 获取 thiz 的类,缓存 pushResult 方法
jclass bridgeClass = env->GetObjectClass(thiz);
if (s_pushResult == nullptr) {
s_pushResult = env->GetMethodID(bridgeClass, "pushResult",
"(Lcom/example/rk3576/NativeBridge$RecognitionResult;)V");
}
// 获取 RecognitionResult 类并缓存构造方法
if (s_resultClass == nullptr) {
jclass localResultClass = env->FindClass("com/example/rk3576/NativeBridge$RecognitionResult");
s_resultClass = static_cast<jclass>(env->NewGlobalRef(localResultClass));
s_resultInit = env->GetMethodID(s_resultClass, "<init>", "(IIIIFLjava/lang/String;)V");
env->DeleteLocalRef(localResultClass);
}
if (s_pushResult != nullptr && s_resultInit != nullptr && count > 0) {
// 构造一个简单的识别结果并回调
jobject resultObj = env->NewObject(s_resultClass, s_resultInit,
static_cast<jint>(0),
static_cast<jint>(0),
width,
static_cast<jint>(height / 2),
static_cast<jfloat>(0.85f),
env->NewStringUTF("rk3576_demo_target"));
env->CallVoidMethod(thiz, s_pushResult, resultObj);
env->DeleteLocalRef(resultObj);
}
env->DeleteLocalRef(bridgeClass);
return count;
}
extern "C" JNIEXPORT jint JNICALL
JNI_OnLoad(JavaVM* vm, void* reserved) {
s_vm = vm;
// 这里可以做 jclass 的提前缓存,避免运行时的竞态条件
return JNI_VERSION_1_6;
}
这个例子把前面讲的多个要点融合在一起:GetByteArrayElements 读取图像数据、JNI_ABORT 标记只读模式、GetMethodID 缓存实例方法、FindClass 配合 NewGlobalRef 缓存类引用、NewObject 调用构造方法、NewStringUTF 创建字符串,最后 CallVoidMethod 回调到 Java 层。
有几个值得注意的地方。第一,s_resultInit 缓存的构造方法签名是 (IIIIFLjava/lang/String;)V,它对应 Java 构造方法 RecognitionResult(int, int, int, int, float, String),一个字母都不能差。第二,env->NewStringUTF("rk3576_demo_target") 创建的是局部引用,在 CallVoidMethod 执行完、函数返回后就会被释放,Java 层的 pushResult 持有了这个字符串的引用,所以不会丢。第三,每次 FindClass 或 GetObjectClass 返回的局部引用都要记得 DeleteLocalRef,在循环频繁调用时尤其重要,否则局部引用表会满,ART 虚拟机默认阈值是 512 个,超出直接 abort。
4.3 交叉编译与部署到 RK3576
在 RK3576 设备上跑这个例子的完整流程是这样的。如果是 Android Studio 工程,直接 Build APK 安装即可。如果是把 JNI 模块编进 AOSP 源码,就需要在 device/rockchip 对应的产品 mk 文件里添加模块声明。
我推荐一种更灵活的联调方式:用 ndk-build 或 CMake 单独编出 libjni_core.so,然后通过 adb push 推送到 /data/local/tmp,再写一个简单的 Java 测试程序调用。这样不用每次改 C++ 代码都重编整个 APK,大幅缩短迭代时间。在 CMake 里出 so 的命令是:
bash复制cmake -DCMAKE_TOOLCHAIN_FILE=$NDK/build/cmake/android.toolchain.cmake \
-DANDROID_ABI=arm64-v8a \
-DANDROID_PLATFORM=android-24 \
-DANDROID_STL=c++_shared \
..
make
编出来的 libjni_core.so 要手工放到 APK 的 lib/arm64-v8a 目录下,或者用 adb push 到 /data/local/tmp 后用 System.load() 加载。Android Studio 的标准流程是自动处理这些,但独立 so 推送到设备的流程在调试阶段更好使,特别是 RK3576 这类嵌入式板子经常没有配套的完整调试环境,手工部署反而更直接。
5. 常见问题与排查技巧实录
JNI 开发最让人头疼的不是语法,而是崩溃时 logcat 里那几行让人摸不着头脑的 JNI 错误信息。下面这几个问题是我在 RK3576 开发过程中真实遇到过的,整理出来供大家参考。
5.1 JNI 崩溃黑话速查表
| 崩溃信息 | 原因 | 解决方案 |
|---|---|---|
JNI DETECTED ERROR IN APPLICATION: use of deleted local reference |
使用了已经 DeleteLocalRef 的引用 | 检查引用生命周期,确认在 DeleteLocalRef 后没有再使用 |
JNI DETECTED ERROR IN APPLICATION: JNI GetMethodID called with pending exception |
前一个 JNI 调用抛了异常但没有处理 | 在每次 JNI 调用后检查 ExceptionCheck() 或 ExceptionClear() |
No implementation found for void com.example.NativeBridge.call() |
Java 层声明的 native 方法在 so 里找不到对应导出函数 | 用 nm -D libxxx.so 查看导出符号,核对方法名和包名 |
Ljava/lang/NoSuchMethodError: <init> |
构造方法签名错误或类名错误 | 用 javap -s -p 核对签名 |
abort message: 'art::Thread::DumpStack' 伴随 SIGSEGV |
Native 代码空指针或数组越界 | 用 tombstone 定位崩溃行号,或 AddressSanitizer 排查 |
排查 JNI 崩溃时,我强烈建议开启 debug 模式下的更严格检查:
bash复制adb shell setprop debug.checkjni 1
adb shell setprop debug.hwui.renderer opengl
adb logcat -s AndroidRuntime:E DEBUG:V
debug.checkjni 1 会启用 ART 的 JNI 访问检查,能捕获到很多潜在的引用误用和类型不匹配,虽然会拖慢执行速度,但排查问题阶段非常划算。
5.2 方法签名错误的定位思路
NoSuchMethodError 这个错误是最常见的。它的定位思路一般是:先确认 Java 层方法名拼写无误,再确认 GetMethodID 的调用对象是实例方法还是静态方法,最后用 javap 核对签名。
有一个细节值得强调:JNI 规范要求 GetMethodID 必须在 ExceptionCheck() 为 JNI_FALSE 的状态下调用。因为 FindClass 找不到类、或者 GetArrayLength 传入了错误对象,都会使 JVM 设置 pending exception,此时再调用 GetMethodID,ART 会直接报错。我的习惯是在每个可能抛异常的 JNI 函数调用后立刻检查,如果有异常先 ExceptionDescribe 打日志,再 ExceptionClear 清理,防止异常状态在 JNI 调用间传播。
5.3 动态注册 vs 静态注册:哪个更适合 RK3576 项目
前面所有示例用的都是静态注册,也就是函数名必须严格遵守 Java_包名_类名_方法名 的规则。这种方式简单直观,但有两个问题:一是一次 .so 导出大量函数名,符号表很大;二是方法名重构时容易遗漏,Java 侧改了名但 Native 侧没改,运行时才崩溃。
动态注册是另一种方式,在 JNI_OnLoad 里用 RegisterNatives 注册方法映射。好处是方法命名自由、崩溃时能拿到更清晰的堆栈、加载更快。对于模块较多的 RK3576 大型项目,我推荐使用动态注册,尤其适合 JNI 方法数量超过 20 个的模块。
cpp复制static const JNINativeMethod gMethods[] = {
{"detectObjects", "([BII)I", reinterpret_cast<void*>(detectObjects)},
{"nativeInit", "()Z", reinterpret_cast<void*>(nativeInit)},
{"nativeRelease", "()V", reinterpret_cast<void*>(nativeRelease)},
};
extern "C" JNIEXPORT jint JNICALL
JNI_OnLoad(JavaVM* vm, void* reserved) {
JNIEnv* env = nullptr;
if (vm->GetEnv(reinterpret_cast<void**>(&env), JNI_VERSION_1_6) != JNI_OK) {
return JNI_ERR;
}
jclass clazz = env->FindClass("com/example/rk3576/NativeBridge");
if (clazz == nullptr) {
return JNI_ERR;
}
if (env->RegisterNatives(clazz, gMethods, sizeof(gMethods) / sizeof(gMethods[0])) != JNI_OK) {
return JNI_ERR;
}
env->DeleteLocalRef(clazz);
return JNI_VERSION_1_6;
}
动态注册还有一个好处,就是可以先定义好 JNI_OnLoad 里的 RegisterNatives 表,不管 C++ 函数是否实现,Java 侧编译都不会报错,运行时如果某个方法没注册成功,RegisterNatives 会返回错误码而不是崩溃,便于快速定位是哪一行出了问题。
5.4 多线程回调 Java 方法时的注意事项
RK3576 做 AI 推理时,Native 层的推理线程往往和 Java 层的 UI 线程不是同一个线程。JNI 规范里有个硬性要求:JNIEnv* 是线程绑定的,不能跨线程传递使用。你在子线程里拿到的 JNIEnv*,拿到主线程去调用 CallVoidMethod,必崩。
正确做法是,在子线程里通过 JavaVM* 获取当前线程的 JNIEnv*:
cpp复制static JavaVM* s_vm = nullptr;
void NativeThreadFunction() {
JNIEnv* env = nullptr;
jint attachResult = s_vm->AttachCurrentThread(&env, nullptr);
if (attachResult == JNI_OK && env != nullptr) {
// 现在可以安全调用 Java 方法了
jclass clazz = env->FindClass("com/example/rk3576/NativeBridge");
jmethodID onThreadDone = env->GetMethodID(clazz, "onThreadDone", "()V");
env->CallStaticVoidMethod(clazz, onThreadDone);
// 线程退出时记得 Detach
s_vm->DetachCurrentThread();
}
}
这里有个性能陷阱:AttachCurrentThread 和 DetachCurrentThread 是有开销的,在高频回调场景下不要每次调用都做 attach/detach。更合理的模式是线程启动时 attach 一次,线程退出时才 detach。线程内部用一个局部 JNIEnv* 反复使用即可。
还要提一个很多人忽略的问题:子线程 attach 后,即使主线程没有给这个子线程创建任何 Java 对象,这个线程也会在 ART 里注册为 Java 线程,栈帧会增加约 64 KB 的内存占用。如果开的线程很多,内存开销不容忽视。我个人在做 RK3576 多路视频分析时,把线程数控制在 CPU 核心数的两倍以内,并且对 Native 工作线程做了池化,避免频繁创建和销毁。
6. 写在后面的实操体会
JNI 这块语法本身不复杂,复杂的是它横跨了 Java 虚拟机和 Native 运行时的边界,每一个边界都是坑的藏身之处。我在 RK3576 项目里做 JNI 封装时有过一个很深的体会:JNI 层的代码要尽量薄,只做类型转换和边界处理,真正的业务逻辑别塞进 JNI 实现里。一旦业务逻辑复杂了,JNI 函数就变成一个大杂烩,引用管理、线程同步、异常处理全混在一起,出问题后极难排查。
数据类型映射是这一系列内容的第一块硬骨头,但更关键的其实是引用管理和线程模型,这两块是 JNI 崩溃的绝大多数来源,也是后面“JNI 核心语法(中)”要详细展开的部分。如果你要用 RK3576 做实际产品,建议先把本地引用和全局引用的生命周期彻底搞清楚,再把 AttachCurrentThread 和异常处理的流程写进自己的代码模板里,这样后面加功能、加模块时都能少踩很多坑。
