3. JNI与NDK开发基础:JNI函数注册、数据类型映射、C/C++与Java互调
各位同学,今天我们来聊聊JNI和NDK。说实话,这可能是整个嵌入式Android开发里最让人头疼的部分之一。我记得刚入行那会儿,第一次看到JNI的代码,满屏的JNIEnv和jobject,差点把我劝退。但后来我发现,只要把几个核心概念理清楚,这东西其实没那么玄乎。
说白了,JNI就是一座桥。桥的一头是Java世界,另一头是C/C++世界。你要让两边的人能互相串门、互相传话,就得有套规矩。今天我们就来把这套规矩掰开揉碎讲清楚。
3.1 JNI函数注册:两种方式,各有千秋
JNI函数的注册,说白了就是告诉虚拟机:“嘿,这个Java方法对应的C函数是哪个?” 有两种方式可以实现。
3.1.1 静态注册:按名字找函数
这是最传统的方式。你只要按照JNI的命名规范来写函数名,虚拟机就能自动找到它。命名规则是:Java_包名_类名_方法名。
举个例子,假设Java端有个类叫com.example.NativeLib,里面有个native int add(int a, int b)方法。那C端的函数名就得写成:
JNIEXPORT jint JNICALL Java_com_example_NativeLib_add
(JNIEnv *env, jobject thiz, jint a, jint b) {
return a + b;
}
嗯,这里要注意:JNIEnv*和jobject是每个JNI函数的前两个固定参数。前者是JNI环境的指针,后者是调用这个native方法的Java对象(如果是静态方法,那就是jclass)。
3.1.2 动态注册:用JNI_OnLoad手动绑定
动态注册就灵活多了。你可以在JNI_OnLoad函数里,通过RegisterNatives方法手动建立Java方法和C函数的映射关系。
static const JNINativeMethod methods[] = {
{"add", "(II)I", (void*)native_add},
{"stringFromJNI", "()Ljava/lang/String;", (void*)native_stringFromJNI}
};
JNIEXPORT jint JNICALL JNI_OnLoad(JavaVM *vm, void *reserved) {
JNIEnv *env = NULL;
if ((*vm)->GetEnv(vm, (void**)&env, JNI_VERSION_1_6) != JNI_OK) {
return JNI_ERR;
}
jclass clazz = (*env)->FindClass(env, "com/example/NativeLib");
if (clazz == NULL) return JNI_ERR;
jint result = (*env)->RegisterNatives(env, clazz, methods, sizeof(methods)/sizeof(methods[0]));
if (result < 0) return JNI_ERR;
return JNI_VERSION_1_6;
}
你看,这里的关键是JNINativeMethod结构体。它有三个字段:Java方法名、方法签名、C函数指针。方法签名这东西,说白了就是描述参数和返回值的类型字符串。比如"(II)I"表示两个int参数,返回int。
3.2 数据类型映射:Java和C/C++怎么传数据
Java和C/C++的数据类型不是一一对应的。比如Java的int是32位有符号整数,C的int在不同平台上可能是16位或32位。所以JNI定义了一套映射规则。
| Java类型 | JNI类型 | C/C++类型 | 说明 |
|---|---|---|---|
| boolean | jboolean | unsigned char | 0表示false,非0表示true |
| byte | jbyte | signed char | 8位有符号 |
| char | jchar | unsigned short | 16位Unicode字符 |
| short | jshort | short | 16位有符号 |
| int | jint | int | 32位有符号 |
| long | jlong | long long | 64位有符号 |
| float | jfloat | float | 32位浮点 |
| double | jdouble | double | 64位浮点 |
| String | jstring | jstring | 需要调用GetStringUTFChars获取C字符串 |
| 数组 | jintArray等 | jintArray | 需要调用GetIntArrayElements获取指针 |
为什么会有这套映射?你想想看,Java虚拟机要保证跨平台一致性,而C/C++在不同硬件上的类型长度可能不一样。所以JNI在中间做了一层适配,确保数据不会传错。
3.3 C/C++调用Java:反向操作也不难
很多人以为JNI只能从Java调C,其实反过来也行。在C代码里,你可以通过JNIEnv提供的API来调用Java的方法、访问Java的字段。
举个例子,假设Java端有个类:
public class NativeLib {
private int counter = 0;
public void onProgress(int percent) {
Log.d("NativeLib", "Progress: " + percent + "%");
}
}
在C代码里,你可以这样调用Java的onProgress方法:
void call_java_progress(JNIEnv *env, jobject thiz, int percent) {
// 1. 获取Java类的class对象
jclass clazz = (*env)->GetObjectClass(env, thiz);
// 2. 获取方法ID
jmethodID methodId = (*env)->GetMethodID(env, clazz, "onProgress", "(I)V");
if (methodId == NULL) {
// 方法没找到,处理错误
return;
}
// 3. 调用方法
(*env)->CallVoidMethod(env, thiz, methodId, percent);
// 4. 释放局部引用(可选,但建议做)
(*env)->DeleteLocalRef(env, clazz);
}
你看,步骤很清晰:先找到类,再找到方法,最后调用。访问字段也是类似的套路,只不过用GetFieldID和GetIntField之类的函数。
AttachCurrentThread。我曾经在一个后台线程里直接调Java方法,结果程序直接挂掉,日志里只有一句"JNI ERROR (app bug)",排查了好久才发现是线程没attach。
3.4 知识体系总览
为了让你对整个JNI与NDK开发有个全局认识,我画了一张图。这张图把今天讲的核心内容串在了一起:
3.5 实战中的几个坑
最后,我分享几个实际项目中踩过的坑,希望能帮你少走弯路。
- 局部引用溢出:JNI的局部引用是有数量限制的(通常512个)。如果你在循环里频繁创建局部引用而不释放,就会溢出。我遇到过在图像处理循环里每帧都创建jstring,结果处理到第513帧时直接崩溃。解决方案是及时调用
DeleteLocalRef,或者用PushLocalFrame/PopLocalFrame批量管理。 - 全局引用泄漏:如果你需要在多个JNI调用之间保持一个Java对象的引用,必须用
NewGlobalRef创建全局引用。用完了一定要DeleteGlobalRef释放。我曾经在某个模块里忘记释放全局引用,导致内存泄漏,应用运行几天后OOM了。 - 异常处理:JNI函数里如果发生了Java异常,不会自动抛出。你需要调用
ExceptionCheck或ExceptionOccurred来检查,然后决定是处理还是清除。不清除异常的话,后续的JNI调用都会失败。 - 方法签名写错:这个前面提过了,但值得再说一次。签名写错不会在编译时报错,只在运行时崩溃。我的习惯是写完后,用
javap -s命令查看Java类的签名,然后逐字符核对。
好了,关于JNI与NDK开发基础,今天就讲到这里。这些内容看起来琐碎,但每一个细节都关系到程序的稳定性和性能。你只要把函数注册、类型映射、互调这三个核心点吃透,剩下的就是多写多练了。