【菜狗教程】JNI.01 - 跨越 JVM 的边界

JNI 概述

FFI(Foreign Function Interface) 是编程中比较通用的概念,表示一种在不同的编程语言之间互相调用的机制。两种编程语言在设计时内存里的内容就几乎不可能互相兼容,但每出一种新的编程语言都重写一遍所有的轮子非常不现实,FFI 机制意味着编程语言对外提供其自身的调用约定,允许被另一种编程语言的程序调用。JNI 是一种用于 Java 和 C/C++ 之间的 FFI,全称是 Java Native Interface。

在 Android App 开发中,native 通常使用 C/C++ 编译出的动态库(.so),直到近几年才有 Rust 的身影出现,我们还是以 C/C++ 为主。

可能目前真实使用 Rust 做移动端 SDK 的团队并不多,但在大家没怎么注意的时候,Android NDK 的官方文档中已经出现了推荐 Rust 的描述: 原文链接:developer.android.com/ndk/guides/...

JNI 的使用方法

提到 JNI,我猜大部分 Android App 开发者是接触过的。对于不是重度依赖内部 native SDK 的项目来说,使用 JNI 的场景通常只有接入某个文档齐全的第三方库,或者接入之后做一些 API 的封装,大部分的精力都不在 JNI 本身,所以调通了之后很快就忘记 JNI 部分本身到底是怎么写的了,以至于下次用到的时候几乎是从头开始再学一次。

我自己就经历过这种情况,在上家公司做一个摄像头实时扫描的功能时,需要给 Android 和 iOS 双端提供基于 OpenCV 的 SDK,整个功能都做好发布并优化过几次了,今年在新公司做其他功能需要写 JNI 的时候我发现我依然不能思路清晰地完成 JNI 部分的代码,总会遇到编译期甚至运行时才暴露的问题,再反复修改。

所以先来看一下 JNI 怎么使用吧。根据 Android 调用 C++ 的运行流程,我们可以将 JNI 简单分为三个阶段:

  1. 注册 native 函数
  2. 调用 native 函数
  3. 向 Java/Kotlin 回调数据

注册

注册可以分为静态注册动态注册。在 Java 中可以使用 native 关键字定义一个 native 函数,native 函数不允许有函数体,注册的目的就是将 Java 中的 native 函数和 C++ 里的一个函数建立一对一的映射关系。Kotlin 的 external 函数同理。

静态注册是通过对函数命名的「约定」实现的,定位一个 Java 函数需要包名+类名+函数名+函数参数列表+函数返回值类型,C 语言中没有类,C 和 C++ 都没有包名的概念,所以约定就是参数和返回值直接对应,函数名由包名+类名+函数名组成。

cpp 复制代码
// native-lib.cpp 
extern "C" JNIEXPORT jstring JNICALL
Java_cg_share_jni_NativeBridge_stringFromJNI(
        JNIEnv* env,
        jobject /* this */) {
    std::string hello = "Hello from C++";
    return env->NewStringUTF(hello.c_str());
}

// NativeBridge.kt
package cg.share.jni
class NativeBridge {
    companion object {
        init {
            System.loadLibrary("jni")
        }
    }

    external fun stringFromJNI(): String
}

Android Studio 直接创建含 native 的 project 就有类似上面的代码了,不仅如此,Android Studio 甚至能支持两个函数之间的快捷跳转,看起来很直观。在一些小型项目中只使用静态注册就够了,但随着 native 函数的增加,静态注册会变得很难管理,Android Studio 对 native 的支持也会卡顿甚至不响应,这种时候就该考虑动态注册了。

动态注册是通过调用 JNIEnv::RegisterNatives 实现的,调用的时机是 JNI_OnLoad。这里可以将 JNI_OnLoad 理解成一个生命周期,触发的时机是 System.loadLibrary 选择了对应的 .so 动态库之后。

在 JNI 的 C++ 代码中,有两个常用的结构体,分别是 JavaVM 和 JNIEnv,他们定义在 jni.h 中,所有跟 JVM 有关的操作都需要经由他们。详细的介绍后面再说。

我们改造一下上面的代码,还是实现一个 stringFromJNI,Kotlin 代码就不重复贴了。

