JNI

JNI & NDK

JNI(Java Native Interface),提供若干的API实现Java和其他语言的通信(主要是C&C++)。

NDK(Native Development Kit), 是Google提供的一套方便开发者使用JNI机制的工具。

为什么使用JNI

安全性。由于 apk 的 java 层代码很容易被反编译,而 C/C++ 库反汇难度较大。

可以方便地使用现存的开源库。大部分现存的开源库都是用 C/C++ 代码编写的。

提高程序的执行效率。将要求高性能的应用逻辑使用 C 开发,从而提高应用程序的执行效率。

便于移植。用 C/C++ 写的库可以方便在其他的嵌入式平台上再次使用。

示例(hellojni)

我们先看一下使用JNI的基本流程。

1、 创建java类JniTest

package com.nd.jnidemo;

public class JniTest {

static {

System.loadLibrary("hellojni");

}

public static native String getStringFromNative();

}

2、 build工程,生成JniTest.class文件(在app\build\intermediates\classes\debug目录下),使用javah -jni com.nd.jnidemo.JniTest生成头文件com_nd_jnidemo_JniTest.h


JNI_第1张图片
com_nd_jnidemo_JniTest.h

3、 创建com_nd_jnidemo_JniTest.c,和头文件一起放在src/main/jni目录下

#include "com_nd_jnidemo_JniTest.h"

JNIEXPORT jstring JNICALL Java_com_nd_jnidemo_JniTest_getStringFromNative

(JNIEnv * env, jclass jcls){

return (*env)->NewStringUTF(env, "hello jni");

}

4、 配置NDK路径


JNI_第2张图片
配置NDK

5、 在app\build.gradle,defaultConfig中配置ndk

ndk{

moduleName "hellojni"

abiFilters "armeabi", "armeabi-v7a", "x86"

}

6、 build工程,在app\build\intermediates\ndk目录下会生成libhellojni.so,如果碰到插件需要升级相关的错误提示,在gradle.properties加入这句话:

android.useDeprecatedNdk = true

7、 使用

TextView tvJni = (TextView) findViewById(R.id.tvJni);

tvJni.setText(JniTest.getStringFromNative());

JNI详解

我们从上述例子出发,详细讲解JNI相关知识。

加载库

System.loadLibrary("库名称");

系统会自动根据不同的平台拓展成真实的动态库文件名,例如在Linux系统上会拓展成libhellojni.so,而在Windows平台上则会拓展成hellojni.dll。

函数注册

一个问题:java层native函数是如何与JNI层的函数关联起来?


JNI_第3张图片
函数注册

静态注册

hellojni中便是使用了静态注册方法,它是通过名称查找的方式来建立关联,因此要遵守它的命名规范:函数名以"Java _ 包名 _ 类名 _ 方法名"(Java_com_nd_jnidemo_JniTest_getStringFromNative) 命名。

当Java层调用getStringFromNative函数时,它会从库中查找Java_com_nd_jnidemo_JniTest_getStringFromNative,如果没有,就会报错。如果找到,则会为它们建立一个关联关系,其实就是保存JNI层函数的函数指针。以后再调用getStringFromNative函数时,直接使用这个函数指针就可以,这项工作由虚拟机完成。

这种方法缺点:

函数命名被限制,名称过长。

初次调用native函数时要根据函数名字搜索对应的JNI层函数来建立关联关系,这样会影响运行效率。

动态注册

解决上述问题便是使用:动态注册。

这种方法显示地为Java层和Jni层函数建立关联关系,因此只需考虑两个问题:

1、 何时注册

当Java层通过System.loadLibrary加载完JNI动态库后,紧接着会查找该库中一个叫JNI_OnLoad的函数,如果有,就调用它,而动态注册的工作便可以在这里完成。

2、 如何注册

Jni中,有一个记录关联关系的结构体----JNINativeMethod

