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
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路径
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层的函数关联起来?
静态注册
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类型标示)返回值类型标示
类型标示示意表
函数签名很容易写错,可以使用下列命令获得:
javap –s -p xxx
其中xxx是编译后的class文件名;s表示输出内部数据类型的签名信息,p表示打印所有函数和成员的签名信息,而默认只会打印public成员和函数的签名信息。
数据类型转换
1、 基础数据类型转换
基础数据类型的转换比较简单,要注意的是转换后字长的不同。
2、引用类型数据转换
由上表可知,除了Java中基本数据类型的数组、Class、String和Throwable外,其余所有Java对象的数据类型在JNI中都用jobject表示。对象类型都用jobject表示,就好比是Native层的void*类型一样,对开发者来说,是完全透明的。既然是透明的,那该如何使用和操作它们?
操作jobject
如何操作jobject,可以从另一个角度入手。一个Java对象是由什么组成的?当然是它的成员变量和成员函数。那么,操作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文件的大致结构。
掌握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的虚拟层),从而得到更好的性能。
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 = []
}
}