1.JNI
JNI(Java Native Interface)指Java本地调用,作用是使Java与C/C++具有交互能力。JNI相当于翻译官,使两种语言能够互相理解相互调用。
NDK(Native Development kit)本地开发工具包,允许使用原生语言(C/C++)来实现应用程序的部分功能。
通过JNI技术可以实现Java调用C程序、C程序调用Java代码。
JNI的使用步骤:
①java声明native函数
②jni实现对应的c函数
③编译生成so库
④java加载so库,并调用native函数
为什么要使用JNI技术呢?因为当需要进行大量数据计算时,原生代码的计算速度远远快于Java:
2.JNI函数注册
开发中使用JNI,一般是定义Java native方法,并写好对应的C方法实现。那么在Java代码中调用Java native方法时,虚拟机是怎么知道并调用so库里对应的C方法的呢?其实Java native方法与C方法的对应关系是通过注册实现的。
JNI函数分为静态注册和动态注册两种方式。
①静态注册
静态注册就是通过固定的命名规则映射Java和Native函数。即C中按照JNI规范书写函数名:Java_包名_类名_方法名。
通过javac和javah编译出头文件,然后实现对应的cpp文件,这就是静态注册的方式。这种调用方式是由于JVM按照默认的映射规则来匹配对应的native函数,如果没匹配就会报错。
举例:
//Java层代码JniSdk.java
public class JniSdk {
static {
System.loadLibrary("test_jni");
}
public native String showJniMessage();
}
//Native层代码test_jni.cpp
extern "C" JNIEXPORT jstring JNICALL Java_com_example_dragon_androidstudy_jnidemo_JniSdk_showJniMessage (JNIEnv* env, jobject job) {
return env->NewStringUTF("hello world");
}
在这里,java层的showJniMessage函数对应的就是c语言那边的Java_com_example_jnidemo_JniSdk_showJniMessage函数。这个映射是JVM实现的。
在调用showJniMessage函数时JVM会从JNI库寻找对应的函数并调用。寻找时的规则:由于JniSdk.java的包名是com.example.jnidemo,那么showJniMessage方法完整的路径就是com.example.jnidemo.JniSdk.showJniMessage。而.在C里面有特殊的函数,所以JVM就将它替换成了_,并在前面加了Java_标识,就变成了上面的方法。
静态注册的优缺点:
系统默认方式,使用简单;
灵活性差(如果修改了java native函数所在类的包名或类名,需手动修改C函数名称;
②动态注册
使用静态注册方式时,每次使用JNI都要先声明native,然后编译成class,再生成头文件,最后再按照特定的规则去实现native函数。这一整套流程下来不仅繁琐,很多步骤还是没必要的(没必要生成.h文件,只要在.c文件里面根据对应的规则声明函数即可)。而且JVM在根据静态注册匹配的规则调用函数时效率也比较低。因此有了动态注册的方法。
动态注册就是不用默认的映射规则,直接由开发者告诉JVM java中的native函数对应的是C文件里的哪个函数。
动态注册一般是通过重写JNI_OnLoad函数,用jint RegisterNative(j class clazz, const JNINativeMethod* methods, jint nMethods)函数将Java中定义的native函数和C/C++中定义的函数进行映射。即在JNI_OnLoad中指定Java Native函数与C实现函数的对应关系。
所以动态注册在调用Native方法之前就已经知道它在JNI中对应的函数指针,这需要用到一个结构体来描述两者之间的关系:
typedef struct {
const char* name; //对应java中native的函数名
const char* signature; //java中native函数的函数签名
void* fnPtr; //JNI中对应的函数指针
} JNINativeMethod;
举例:
//Java层定义Native函数
public class JniSdk {
static {
System.loadLibrary("test_jni");
}
public static native int numAdd(int a, int b);
public native void dumpMessage();
}
//.cpp中动态注册
/*定义函数映射关系。需要注册的函数列表放在JNINativeMethod类型的数组中。参数:1.java中用native关键字声明的函数名;2.签名(传进来参数类型和返回值类型的说明) ;3.C/C++中对应函数的函数名(地址)*/
static JNINativeMethod g_methods[] = {
{"numAdd", "(II)I", (void*)add},
{"dumpMessage","()V",(void*)dump},
};
jint JNI_OnLoad(JavaVM *vm, void *reserved) {
j_vm = vm;
JNIEnv *env = NULL;
//获取JNIEnv
if (vm->GetEnv((void**)&env, JNI_VERSION_1_2) != JNI_OK) {
LOGI("on jni load , get env failed");
return JNI_VERSION_1_2;
}
//指定类的路径(把.换成/),通过FindClass方法来找到对应的类
jclass clazz = env->FindClass("com/example/jnidemo/JniSdk");
//注册函数,参数分别为java类、所要注册的函数数组、注册函数的个数(也可以用sizeof(g_methods)/sizeof(g_methods[0])
jint ret = env->RegisterNatives(clazz, g_methods, 2);
if (ret != 0) {
LOGI("register native methods failed");
}
return JNI_VERSION_1_2;
}
在调用System.loadlibrary函数时,JVM会回调上面的JNI_OnLoad函数,就是在这个函数里通过env->RegisterNatives进行的动态注册,其中env是jni函数实现的核心。
动态注册原理图:
动态注册的优缺点:
函数名看着舒服一些,但是需要在C代码中维护Java Native函数与C函数的对应关系;
灵活性稍高(如果修改了java native函数所在类的包名或类名,仅调整Java native函数的签名信息)
4.函数签名
函数签名可以理解成一个函数的唯一标识,一个签名对应着一个函数的签名,它们是一一对应的关系。
函数的签名是针对函数的参数以及返回值组成的。它遵循如下格式(参数类型1;参数类型2;参数类型3.....)返回值类型。例如上面的numAdd函数一样,它在java层的函数声明是:
int numAdd(int a, int b)
这里面有两个参数都是int,并且返回值也是int。所以的函数签名是(II)I。而dumpMessage函数没有任何参数,并且返回值也是空,所以它的签名是()V。
注:如果自己手动写这种签名的话很容易出错,有一个工具可以很方便的列出每个函数的签名。可以先通过javac命令编译出class文件。然后再通过javap -s -p xxx.class命令列出这个class文件所有的函数签名。
5.JNIEnv
java函数和jni函数一旦建立映射关系后,在java层调用native函数就变得很简单了。但是一般程序并不仅仅是这么简单的需求,大多数的时候还需要jni函数调用java层的函数,比如进行后台文件操作后将结果通知到上层,这个时候就需要jni调用java的函数了。这时就要JNIEnv出场了,JNIEnv可以说是贯穿了整个JNI技术的核心。
JNIEnv代表了Java环境,通过JNIEnv*就可以对Java端的代码进行操作,如:创建Java对象NewObject或NewString、调用Java对象的方法CallMethod、获取Java对象的属性GetField等。
通过JNIEnv *env调用Java层代码,如获得某个字段、获取某个函数、执行某个函数等:
//获得某类中定义的字段id
jfieldID GetFieldID(jclass clazz, const char* name, const char* sig) {
return functions->GetFieldID(this, clazz, name, sig);
}
//获得某类中定义的函数id
jmethodID GetMethodID(jclass clazz, const char* name, const char* sig) {
return functions->GetMethodID(this, clazz, name, sig);
}
这里与Java的反射比较类似,参数:clazz表示类的class对象;name表示字段名、函数名;sig:如果是字段表示字段类型的描述符,如果是函数表示函数结构的描述符。
举例:
//Java代码
public class Hello{
public int property;
public int fun(int param, int[] arr){
return 100;
}
}
//JNI C/C++代码
JNIEXPORT void Java_Hello_test(JNIEnv* env, jobject obj){
jclass myClazz = env->GetObjectClass(obj);
jfieldId fieldId_prop = env -> GetFieldId(myClazz, "property", "I");
jmethodId methodId_fun = env -> GetMethodId(myClazz, "fun", "(I[I)I");
}
Java语言的执行环境是Java虚拟机JVM,JVM其实是主机环境中的一个进程,每个JVM虚拟机都在本地环境中有一个JavaVM结构体,该结构体在创建JVM虚拟机时被返回。JNI全局仅仅有一个,JavaVM是Java虚拟机在JNI层的代表,一个JVM对应一个JavaVM结构。一个JVM中可能创建多个Java线程,每个线程对应一个JNIEnv结构,它们保存在线程本地存储TLS中。JNIEnv是一个线程相关的函数表结构体,该结构体代表了Java在本线程的执行环境。不同线程的JNIEnv是不同,也不能相互共享使用。在本地代码中通过JNIEnv的函数表来操作Java数据或调用Java方法。
也就是说,JNIEnv是JVM内部维护的一个和线程相关的代表JNI环境的结构体。这个结构体和线程相关,并且C函数里面的线程与java函数中的线程是一一对应关系,也就是说如果在java里的某个线程调用jni接口,不管调用多少个JNI接口,传递的JNIEnv都是同一个对象。因为这个时候java只有一个线程,对应的JNI也只有一个线程,而JNIEnv是跟线程绑定的,因此也只有一个。
6.JNI函数
常见的JNI方法:
#include <jni.h>
#include <string>
extern "C" JNIEXPORT jstring JNICALL
Java_com_test_testnativec_MainActivity_stringFromJNI(JNIEnv* env, jobject /* this */) {
std::string hello = "Hello from C++";
return env->NewStringUTF(hello.c_str());
}
JNI方法结构与Java方法类似,同样包含方法名、参数、返回类型,只不过多了一些修饰词、特定参数类型而已。
①extern "C"
作用:避免编绎器按照C++的方式去编绎C函数。
如果删掉extern “C”,重新生成so,运行app,结果会直接闪退:UnsatisfiedLinkError: No implementation found for java.lang.String com.test.testnativec.MainActivity.stringFromJNI()
通过反编译so文件会发现,去掉extern “C” 后函数名字竟然被修改了:
//保留extern "C"
000000000000ea98 T
Java_com_test_testnativec_MainActivity_stringFromJNI
//去掉extern "C"
000000000000eab8 T
_Z40Java_com_test_testnativec_MainActivity_stringFromJNIP7_JNIEnvP8_jobject
所以,如果希望编译后的函数名不变,应通知编译器使用C的编译方式编译该函数(即:加上关键字:extern “C”)。
②JNIEXPORT、JNICALL
JNIEXPORT用来表示该函数是否可导出(即:方法的可见性);JNICALL用来表示函数的调用规范(如:__stdcall)。
可以通过点击JNIEXPORT、JNICALL关键字跳转到jni.h中的定义,如下图:
通过查看jni.h中的源码,原来JNIEXPORT、JNICALL是两个宏定义。
宏可以这样理解:宏JNIEXPORT代表的就是右侧的表达式:__attribute__ ((visibility ("default")));或者也可以说:JNIEXPORT是右侧表达式的别名。宏可表达的内容很多,如:一个具体的数值、一个规则、一段逻辑代码等。
attribute___((visibility ("default"))) 描述的是“可见性”属性visibility。default表示外部可见,类似于public修饰符 (即:可以被外部调用);hidden表示隐藏,类似于private修饰符 (即:只能被内部调用)。
如果想使用hidden隐藏方法,可这么写:
#include <jni.h>
#include <string>
extern "C" __attribute__ ((visibility ("hidden"))) jstring JNICALL
Java_com_test_testnativec_MainActivity_stringFromJNI(JNIEnv* env, jobject /* this */) {
std::string hello = "Hello from C++";
return env->NewStringUTF(hello.c_str());
}
重新编译、运行,结果闪退了。原因是函数Java_com_test_testnativec_MainActivity_stringFromJNI已被隐藏,而在java中调用该函数时,找不到该函数,所以抛出了异常。
宏JNICALL右边是空的,说明只是个空定义。宏JNICALL代表的是右边定义的内容,那么java代码也可直接使用右边的内容(空)替换调JNICALL(即去掉JNICALL关键字),编译后运行,调用so仍然是正确的。
7.demo测试
①搭建环境
启动Android Studio --> 打开SDK Manager --> SDK Tools:
选择NDK、CMake、LLDB(调试Native时会使用),选择Apply进行安装,等安装成功后,NDK开发所依赖的环境也就齐全了。
②Native C++项目创建(HelloWord案例)
新建项目,选择Native C++:
新创建的项目,默认已包含完整的native示例代码、cmake配置:
这样就可以自己定义Java native方法,并在cpp目录中写native实现了,很方便。
可以直接使用工程默认生成的native-lib.cpp,简单调整一下native实现方法的代码:
因Native C++工程默认已配置好了CMakeLists.txt和gradle,所以可直接运行工程看效果。
JNI交互效果已经看到了,说明CMake编译成功了。
③现在分析一下CMake生成的库文件与apk中的库文件
Android工程编译时,会执行CMake编译,在 工程/app/build/.../cmake/ 中会产生对应的so文件:
继续编译安卓工程,会根据build中的内容生成*.apk安装包文件。反编译apk安装包文件,查找so库文件。原来在apk安装包中,so库都被存放在lib目录中:
那CMake是如何编译生成so库的呢?其实CMake是基于CMakeLists.txt文件和gradle配置实现编译Native类的。
看一下CMakeLists.txt文件:
#cmake最低版本要求
cmake_minimum_required(VERSION 3.4.1)
# 配置库生成路径。CMAKE_CURRENT_SOURCE_DIR指cmake库的源路径,通常是build/.../cmake/;/../jniLibs/指与CMakeList.txt所在目录的同级目录:jniLibs (如果没有会新建);ANDROID_ABI指生成库文件时,采用gradle配置的ABI策略(即:生成哪些平台对应的库文件)
set(CMAKE_LIBRARY_OUTPUT_DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR}/../jniLibs/${ANDROID_ABI})
#添加库
add_library(
# 库名
native-lib
# 类型:SHARED 是指动态库,对应的是.so文件,STATIC 是指静态库,对应的是.a文件
SHARED
# native类路径
native-lib.cpp)
# 查找依赖库
find_library(
# 依赖库别名
log-lib
# 希望加到本地的NDK库名称,log指NDK的日志库
log)
# 链接库,建立关系( 此处就是指把log-lib 链接给 native-lib使用 )
target_link_libraries(
# 目标库名称(native-lib 是要生成的so库)
native-lib
# 要链接的库(log-lib 是上面查找的log库)
${log-lib})
再看一下app的gradle又是如何配置CMake的呢?
apply plugin: 'com.android.application'
android {
compileSdkVersion 29
buildToolsVersion "29.0.1"
defaultConfig {
applicationId "com.qxc.testnativec"
minSdkVersion 21
targetSdkVersion 29
versionCode 1
versionName "1.0"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
//定义cmake默认配置属性
externalNativeBuild {
cmake {
cppFlags ""
}
}
}
//定义cmake对应的CMakeList.txt路径(重要)
externalNativeBuild {
cmake {
path "src/main/cpp/CMakeLists.txt"
}
}
sourceSets {
main {
jniLibs.srcDirs = ['jniLibs']//指定lib库目录
}
}
}
接着,重新build就会在cpp相同目录级别位置生成jniLibs目录,so库也在其中了: