众所周知,在 Android 平台上开发移动端原生应用,通常会使用 Java 或 Kotlin 这两种主要的编程语言。然而,假设我提供了一个C++的SDK库,要求你开发一个Android应用,或是你突然发现一个C++的库很优秀,想要在Java项目当中调用,这个时候该怎么办?
这里我们就要用到Java提供的JNI(Java Native Interface)来集成C或 C++ 代码。
JNI 提供了一种机制,使得 Java 代码能够调用本地(C/C++)代码。这样,你可以使用 C++ 编写处理那些需要用到C++的高性能支持或三方库支持的部分,而在应用的其他部分使用 Java 或 Kotlin 编写。这种混合语言开发的方式在需要兼顾性能和开发效率的场景下是比较常见的。
我们今天来实现一个简单的Java调用C++代码输出Hello World的操作。
代码仓库地址如下:GitHub-JNIHelloWorld
首先,我们新建一个Java空项目,然后在src/main目录下新建cpp目录。
接下来我们在java目录下新建一个java类文件,就叫HelloWorld.java好了。
代码如下:
public class HelloWorld {
static {
System.load(System.getProperty("user.dir") + "/src/main/cpp/libHelloWorld.dll");
}
/**
* 在C++当中输出HelloWorld
*/
public native void printHelloWorld();
/**
* 通过Java传参给C++,在C++当中输出
* @param str Java传入的字符串
*/
public native void say(String str);
public static void main(String[] args) {
HelloWorld helloWorld = new HelloWorld();
helloWorld.printHelloWorld();
helloWorld.say("Hello World from Java!");
}
}
提前提个醒,如果是Linux系统,这里要把libHelloWorld.dll改为libHelloWorld.so。
首先声明一个static块,在类加载的时候执行一次代码,使用JNI提供的System.load()方法读取目标目录下的cpp动态链接库文件。
System.load()本身传参只能传入绝对路径,但在Java当中,user.dir指代当前项目目录,即我设置的E:\project\JNIHelloWorld,这里使用System.getProperty("user.dir")并用字符串连接的方式可以巧妙地让绝对路径间接变成相对路径。
这里的libHelloWorld.dll还没生成,后面讲C++的部分我们再说。
我们还用到了一个大家不是很常用的native关键字,这个关键字在Java中用于声明一个本地方法。本地方法是在Java外部的语言(如C或C++)中实现的方法,然后通过Java的本地方法接口(JNI)在Java中调用。这在需要使用系统级资源,或者已经存在的大量非Java代码库时非常有用。
换句话说,接下来我们要在C++当中实现printHelloWorld和say两个方法,并在Java当中调用这两个方法。一个是从C++里面直接输出Hello World,一个是从Java传Hello World字符串给C++,再到C++当中输出。
接下来我们要生成C++头文件。
在当前目录中打开命令行,使用如下指令用UTF-8编码自动创建头文件到cpp目录:
javac -encoding UTF-8 -h .\src\main\cpp .\src\main\java\HelloWorld.java
此时,在java目录下我们的java文件被编译成class文件了,而cpp目录下也生成了一个头文件。
我们来看看这个头文件里面是怎么写的:
/* DO NOT EDIT THIS FILE - it is machine generated */
#include <jni.h>
/* Header for class HelloWorld */
#ifndef _Included_HelloWorld
#define _Included_HelloWorld
#ifdef __cplusplus
extern "C" {
#endif
/*
* Class: HelloWorld
* Method: printHelloWorld
* Signature: ()V
*/
JNIEXPORT void JNICALL Java_HelloWorld_printHelloWorld
(JNIEnv *, jobject);
/*
* Class: HelloWorld
* Method: say
* Signature: (Ljava/lang/String;)V
*/
JNIEXPORT void JNICALL Java_HelloWorld_say
(JNIEnv *, jobject, jstring);
#ifdef __cplusplus
}
#endif
#endif
重点关注中间两个函数声明。
我们先讲讲JNIEXPORT和JNICALL。
JNIEXPORT和JNICALL是Java Native Interface(JNI)规范中定义的宏,用于确保Java能够正确地调用C或C++中的本地方法。
- JNIEXPORT是一个指示编译器的宏,它确保了本地方法的符号在生成的动态链接库(DLL)中是可见的,这样Java就能找到并调用它。
- JNICALL是另一个宏,它定义了函数调用的约定。这是平台特定的,例如,在Windows上,它被定义为__stdcall。
然后再来看看函数名称,比如这里的Java_HelloWorld_printHelloWorld,我们可以发现,他和我们定义的方法名差别是多了“Java_HelloWorld_”这一串文字。这里的格式是固定的,即Java_Java类名_函数名,记住这个格式即可。
此外,我们发现,如果是C++的语言环境下,整套代码被用extern "C"这个C语言块包了起来。
这是因为Java的JNI技术期望的是C语言的链接规则。假设我们在代码中使用了C++的一些特性,比如函数重载,JNI则会进行报错。因此我们直接提供给JNI的不可以是有C++函数重载等特性的函数,必须自行编写函数,在内部把那些三方SDK提供的函数进行独立处理并封装成自己的一套方法后再提供给Java使用。由此可见,extern "C"自然而然成为了一种典型的处理方式。
大伙可能还会注意到,我们在say这个方法当中传入的参数类型是Java类型字符串jstring,而不是C++类型的string或字符串数组。
现在我们来编写cpp代码。在cpp目录下创建HelloWorld.cpp,根据头文件的模板编写代码即可。
代码如下:
#include <jni.h>
#include <iostream>
#include "HelloWorld.h"
JNIEXPORT void JNICALL Java_HelloWorld_printHelloWorld(JNIEnv* env, jobject) {
std::cout << "Hello World from c++!" << std::endl;
}
JNIEXPORT void JNICALL Java_HelloWorld_say(JNIEnv* env, jobject, jstring str) {
const char *nativeString = env->GetStringUTFChars(str, 0);
std::cout << nativeString << std::endl;
env->ReleaseStringUTFChars(str, nativeString);
}
这里没用到函数重载,就不需要用extern "C"块包装再重新封装起来了。
值得一提的是,在JNIEnv当中提供了GetStringUTFChars方法,用于将我们这里传入的jstring转换成C风格的字符串。因此我们可以创建一个char类型的指针,指向这个函数传出的C风格字符串,并输出出来,最后再用ReleaseStringUTFChars方法释放资源。
编写好cpp文件后,我们在项目目录下打开命令行,使用这个指令生成dll动态链接库文件:
g++ -shared -o .\src\main\cpp\libHelloWorld.dll .\src\main\cpp\HelloWorld.cpp -I"%JAVA_HOME%\include" -I"%JAVA_HOME%\include\win32"
简单讲讲这个指令每一个部分有什么用。
- 这里的%JAVA_HOME%指代的是你系统里设置的JAVA目录环境变量。
- 通过-shared告诉编译器生成一个共享库文件。
- 通过-o将文件输出到对应目录。
- 然后我们指定了C++文件的位置。
- -I选项用于指定编译器在查找头文件时应该搜索的目录。在这里,这两个目录是Java JDK中的JNI头文件的位置。
但要注意,上面的是cmd用的指令,如果你用的是powershell,记得把%JAVA_HOME%改为$env:JAVA_HOME,如果用的是bash,则改为$JAVA_HOME。如果是Linux的话这条指令得大改,变成这样:
g++ -shared -o ./src/main/cpp/libHelloWorld.so ./src/main/cpp/HelloWorld.cpp -I"$JAVA_HOME/include" -I"$JAVA_HOME/include/linux"
此外,由于Linux系统中生成的是.so(共享对象)文件,而不是.dll文件,我们也要在java代码中把System.load得到的文件后缀名改为.so。
现在我们所有的工作都已经完成了,来执行一下Java代码试试看。
这就是Java执行C++代码的最简单的方式。我们在之后的开发过程中可能还会用到例如javacpp这样的三方库,帮助我们更加轻松地调用C++代码,但其本质仍然是通过JNI实现这种特殊的I/O操作。
简单总结一下Java项目调用C++代码的方式,无非就是:
- 针对已有的C++代码编写C语言层代码,完成需要在C/C++部分需要做的操作并封装
- 生成动态链接库dll或so文件
- Java通过JNI读取动态链接库文件,调用自己写好的C语言层的代码
可想而知,这样做只是操作相对麻烦一点,再加上跨平台能力确实十分有限,因此大家更喜欢在移动端做前端套壳项目。但前端套壳项目在性能这方面还是太过差劲了,在面向高性能的业务场景下,我们更需要的是这样的项目,而非前端套壳,因此多掌握一门技能实际上也挺不错。