使用Android Studio直接创建jni接口比较简单,集成开发工具为我们做了大量工作,且默认为静态注册,下面首先简单梳理一下使用最新版本Android Studio(4.0.1)创建NDK开发工程的方法:
首先新建工程,选择C++工程;
填写工程信息后选择C++可支持的版本以及编译选项,这里暂时选择默认的toolchain;
完成后即可看到AS为我们自动生成NDK开发的c++目录,对应的CMakeLists.txt以及cpp源文件示例。接下来只需要在cpp目录下创建需要的其他c++源文件即可。在Android Studio自动配置的静态注册中,Java文件新定义任何接口都可以自动在cpp文件中生成对应的接口声明。
Android Studio自动生成的c++接口声明示例:
#include <jni.h>
#include <string>
extern "C" JNIEXPORT jstring JNICALL
Java_com_example_jnitest_jniutil_JniManager_stringFromJNI(
JNIEnv* env,
jobject /* this */) {
std::string hello = "Hello from C++";
return env->NewStringUTF(hello.c_str());
}
extern "C"
JNIEXPORT jint JNICALL
Java_com_example_jnitest_jniutil_JniManager_getIDFromJni(JNIEnv *env, jobject thiz) {
// TODO: implement getIDFromJni()
return 0;
}
由于IDE为我们完成了过多工作使我们无法看清jni在创建过程中具体做了什么,因此这里简单尝试一下手动进行静态注册jni的过程,稍微降低对IDE的依赖。
创建jni主要需要三个步骤:创建必要的源文件、添加CMakeLists.txt、配置gradle。
1) 创建相关源文件:在原有的普通Android工程中,创建需要调用Native方法的java源文件,如:
package com.example.myappdemo.nativeManager;
public class NativeManager {
public native String getName(int id); // 本地方法声明
}
然后针对这个Java文件创建cpp的头文件,可以使用jdk指令:
step1: javac NativeManage.java // 生成class文件
step2: javah -jni com.example.jnitest2.nativeManager.NativeManager // 生成jni头文件
但是实验发现以上命令无法再适用于jdk10以上了,jdk10以后javah被包含在javac中,因此以上命令可以合并为:
javac -h -jni NativeManager.java // 直接在当前目录下创建jni目录并生成头文件,根据项目不同要求,可以将路径更改为c++目录下
生成的头文件如下:
/* DO NOT EDIT THIS FILE - it is machine generated */
#include
/* Header for class com_example_myappdemo_nativeManager_NativeManager */
#ifndef _Included_com_example_myappdemo_nativeManager_NativeManager
#define _Included_com_example_myappdemo_nativeManager_NativeManager
#ifdef __cplusplus
extern "C" {
#endif
/*
* Class: com_example_myappdemo_nativeManager_NativeManager
* Method: getName
* Signature: (I)Ljava/lang/String;
*/
JNIEXPORT jstring JNICALL Java_com_example_myappdemo_nativeManager_NativeManager_getName
(JNIEnv *, jobject, jint);
#ifdef __cplusplus
}
#endif
#endif
注意到h文件方法的名字结构是比较规则的,是以Java_包名_类名_方法名的结构出现,这也是静态注册的特点之一。
然后创建同名的cpp文件(与h文件同名方便识别),如下:
#include "com_example_myappdemo_nativeManager_NativeManager.h"
JNIEXPORT jstring JNICALL Java_com_example_myappdemo_nativeManager_NativeManager_getName
(JNIEnv *env, jobject obj, jint id) {
std::string hello = "Hello from C++";
return env->NewStringUTF(hello.c_str());
}
CMakeLists.txt是用来生成makefile(或者project文件)的工具,用于编译c++文件。具体CMakeLists的细节不在这里介绍,这里主要介绍基本的CMakeLists.txt如何创建和实现。
首先创建CMakeLists.txt文件,然后列出编译基本规则,如下:
// 这里是要求的最低cmake版本,但不是指定版本,指定版本在gradle中说明
cmake_minimum_required(VERSION 3.4.1)
// 这里是指定include文件夹路径,是CMakeLists.txt的相对路径,include由开发者根据情况自行创建
include_directories(include)
// 需要添加的library,native-lib为so库的名字;SHARED表示类型为共享库;
add_library( native-lib SHARED
// 编译该库需要的源文件
com_example_myappdemo_nativeManager_NativeManager.cpp
com_example_myappdemo_nativeManager_NativeManager.h )
Android Studio是通过gradle完成代码编译的,因此CMakeLists.txt也需要配置在gradle中才能实现动态库的编译和加载。app/build.gradle中需要配置CMakeLists的位置有两处,一处为defaultConfig选项,一处为android选项中.
其中在defaultConfig中externalNativeBuild的cmake参数为编译选项,如下所示:
defaultConfig {
applicationId "com.example.myappdemo"
minSdkVersion 26
targetSdkVersion 29
versionCode 1
versionName "1.0"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
externalNativeBuild {
cmake {
cppFlags "-std=c++11" // 此处表示使用c++11标准
}
}
}
在android中的externalNativeBuild需要标时CMakeLists.txt的路径和使用的cmake版本:
externalNativeBuild {
cmake {
// 这里的路径是相对build.gradle的
path "src/main/cpp/CMakeLists.txt"
version "3.10.2"
}
}
动态注册jni不需要静态注册那样严格地要求方法名,但是需要手动将native方法和java方法关联起来,并手动注册。具体步骤如下:
public final class DynamicJniRegister {
private DynamicJniRegister() {}
private static class SingleInstHolder {
private static DynamicJniRegister sInstance = new DynamicJniRegister();
}
public static DynamicJniRegister getInstance() {
return SingleInstHolder.sInstance;
}
// 定义的native方法
public native String getGreetingsFromDynamicJni();
}
创建jni的cpp文件,可以自行命名,但方便后续开发,依然推荐取名为java类包名。源文件需要包含jni的基础功能头文件jni.h;
在cpp文件中定义native接口的实现,如:
jstring getGreetingsFromDynamicJni(JNIEnv* env, jobject obj) {
std::string hello = "Greetings from dynamic C++";
return env->NewStringUTF(hello.c_str());
}
static const JNINativeMethod jniNativeMethod[] = {
{"getGreetingsFromDynamicJni", "()Ljava/lang/String;", (jstring *)getGreetingsFromDynamicJni}
};
static const char* mClassName = "com/example/myappdemo/nativemanager/DynamicJniRegister";
jint JNI_OnLoad(JavaVM *vm, void *unused) {
JNIEnv* env = NULL;
int res = vm->GetEnv((void**)&env, JNI_VERSION_1_4);
if (res != JNI_OK) {
return -1;
}
jclass mainClass = env->FindClass(mClassName);
res = env->RegisterNatives(mainClass, jniNativeMethod, 1);
if (res != JNI_OK) {
return -1;
}
return JNI_VERSION_1_4;
}
add_library( dynamic_native-lib SHARED
jni_dynamic_register.cpp)
static {
System.loadLibrary("dynamic_native-lib");
}
8)最后,使用1.1.2中的3)相同的方法配置build.gradle。至此就完成了动态注册jni.
CMakeLists.txt是用来生成makefile并编译c++/c源文件的,可以通过简单的指令输入对源文件的编译需求。在使用Android Studio时,CMakeLists.txt是在修改后sync时执行生成makefile的,编译时自动读取externalNativeBuild中的makefile,并编译c/c++代码。
这里总结一下CMakeLists的基本编写方法。
1) 首先使用cmake_minimum_required限定cmake的最低版本,否则会产生警告:
cmake_minimum_required(VERSION 3.4.1)
2) 构建新的静态/动态库
使用add_library可以根据已有的cpp源文件编译新的静态/动态库,命令的参数为:
add_library(
dynamic_native-lib // 该参数为库的名字,如果是动态库,最终文件名为:libdynamic_native_lib.so
SHARED // 库的类型,SHARED为共享动态库,STATIC为静态库
jni_dynamic_register.cpp) // 对应的源文件,所有的源文件都可以罗列在这里,无需标点分割。
3)使用预编译库中的函数
Android中预制了一些NDK的库供开发者使用,比如log库。这类的原生库可以通过find_library将该库与变量关联,再通过target_link_libraries将变量连接在希望使用它的库中,以便于使用该库中的函数。
find_library的参数结构为:
find_library( log-lib // 希望连接到的变量名,即定义一个变量与需要使用的库对应
log) // 希望用到的NDK预制库
target_link_libraries的参数结构为:
target_link_libraries( dynamic_native-lib // 希望用到NDK预制库方法的目标库
${log-lib} // find_library定义好的变量,这里可以添加多个
4) 引入第三方so库
引入第三方库的方法与创建一个新的native库类似,区别在于最后一个参数,我们通过IMPORTED标志告知CMake只希望将库导入到项目中。
关于目标库的路径有几点需要说明:
a. CMAKE_SOURCE_DIR表示的是CMakeLists.txt所在的路径,当指定第三方so所在路径时,应当以这个常量为起点。
b, 在具体项目中可以为每种ABI提供单独的软件包,就可以在jinLibs(如果是project结构的目录则是libs)下建立多个目录,每个目录对应一种ABI接口类型,然后再通过${ANDROID_ABI}来泛化这一层目录的结构,这样有助于充分利用特定的CPU架构。第三方的库关联到原生库与NDK库关联到原生库的原理是一样的。
c. 为了确保CMake可以在编译时定位我们的头文件,需要将include_directories() 命令添加到 CMake构建脚本中并指定头文件的路径
add_library(
#指定目标导入库
imported-lib
#设置导入库的类型(静态或动态)为shared library.
SHARED
#告知 CMake imported-lib 是导入的库
IMPORTED )
set_target_properties(
#指定目标导入库
imported-lib
#指定属性(本地导入的已有库)
PROPERTIES IMPORTED_LOCATION
#指定你要导入库的路径. 比如:
${CMAKE_SOURCE_DIR}/libs/${ANDROID_ABI}/libimported-lib.so )
#为了确保 CMake 可以在编译时定位到我们的 头文件,我们需要使用include_directories()命令,
#并包含头文件的路径
include_directories(libs/include/)
#要将预构建库关联到我们的原生库,需要将其添加到CMake构建脚本的target_link_libraries()命令中
target_link_libraries(
#这里指定了三个库,分别是native-lib、imported-lib和log-lib.
native-lib
imported-lib
#log-lib是包含在NDK中的一个日志库
${log-lib} )
Jni为C/C++编程,但是具体使用风格还是会有稍许不同(比如在字符串编码以及常用数据类型等),这里我会简单总结一下jni编程中的主要关键词、主要方法以及其他需要主意的事项。
在1.1.1和1.1.2中我们看到静态注册的jni的c++和h文件中有一些特殊的描述符、宏以及数据结构,这里主要总结一下这些描述符的含义:
#ifdef __cplusplus
extern “C” {
#endif
/*
#ifdef __cplusplus
}
typedef _JNIEnv JNIEnv;
struct _JNIEnv {
/* do not rename this; it does not seem to be entirely opaque */
const struct JNINativeInterface* functions;
#if defined(__cplusplus)
jint GetVersion()
{ return functions->GetVersion(this); }
jclass DefineClass(const char *name, jobject loader, const jbyte* buf,
jsize bufLen)
{ return functions->DefineClass(this, name, loader, buf, bufLen); }
jclass FindClass(const char* name)
{ return functions->FindClass(this, name); }
jmethodID FromReflectedMethod(jobject method)
{ return functions->FromReflectedMethod(this, method); }
......
通过该源码可以看到,JNIEnv中包含JNINativeInterface结构,而后面的所有函数均是对JNINativeInterface中函数的封装,并对参数进行了修改。并且又看到了熟悉的宏:#if defined(__cplusplus)。这里可以看出C和C++对JNIEnv的处理有所不同。对于C编写的Jni程序,JNIEnv等同于JNINativeInterface,而对于C++来说,JNIEnv则对其进行了一次封装。
2. jobject
如果native方法不是static的话,jobject就代表这个native方法的类实例。
如果native方法是static的话,jobject就代表这个native方法的类的class对象实例(static方法不需要类实例的,所以就代表这个类的class对象)。
3. JavaVM
JavaVM的源码如下:
struct _JavaVM {
const struct JNIInvokeInterface* functions;
#if defined(__cplusplus)
jint DestroyJavaVM()
{ return functions->DestroyJavaVM(this); }
jint AttachCurrentThread(JNIEnv** p_env, void* thr_args)
{ return functions->AttachCurrentThread(this, p_env, thr_args); }
jint DetachCurrentThread()
{ return functions->DetachCurrentThread(this); }
jint GetEnv(void** env, jint version)
{ return functions->GetEnv(this, env, version); }
jint AttachCurrentThreadAsDaemon(JNIEnv** p_env, void* thr_args)
{ return functions->AttachCurrentThreadAsDaemon(this, p_env, thr_args); }
#endif /*__cplusplus*/
};
从源码中可以看到JavaVM的主要成员函数都是围绕虚拟机进行的,包括:销毁虚拟机、归入线程、作为守护线程归入某线程、获取环境参数等等。因此这个变量是针对整个线程的性质进行描述和控制的。
在jni动态注册的初始时,就是通过GetEnv获取虚拟机环境参数的。
4. JNINativeMethod 源码如下:
typedef struct {
const char* name;
const char* signature;
void* fnPtr;
} JNINativeMethod;
JNINativeMethod是用来联系Java方法与本地方法的数据,一般会定义成数组(因为有多个本地方法需要注册)。其中name为Java方法,signature是用来描述参数以及返回值的签名,fnPtr是C++的函数指针。
这里所讲的函数,是指jni.h中的函数,包括JNIEnv以及JavaVM中的部分成员函数。
static const char* mClassName = "com/example/jnitest2/nativeManager/DynamicRegisterJniManager";
jclass nativeManagerCls = env->FindClass(mClassName);
一般是用来做反射使用(调用某Java类的成员方法),或者在动态注册jni的初始时,需要用它来寻找关联的Java类。
在1.2 动态注册jni中,以及2.1.2的第4条JNINativeMethod结构 中可以看到函数关系表都是有
函数签名参与的,函数签名用来描述函数的参数以及返回值,当需要使用可以通过jdk直接查询签名表,如:javap -s -p StaticRegisterJniManager.class
结果参考下图:
常见的签名如下表所示:
数据类型 | 签名 |
---|---|
boolean | Z |
byte | B |
char | C |
short | S |
int | I |
long | J |
float | F |
double | D |
void | V |
object | L开头,以/分隔包的完整类型,结尾加‘;’ 比如String为:Ljava/lang/String; |
Array | 以[开头,加上数组元素类型的签名。如int[]应为[I,int[][]为[[I |
如何组织JNINativeMethod的签名格式呢?比如当我的Java方法是这样的:
String foo(int a, boolean b, Date[] c), 对应的签名应该为:
(参数1 参数2 参数3 …)返回值
因此应该为:
(I Z [Ljava/util/Date;)Ljava/lang/String;
在Java的JVM内码中,String的编码是utf16编码格式,(也就是说每个字符的大小为2~4个字节),但是在jni中一般使用utf8编码格式,而在C/C++中则普遍使用原始数据(ASCII码),1个字节,中文使用GB2312(2个字节)。因此在使用jni进行字符串传递时,一定需要进行编码的转换,否则会出现乱码情况。
编码的转换过程参考下图:
JNIEnv中有关String的函数参考下表:
函数定义 | 函数功能 |
---|---|
jstring (NewString)(JNIEnv, const jchar*, jsize) | 创建新的String |
jsize (GetStringLength)(JNIEnv, jstring) | 返回Unicode字符串的字符数。 |
const jchar* (GetStringChars)(JNIEnv, jstring, jboolean*) | 获取以Unicode格式编码的字符串 |
void (ReleaseStringChars)(JNIEnv, jstring, const jchar*) | 释放字符串的空间 |
jstring (NewStringUTF)(JNIEnv, const char*) | 创建新的jstring字符串 |
jsize (GetStringUTFLength)(JNIEnv, jstring) | 返回UTF-8字符串的字节数,不包含末尾’\0’。 |
const char* (GetStringUTFChars)(JNIEnv, jstring, jboolean*) | 把jstring指针(指向JVM内部Unicode序列)转化成UTF-8格式的C字符串 |
void (ReleaseStringUTFChars)(JNIEnv, jstring, const char*) | 释放UTF-8编码的字符串 |
值得一提的是,GetStringLength与GetStringUTFChars两个函数的表现效果是不确定的,具体由可能会将提供的参数jstring指针直接强转编码形势后返回,也可能会开辟一段空间并将内容copy过去。第三个参数jboolean*会反应本次操作是否发生了拷贝。为了保险起见,返回的字符串不应该做任何修改,且使用完成应该对应使用ReleaseStringChars和ReleaseStringUTFChars进行释放,防止内存泄漏。
Jni中每个函数都具有JNIEnv*和jobject两个参数,其中jobject在前文中介绍到,是调用者类的对象或者其class。如果当前native方法不是static时,欲访问其成员需要以下步骤(此处假设目标类的某成员属性为 int property = 0):
note:当操作私有成员时,需要设置setAccessible(true)后才可以哦!
访问成员方法一般需要按照以下流程(获得类的引用流程基本类似,因此这里我们将前提条件那个了更换,在3.1中假设调用者类就是需要反射的类,此处我们将假定反射任意一个类,例如类名为:FooClass,目标方法为:void methodName(String, int) ):