typedef struct {

//Java中native函数的名字,不用携带包的路径。例如“getStringFromNative“。

constchar* name;

//Java函数的签名信息,用字符串表示,是参数类型和返回值类型的组合。

const char* signature;

void*  fnPtr;  //JNI层对应函数的函数指针,注意它是void*类型。

} JNINativeMethod;

因此先定义函数的对应关系

static JNINativeMethod gMethods[] = {

{"dynamicRegister", "()Ljava/lang/String;", (void *) dynamicRegister}

};

然后调用方法进行注册:

(*env) -> RegisterNatives(env, clazz, gMethods, numMethods);

函数签名

Java支持函数重载,也就是说,可以定义同名但不同参数的函数。但仅仅根据函数名,是没法找到具体函数的。为了解决这个问题,使用了参数类型和返回值类型的组合作为一个函数的签名信息,有了签名信息和函数名,就能准确找到具体函数。

格式:

(参数1类型标示参数2类型标示...参数n类型标示)返回值类型标示

类型标示示意表


JNI_第4张图片
类型标示示意表

函数签名很容易写错,可以使用下列命令获得:

javap –s -p xxx

其中xxx是编译后的class文件名;s表示输出内部数据类型的签名信息,p表示打印所有函数和成员的签名信息,而默认只会打印public成员和函数的签名信息。

数据类型转换

1、 基础数据类型转换


JNI_第5张图片
基础类型转换

基础数据类型的转换比较简单,要注意的是转换后字长的不同。

2、引用类型数据转换


JNI_第6张图片
引用类型转换

由上表可知,除了Java中基本数据类型的数组、Class、String和Throwable外,其余所有Java对象的数据类型在JNI中都用jobject表示。对象类型都用jobject表示,就好比是Native层的void*类型一样,对开发者来说,是完全透明的。既然是透明的,那该如何使用和操作它们?

操作jobject

如何操作jobject,可以从另一个角度入手。一个Java对象是由什么组成的?当然是它的成员变量和成员函数。那么,操作jobject的本质就应当是操作这些对象的成员变量和成员函数。


JNI_第7张图片
操作jobject

1、 jfieldID 和jmethodID 成员变量和成员函数是由类定义的,它是类的属性,所以在JNI规则中,用jfieldID 和jmethodID 来表示Java类的成员变量和成员函数,它们通过JNIEnv的下面两个函数可以得到:

//获取jfieldID

jfieldID GetFieldID(JNIEnv *env,jclass clazz,const char*name, const char *sig);

jfieldID GetStaticFieldID(JNIEnv *env,jclass clazz,const char*name, const char *sig);

//获取jmethodID

jmethodID GetMethodID(JNIEnv *env,jclass clazz, const char*name,const char *sig);

jmethodID GetStaticMethodID(JNIEnv *env,jclass clazz, const char*name,const char *sig);

其中,jclass代表Java类,name表示成员函数或成员变量的名字,sig为这个函数和变量的签名信息。如前所示,成员函数和成员变量都是类的信息,这两个函数的第一个参数都是jclass。

2、 使用jfieldID和jmethodID 操作Jobject的成员函数,可以使用JNIEnv提供的一系列方法,只需传入jobject(或jclass)、jmethodid,形式如下:

//调用成员函数

NativeType CallMethod(JNIEnv *env,jobject obj,jmethodID methodID, ...)

NativeType CallStaticMethod(JNIEnv *env,jclass clazz,jmethodID methodID, ...)

操作Jobject的成员变量,使用方法形式如下:

//获取成员变量值

NativeType GetField(JNIEnv *env,jobject obj,jfieldID fieldID)

NativeType GetStaticField(JNIEnv *env,jclass clazz,jfieldID fieldID)

//设置成员变量值

void SetField(JNIEnv *env,jobject obj,jfieldID fieldID,NativeType value)

void SetStaticField(JNIEnv *env,jclass clazz,jfieldID fieldID,NativeType value)

jstring

Java中的String也是引用类型,不过由于它的使用非常频繁,所以在JNI规范中单独创建了一个jstring类型来表示Java中的String类型。虽然jstring是一种独立的数据类型,但是它并没有提供成员函数供操作。相比而言,C++中的string类就有自己的成员函数了。那么该怎么操作jstring呢?还是得依靠JNIEnv提供的帮助。这里看几个有关jstring的函数:

· 调用JNIEnv的NewString(JNIEnv *env, const jchar*unicodeChars,jsize len),可以从Native的字符串得到一个jstring对象。其实,可以把一个jstring对象看成是Java中String对象在JNI层的代表,也就是说,jstring就是一个Java String。但由于Java String存储的是Unicode字符串,所以NewString函数的参数也必须是Unicode字符串。

· 调用JNIEnv的NewStringUTF将根据Native的一个UTF-8字符串得到一个jstring对象。在实际工作中,这个函数用得最多。

· 上面两个函数将本地字符串转换成了Java的String对象,JNIEnv还提供了GetStringChars和GetStringUTFChars函数,它们可以将Java String对象转换成本地字符串。其中GetStringChars得到一个Unicode字符串,而GetStringUTFChars得到一个UTF-8字符串。

· 另外,如果在代码中调用了上面几个函数,在做完相关工作后,就都需要调用ReleaseStringChars或ReleaseStringUTFChars函数对应地释放资源,否则会导致JVM内存泄露。这一点和jstring的内部实现有关系,读者写代码时务必注意这个问题。

Android.mk

Android.mk文件是在使用NDK编译C代码时必须的文件,Android.mk文件中描述了哪些C文件将被编译且指明了如何编译。

下图是Android.mk文件的大致结构。


JNI_第8张图片
Android.mk大致结构

掌握Android.mk文件的编写主要是掌握其里头将要使用的一些关键字。

LOCAL_PATH := $(call my-dir)

include $(CLEAR_VARS)

LOCAL_MODULE := hellojni

LOCAL_LDFLAGS := -Wl,--build-id

LOCAL_LDLIBS := \

-llog \

LOCAL_SRC_FILES := \

E:\An_Gradle_Project\JniDemo\app\src\main\jni\com_nd_jnidemo_JniTest.c \

E:\An_Gradle_Project\JniDemo\app\src\main\jni\dynamic_register.c \

LOCAL_C_INCLUDES += E:\An_Gradle_Project\JniDemo\app\src\main\jni

LOCAL_C_INCLUDES += E:\An_Gradle_Project\JniDemo\app\src\debug\jni

include $(BUILD_SHARED_LIBRARY)

LOCAL_PATH 是描述所有要编译的C文件所在的根目录,这边的赋值为$(call my-dir),代表根目录即为Android.mk所在的目录。

include $(CLEAR_VARS) 代表在使用NDK编译工具时对编译环境中所用到的全局变量清零,如LOCAL_MODULE,LOCAL_SRC_FILES等,因为在一次NDK编译过程中可能会多次调用Android.mk文件,中间用到的全局变量可能是变化的。

LOCAL_MODULE 是最后生成库时的名字的一部分,给其加上前缀lib和后缀.so就是生成的共享库的名字libhellojni.so。

LOCAL_LDFLAGS:这个编译变量传递给链接器一个一些额外的参数。

LOCAL_LDLIBS是链接系统库。

LOCAL_SRC_FILES 指明要被编译的c文件的文件名。

include $(BUILD_SHARED_LIBRARY) 指明NDK编译后生成动态库。

关于Android.mk文件的其他关键字,读者可以自行阅读了解。

ABI与CPU关系

早期的Android系统几乎只支持ARMv5的CPU架构,目前支持以下七种不同的CPU架构:ARMv5,ARMv7 (从2010年起),x86 (从2011年起),MIPS (从2012年起),ARMv8,MIPS64和x86_64 (从2014年起),每一种都关联着一个相应的ABI。

