1、什么是JNI?
JNI 全称是 Java Native Interface,即java本地接口,换句话说,就是连接JAVA代码和本地代码的桥梁(本地代码是指的使用C或C++编写的代码),本地代码一般是更底层的逻辑,比如操作系统,硬件驱动之类的代码,这种底层代码一般都是C或C++编写的,所以JAVA程序要想实现更高的性能或访问底层资源就绕不开JNI这个桥梁,如下图:
前期准备
需要在Android Studio中配置好NDK和CMAKE工具,如下图:
NDK指Native Development Kit(原生开发工具包),是Android平台的一个开发工具集,用于在Android应用中使用C和C++代码。它包含了API、交叉编译器、链接程序、调试器、构建工具。
CMake是一个跨平台的构建工具。
2、JNI的注册方式
以c++为例,前面说了,JAVA程序中想要调用C++里面的函数时,或者C++函数调用JAVA里面的函数时,都需要使用JNI,而JNI的注册分为静态和动态两种(注册:简单理解就是把java函数和C++函数建立映射关系),先讲比较好理解的静态注册,我们直接从一个简单的例子开始讲:
求两数之和,java层传入两数,c++层计算后返回给java层
2.1、静态注册
2.1.1 编写java层函数
java层先构造一个本地函数,本地函数以native关键字修饰,如下:
public class MyJniTest {
public static native int addInt(int num1, int num2);
}
2.1.2 编写jni层的函数
我们在src\main目录下创建一个jni目录,然后在里面编写JNI方法
下面是JNI方法的标准格式:
JNIEXPORT 返回值 JNICALL Java_包名_类名_函数名(参数列表)
我们先创建jni的头文件,也就是.h文件,假设叫static_register_JNI.h
无需手动敲代码,可以直接对着java层的本地函数按alt+enter,选create JNI fuction,自动创建jni方法,或者使用javah命令生成(参考后面ndkbuild中的做法),生成的头文件如下:
#include <jni.h>
//
// Created by ZhouPeng on 2024/4/15.
//
#ifndef JNI_APPLICATION_STATICJNIREGISTER_H
#define JNI_APPLICATION_STATICJNIREGISTER_H
extern "C" {
JNIEXPORT jint
JNICALL
Java_com_example_jniapplication_MyJniTest_addInt(JNIEnv *env, jclass clazz, jint num1, jint num2);
}
#endif //JNI_APPLICATION_STATICJNIREGISTER_H
上面的JNIEnv:即 Java Native Interface Environment,Java 本地编程接口环境。JNIEnv 内部定义了很多函数用于简化我们的 JNI 编程。JNI 把 Java 中的所有对象或者对象数组当作一个 C 指针传递到本地方法中,这个指针指向 JVM 中的内部数据结构(对象用jobject来表示,而对象数组用jobjectArray或者具体是基本类型数组),而内部的数据结构在内存中的存储方式是不可见的。只能从 JNIEnv 指针指向的函数表中选择合适的 JNI 函数来操作JVM 中的数据结构。
jclass:我的理解是对应Java中的Class
jint: JNI的数据类型,对应Java中的int类型
extern "c":表示可以使用C和C++进行混合开发
我们在头文件里面定义了jni函数,现在我们需要去实现这个函数,需要创建对应的源码文件static_register_JNI.cc
#include "static_register_JNI.h"
#include "add.h"
//
// Created by ZhouPeng on 2024/4/15.
//
extern "C" {
JNIEXPORT jint
JNICALL
Java_com_example_jniapplication_MyJniTest_addInt(JNIEnv *env, jclass clazz, jint num1, jint num2) {
// TODO: implement addInt()
int temp1 = num1;
int temp2 = num2;
int result = add(temp1,temp2);
return result;
}
}
我们看到这个函数其实很简单,我们调用了一个add函数完成求和,其中jnit这个数据类型是jni层才有的数据类型,对应java和c++中的int型,数据类型这块后续会讲。
2.1.3 编写c++层函数
然后我们看下add这个c++方法的实现,也是先创建头文件add.h,内容很简单
using namespace std;
int add(int a,int b);//添加函数声明
然后创建源码文件add.cc,内容也很简单
#include "add.h"
using namespace std;
int add(int a,int b) {
return a+b;
}
到此,我们代码都写好,下面需要选择编译方式了,有两种编译方式,ndkbuild和cmake,后面会讲这两种的编译方法;
2.2、动态注册
动态注册步骤就简洁多了,直接开写jni的源码文件dynamic_register_JNI.cc,代码中的注释已经写的很详细了,数据类型和函数签名会在后面讲到,函数如下:
#include "add.h"
#include <jni.h>
//jni对应的函数
jint addNumber(JNIEnv *env,jclass clazz,jint a,jint b){
int temp1 = a;
int temp2 = b;
//调用对应的c++函数
int result = add(temp1,temp2);
return result;
}
//三个参数,java层的函数名, java层函数的签名, C层函数指针
//获取签名方法: javap -s -p DynamicRegister.class
//建立java函数与jni函数的映射关系
static const JNINativeMethod methods[]={
{"addInt","(II)I",(void*)addNumber},
};
//java层函数load时,便会自动调用该函数
JNIEXPORT jint JNICALL JNI_OnLoad(JavaVM *vm, void *reserved){
//获得 JniEnv
JNIEnv *jniEnv{nullptr};
if (vm->GetEnv((void **) &jniEnv, JNI_VERSION_1_6) != JNI_OK) {
return -1;
}
//FindClass,反射,通过类的名字反射
jclass mainActivityCls = jniEnv->FindClass("com/example/jniapplication/MyJniTest");
//注册方法,
jint ret=jniEnv->RegisterNatives(mainActivityCls,methods,sizeof(methods)/sizeof(methods[0]));
//注册 如果小于0则注册失败
if (ret != 0) {
return -1;
}
return JNI_VERSION_1_6;
}
methods[]:这个数组就是建立java函数与jni函数的映射关系的,有了映射关系才知道调用谁。
JNI_OnLoad:这个函数会在java函数加载时自动调用,通过类名反射拿到java对象。
动态注册的模版是固定的,分为三部分:jni函数的实现,函数映射关系数组,JNI_OnLoad函数实现
3、编译方式
编译方式有ndkbuild和cmake,推荐使用cmake,更方便简洁
3.1 ndkbuild编译
我们以静态注册为例,使用ndkbuild编译的流程如下:
3.1.1 在写好所有源码的基础上,make-project一下项目或者使用javac命令生成.class文件,如下图所示:
如果代码是在app模块下的话,生产的.class文件在下面这个路径下
app\build\intermediates\javac\debug\classes
如果是在其他模块,则是在 其他模块\build\classes\java\main目录下
3.1.2 我们在anroid studio的terminal窗口,使用javah命令生成jni的.h头文件(这里其实和前面使用快捷键生成头文件类似,都是一种自动生成的方法,你也可以不用这种方法,手动写也行)
1、先 cd app/src/main
2、然后使用javah命令,如下
javah -d jni -classpath ..\..\build\intermediates\javac\debug\classes com.example.jniapplication.MyJniTest
上面这段命令中,
-d jni 表示在当前目录下创建一个 jni 文件夹
-classpath 表示后面的参数是class文件的路径,路径和完整类名之间有个空格
该命令会在jni目录下生成一个头文件com_example_jniapplication_MyJniTest.h
3、然后我们复制一下头文件里面的jni方法,创建一个源文件去实现它,就是前面的
static_register_JNI.cc这个文件
4、配置Application.mk文件,在jni目录创建该文件,内容如下:
#//生成哪些架构的so动态库,all表示所有的,包括armeabi-v7a、arm64-v8a、x86、x86_64,多个之间空格隔开
APP_ABI := all
#针对android sdk28生成动态库,与项目保持一致即可
APP_PLATFORM := android-28
5、配置Android.mk文件,在jni目录创建该文件,内容如下:
#每个Android.mk文件必须以定义LOCAL_PATH为开始,它用于在开发tree中查找源文件;
#宏my-dir则由Build System提供,返回包含Android.mk的目录路径。
LOCAL_PATH := $(call my-dir)
#CLEAR_VARS 变量由Build System提供,并指向一个指定的GNU Makefile,由它负责清理很多LOCAL_xxx。
#例如:LOCAL_MODULE, LOCAL_SRC_FILES, LOCAL_STATIC_LIBRARIES等等。但不清理LOCAL_PATH,
#这个清理动作是必须的,因为所有的编译控制文件由同一个GNU Make解析和执行,其变量是全局的,所以清理后才能避免相互影响
include $(CLEAR_VARS)
#LOCAL_MODULE模块必须定义,生成.so文件的文件名,以表示Android.mk中的每一个模块。名字必须唯一且不包含空格。
LOCAL_MODULE:=test
#C/C++所需的源文件路径,多个文件之间用空格隔开
LOCAL_SRC_FILES:=static_register_JNI.cc add.cc
#BUILD_STATIC_LIBRARY:编译为静态库
#BUILD_SHARED_LIBRARY:编译为动态库
#BUILD_EXECUTABLE:编译为Native C可执行程序
include $(BUILD_SHARED_LIBRARY)
6、执行ndkbuild命令,如果配置了环境变量就执行在terminal窗口输入ndk-build
没有配置就是使用完整路径,看sdk在哪里
我的是这样的 C:\Users\ZhouPeng\AppData\Local\Android\Sdk\ndk\20.0.5594570\ndk-build
之后会在libs目录下生成四个文件夹'armeabi-v7a', 'x86', 'arm64-v8a', 'x86_64',每个文件夹下有个.so文件
7、配置build.gradle文件,配置支持的so库架构,对应不同架构的芯片;
在android { 下设置jni依赖目录
8、将前面生成的.so文件复制到app模块的libs目录下,然后找个地方加载这个so库,注意加载时文件名不用写lib的前缀;
static {
System.loadLibrary("test");
}
9、然后java层正常调用方法即可。
3.2 cmake编译
我们以动态注册为例,使用cmake编译流程如下:
3.2.1 编译写jni动态加载的函数,就是前面的dynamic_register_JNI.cc这个源码文件,这里就不再赘述了。
3.2.2 配置CMakeLists.txt文件,在jni目录下创建该文件,内容如下:
#表示项目支持的最低cmake工具版本是3.22.1
cmake_minimum_required(VERSION 3.22.1)
# Declares and names the project.
project("JNI Application")
#设置so库的输出路径
#set(CMAKE_LIBRARY_OUTPUT_DIRECTORY ${PROJECT_SOURCE_DIR}/libs/${ANDROID_ABI})
# Creates and names a library, sets it as either STATIC
# or SHARED, and provides the relative paths to its source code.
# You can define multiple libraries, and CMake builds them for you.
# Gradle automatically packages shared libraries with your APK.
add_library( # Sets the name of the library.
#.so文件的文件名
dynamic_test
# Sets the library as a shared library.
SHARED
# Provides a relative path to your source file(s).
#c++源码文件
dynamic_register_JNI.cc add.cc)
# Searches for a specified prebuilt library and stores the path as a
# variable. Because CMake includes system libraries in the search path by
# default, you only need to specify the name of the public NDK library
# you want to add. CMake verifies that the library exists before
# completing its build.
find_library( # Sets the name of the path variable.
log-lib
# Specifies the name of the NDK library that
# you want CMake to locate.
log)
# Specifies libraries CMake should link to your target library. You
# can link multiple libraries, such as libraries you define in this
# build script, prebuilt third-party libraries, or system libraries.
target_link_libraries( # Specifies the target library.
#.so文件的文件名
dynamic_test
# Links the target library to the log library
# included in the NDK.
${log-lib})
3.2.3 配置build.gradle文件,在android { 设置cmake文件的路径和版本
externalNativeBuild {
cmake {
path file('src/main/jni/CMakeLists.txt')
version '3.22.1'
}
}
3.2.4 找个地方加载so库,然后正常调用即可,编译生成的so库在下面这个目录下,比ndkbuild好的地方是,步骤少,cmake编译无需再把.so文件复制到某个目录下再加载了,可以直接加载。
4、JNI中的数据类型和类型签名
4.1 数据类型
JNI作为java和c++的中间层,有自己的数据类型,基本类型如下:
常见的引用类型:
4.2 类型签名
在JNI动态注册时我们需要使用类型签名,什么是类型签名呢?我们知道Java中的函数可以重载,即一个函数可以有不同数量和类型的参数以及返回值,所以我们动态注册在映射时,光靠函数名称肯定是不行的,所以还需要指定参数和返回值类型的,所以类型签名其实就是用来表示这个函数的参数和返回值的。
以下面这个例子为例,第一个"addInt"是Java函数名称,后面的"(II)I",括号里面的是参数,括号外面的是返回值,I代表是int类型的数据,II代表两个int类型的数据,是不是很简单。
函数签名对应关系如下: