3. JNI与NDK开发基础:JNI函数注册、数据类型映射、C/C++与Java互调

各位同学,今天我们来聊聊JNI和NDK。说实话,这可能是整个嵌入式Android开发里最让人头疼的部分之一。我记得刚入行那会儿,第一次看到JNI的代码,满屏的JNIEnvjobject,差点把我劝退。但后来我发现,只要把几个核心概念理清楚,这东西其实没那么玄乎。

说白了,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)。

我的经验:静态注册的好处是简单直接,不用额外写注册代码。但缺点也很明显——函数名太长,容易写错。我曾经因为包名里多写了一个下划线,排查了整整一个下午。所以如果你用静态注册,建议直接复制Java类全限定名,然后手动替换点号为下划线。

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。

避坑指南:我曾经在动态注册时,因为方法签名写错了一个字符,导致运行时直接崩溃。而且这种崩溃不会给你任何有用的日志,只能靠加日志打印来排查。所以我的建议是:写签名时,对照着JNI的类型映射表一个一个字符核对。

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);
}

你看,步骤很清晰:先找到类,再找到方法,最后调用。访问字段也是类似的套路,只不过用GetFieldIDGetIntField之类的函数。

注意:在C代码里调用Java方法时,一定要确保当前线程已经attach到了Java虚拟机。如果你是在一个原生线程(不是Java创建的线程)里调用,需要先调用AttachCurrentThread。我曾经在一个后台线程里直接调Java方法,结果程序直接挂掉,日志里只有一句"JNI ERROR (app bug)",排查了好久才发现是线程没attach。

3.4 知识体系总览

为了让你对整个JNI与NDK开发有个全局认识,我画了一张图。这张图把今天讲的核心内容串在了一起:

JNI与NDK开发基础 - 知识体系 Java层 native方法声明 JNI_OnLoad(动态注册) 回调Java方法 JNI桥 JNIEnv API 类型映射 方法签名 C/C++层 原生函数 NDK编译 性能计算 核心流程: 1. Java层声明native方法,编译生成头文件或手动编写JNI函数 2. 通过静态注册(按命名规则)或动态注册(JNI_OnLoad)绑定C函数 3. JNI桥负责类型转换:Java int ↔ jint ↔ C int 4. C代码可通过JNIEnv调用Java方法或访问字段(反向互调) 5. 注意线程attach、局部引用管理、异常处理等细节 JNI是双向桥梁,Java和C/C++通过它实现互调

3.5 实战中的几个坑

最后,我分享几个实际项目中踩过的坑,希望能帮你少走弯路。

  • 局部引用溢出:JNI的局部引用是有数量限制的(通常512个)。如果你在循环里频繁创建局部引用而不释放,就会溢出。我遇到过在图像处理循环里每帧都创建jstring,结果处理到第513帧时直接崩溃。解决方案是及时调用DeleteLocalRef,或者用PushLocalFrame/PopLocalFrame批量管理。
  • 全局引用泄漏:如果你需要在多个JNI调用之间保持一个Java对象的引用,必须用NewGlobalRef创建全局引用。用完了一定要DeleteGlobalRef释放。我曾经在某个模块里忘记释放全局引用,导致内存泄漏,应用运行几天后OOM了。
  • 异常处理:JNI函数里如果发生了Java异常,不会自动抛出。你需要调用ExceptionCheckExceptionOccurred来检查,然后决定是处理还是清除。不清除异常的话,后续的JNI调用都会失败。
  • 方法签名写错:这个前面提过了,但值得再说一次。签名写错不会在编译时报错,只在运行时崩溃。我的习惯是写完后,用javap -s命令查看Java类的签名,然后逐字符核对。

好了,关于JNI与NDK开发基础,今天就讲到这里。这些内容看起来琐碎,但每一个细节都关系到程序的稳定性和性能。你只要把函数注册、类型映射、互调这三个核心点吃透,剩下的就是多写多练了。

公众号:蓝海资料掘金营,微信deep3321