应用程序二进制接口ABI(Application Binary Interface)定义了二进制文件(尤其是.so文件)如何运行在相应的系统平台上,从使用的指令集,内存对齐到可用的系统函数库。

很多设备都支持多于一种的ABI。

当一个应用安装在设备上,只有该设备支持的CPU架构对应的.so文件会被安装。

但最好是针对特定平台提供相应平台的二进制包,这种情况下运行时就少了一个模拟层(例如x86设备上模拟arm的虚拟层),从而得到更好的性能。


JNI_第9张图片
ABI与CPU

x86设备上,libs/x86目录中如果存在.so文件的话,会被安装,如果不存在,则会选择armeabi-v7a中的.so文件,如果也不存在,则选择armeabi目录中的.so文件。

x86设备能够很好的运行ARM类型函数库,但并不保证100%不发生crash,特别是对旧设备。

64位设备(arm64-v8a, x86_64, mips64)能够运行32位的函数库,但是以32位模式运行,在64位平台上运行32位版本的ART和Android组件,将丢失专为64位优化过的性能(ART,webview,media等等)。

实战

之前有一个技术预研:图片重排。调查后定位到一个开源软件:k2pdfopt,于是便想将它移植到android上。k2pdfopt的源码比较简单,都是C头文件和源文件,在研究了它的结构之后,提取了图片切割重排那部分代码(一个相对独立的模块,只能处理BMP图片)。在编译生成EXE且在windows上运行确保功能正常后,余下便是编译成so库用在android上。

1、书写native方法

/**

* 图片切割重排

* @param filePath 图片路径

* @return 切割重排后的图片数量

*/

public static native int reflowImage(String filePath);

2、生成C头文件和源文件,源文件主要是k2pdfopt API的调用

3、配置NDK,生成so库

4、测试

上述过程和hellojni这个例子类似,因此不再赘述。

上述成果存在两个问题:

1、BMP图片太大(切割后每张都是330K)

2、基本平台的图片几乎为JPG图片,且现有播放器不支持读取BMP格式。

为了解决上述问题,需要引入jpeg库处理JPG图片。现在任务便是将jpeg库与之前独立的k2pdfopt库集成编译成新so库。之前我们都是在android studio上编译so库,ndk配置中文件目录我们都用了缺省配置(因为当时源码结构比较简单)。现在要集成jpeg库,目录结构相对复杂,需要明确指定依赖关系,因此不能再用缺省值。我不喜欢在as中的ndk进行配置,因此另建文件目录,通过编写Android.mk文件和Application.mk(主要配置ABI)文件来实现。

上述方式中,我们是讲所有的源文件链接在一起编译为新so库。其实,jpeg库是一个独立的库,我们可以先将它独立编译成静态库(或者已经有现成的,压根不需我们编译),然后再和k2pdfopt的源码一起集成。这里用到上面提到的Adnroid.mk多模块书写。

#预编译jpeg库libjpeg-turbo.a

include $(CLEAR_VARS)

LOCAL_MODULE    := jpeg-turbo

LOCAL_SRC_FILES := $(LOCAL_PATH)/libjpeg/libjpeg-turbo.a

include $(PREBUILT_STATIC_LIBRARY)

#k2pdfopt模块

...

#将libjpeg-turbo.a链接进来

LOCAL_STATIC_LIBRARIES := jpeg-turbo

...

OK,通过上述两种方式,两个库便集成成功,最后将该so库应用到现有文档播放器中。 关于 JNI调试 与 异常处理 留给大家做扩展阅读。

补充

如果大家是在AS中生成so库,每次构建时,AS如果发现有JNI文件夹,便会进行NDK交叉编译。如果不想每次都进行NDK编译,可在app/build.grale的souceSets中加入下面这句话。

sourceSets {

main {

jni.srcDirs = []

}

}

你可能感兴趣的:(JNI)