cpp 复制代码
jstring stringFromJNI(JNIEnv* env, jobject /* this */) {
    std::string hello = "Hello from C++ dynamic";
    return env->NewStringUTF(hello.c_str());
}

static bool registerNativeMethods(const std::shared_ptr<JNIEnv>& env, const std::string& className, const std::vector<JNINativeMethod>& gMethods, int numMethods) {
    ALOGE("Registering %s's %d native methods...", className.c_str(), numMethods);
    jclass clazz;
    clazz = env->FindClass(className.c_str());
    if (clazz == NULL) {
        ALOGE("Native registration unable to find class '%s'", className.c_str());
        return JNI_FALSE;
    }
    if (env->RegisterNatives(clazz, gMethods.data(), numMethods) < 0) {
        ALOGE("RegisterNatives failed for '%s', method number is %d", className.c_str(), numMethods);
        return JNI_FALSE;
    }
    return JNI_TRUE;
}

jint JNI_OnLoad(JavaVM* vm, void* reserved) {
    ALOGE("JNI_OnLoad");

    std::shared_ptr<JNIEnv> env = nullptr;
    if (vm->GetEnv(reinterpret_cast<void**>(&env), JNI_VERSION_1_6) != JNI_OK) {
        ALOGE("GetEnv failed!");
        return JNI_ERR;
    }

    std::string className = "cg/share/jni/NativeBridge";
    std::vector<JNINativeMethod> methods =
    {
            {"stringFromJNI", "()Ljava/lang/String;", (void*)stringFromJNI},
    };

    auto success = registerNativeMethods(env, className, methods, methods.size());
    if (!success) {
        return -1;
    }

    return JNI_VERSION_1_6;
}

动态注册就不是那么直观了,我们可以再进行一个拆分。

动态注册的流程可以分为三个阶段:

  1. 获取 JNIEnv
  2. FindClass 找到 native 函数所在的 Java/Kotlin 类
  3. RegisterNatives 将一个函数列表注册到指定 class 里

因为不是直接映射函数,就还需要一种描述函数的方式,在上面的例子中就是stringFromJNI()Ljava/lang/String;,前者是函数名,后者是函数的「签名」。函数签名用一种字符串的映射关系表示类型,通过小括号区分参数列表和返回值。

类型的映射关系是这样的:

Java 类型 JNI 函数签名
boolean Z
byte B
char C
short S
int I
long J
float F
double D
void V
Object L[类名];
String Ljava/lang/String;
int[] [I
Object[] [L[类名];
String[] [Ljava/lang/String;

特别提醒:Object 是 L 开头 ; 结尾的,千万别忘了结尾。

调用

在 Java/Kotlin 中调用 native 函数的过程就没什么复杂的了,跟普通函数一样用就行了。需要注意的就是要保证在调用之前真的执行过 System.loadLibrary。

回调

这里回调的含义是将数据传回给 Java/Kotlin,包含了函数的返回值和异步回调函数,但核心问题是相同的,那就是我们需要使用 C++ 代码创建一个 JVM 里存在的对象。

回头看 stringFromJNI 的例子,Java 需要的返回值类型是 String,而 C++ 的函数返回值是 jstring,但同时函数签名中的返回值类型又是Ljava/lang/String;,好像有一丝微妙的感觉对吧。String 这个返回值是最特殊的一种了,我们可以拆成两部分看。jstring 和 jint、jboolean 一样,是 JNI 定义好的跟 Java 类型可以直接转换的类型,比如在 JNI_OnLoad 里面我们就直接返回了 -1。另一方面,String 在 Java 中并不是基本类型,而是一个对象,所以在函数签名中跟普通的对象是相同的。结合这两点,很容易得知 JNI 创建 String 的方法 NewStringUTF 也是为 String 这个常用类型提供的简化版。

那么抛开基本类型和 String 不谈,C++ 要怎么创建和使用一个普通 Java 对象呢,我们再改造一下代码。

kotlin 复制代码
// 
package cg.share.jni

class NormalObject(val id: Int, val content: String) {

}

interface NormalCallback {
    fun invoke(result: NormalObject)
}

// NativeBridge.kt
//...
external fun stringFromJNIAsync(callback: NormalCallback)

回调函数是一种非常常见的场景,新增的 stringFromJNIAsync 没有返回值,而是通过传一个 callback 对象进来,等 C++ 的函数体创建一个 NormalObject 传回来,Activity 的代码也要做一点微调,这部分就不贴出来了。

那么问题来了,想一下,stringFromJNIAsync 的函数签名是什么?(答案在下一个代码块揭晓)

在 Java 中创建一个对象,只需要调用它的构造方法,在 C++ 里其实也一样,关键在于怎么找到那个构造方法,触发回调函数则是需要通过已知的对象,找到它的回调方法,核心都是「找」。找的过程其实很固定,熟悉反射的话我(首先,我不熟悉)应该可以无痛学习 JNI 的这部分,基本的流程就是先找 class 再找 method。要注意,找的时候同样需要函数签名,还是不熟悉的话 Copilot 可以帮忙。

cpp 复制代码
void stringFromJNIAsync(JNIEnv* env, jobject /* this */, jobject callback /* callback */) {
    std::string hello = "Hello from C++ dynamic async";
    ALOGE("stringFromJNIAsync");

    jclass clazz = env->GetObjectClass(callback);
    jmethodID methodId = env->GetMethodID(clazz, "invoke", "(Lcg/share/jni/NormalObject;)V");

    jclass normalObjectClazz = env->FindClass("cg/share/jni/NormalObject");
    jmethodID normalObjectConstructor = env->GetMethodID(normalObjectClazz, "<init>", "(ILjava/lang/String;)V");
    jobject normalObject = env->NewObject(normalObjectClazz, normalObjectConstructor, 10086, env->NewStringUTF(hello.c_str()));

    env->CallVoidMethod(callback, methodId, normalObject);
}

jint JNI_OnLoad(JavaVM* vm, void* reserved) {
//...
    std::string className = "cg/share/jni/NativeBridge";
    std::vector<JNINativeMethod> methods =
    {
            {"stringFromJNI", "()Ljava/lang/String;", (void*)stringFromJNI},
            {"stringFromJNIAsync", "(Lcg/share/jni/NormalCallback;)V", (void*)stringFromJNIAsync}
    };
//...
}

看完代码是不是觉得很简单呢,因为所有的操作都要通过 JNIEnv 完成,没有 Copilot 的时候输入 env-> 的时候代码提示也是足够用的。

总结

以前我能感觉到 JNI 很难学会,但很难说清楚原因,随着日常使用次数的增加,我发现 JNI 这种跨越两个语言边界的代码是不得不「耦合」的,我们遇到的问题至少会是 C++ 语言本身、JNI 的 API 再加上 CMake 构建等多个方面结合,再加上真实项目中还有多线程的问题和 JVM 垃圾回收的问题,并不是 JNI 本身就设计得很糟糕导致学习曲线很陡峭。

关于 JNI 的设计思路,Oracle 的使用文档里专门写了一整节介绍每一个部分,等我学会了就来写第二篇。

相关推荐
唐诺4 小时前
几种广泛使用的 C++ 编译器
c++·编译器
拭心5 小时前
Google 提供的 Android 端上大模型组件:MediaPipe LLM 介绍
android
冷眼看人间恩怨5 小时前
【Qt笔记】QDockWidget控件详解
c++·笔记·qt·qdockwidget
红龙创客6 小时前
某狐畅游24校招-C++开发岗笔试(单选题)
开发语言·c++
Lenyiin6 小时前
第146场双周赛:统计符合条件长度为3的子数组数目、统计异或值为给定值的路径数目、判断网格图能否被切割成块、唯一中间众数子序列 Ⅰ
c++·算法·leetcode·周赛·lenyiin
yuanbenshidiaos7 小时前
c++---------数据类型
java·jvm·c++
带电的小王7 小时前
WhisperKit: Android 端测试 Whisper -- Android手机(Qualcomm GPU)部署音频大模型
android·智能手机·whisper·qualcomm
梦想平凡8 小时前
PHP 微信棋牌开发全解析:高级教程
android·数据库·oracle
十年一梦实验室8 小时前
【C++】sophus : sim_details.hpp 实现了矩阵函数 W、其导数,以及其逆 (十七)
开发语言·c++·线性代数·矩阵
taoyong0018 小时前
代码随想录算法训练营第十一天-239.滑动窗口最大值
c++·算法