JNI

作者: WayOfDevilin | 来源:发表于2017-05-05 15:38 被阅读0次

    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

    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路径

    配置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的本质就应当是操作这些对象的成员变量和成员函数。

    操作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大致结构

    掌握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的虚拟层),从而得到更好的性能。

    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

          本文链接:https://www.haomeiwen.com/subject/hqqstxtx.html