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 简单分为三个阶段:
- 注册 native 函数
- 调用 native 函数
- 向 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;
}
动态注册就不是那么直观了,我们可以再进行一个拆分。
动态注册的流程可以分为三个阶段:
- 获取 JNIEnv
- FindClass 找到 native 函数所在的 Java/Kotlin 类
- 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 的使用文档里专门写了一整节介绍每一个部分,等我学会了就来写第二篇。