您现在的位置是:网站首页> Android

JNI详解

  • Android
  • 2022-03-29
  • 1080人已阅读
摘要

JNI详解

1、基础概念

1.1、JNI

JNI(Java Native Interface)Java本地接口,使得Java与C/C++具有交互能力


1.2、NDK

NDK(Native Development Kit) 本地开发工具包,允许使用原生语言(C和C++)来实现应用程序的部分功能


Android NDK开发的主要作用:


1、特定场景下,提升应用性能;

2、代码保护,增加反编译难度;

3、生成库文件,库可重复使用,也便于平台、项目间移植;

1.3、CMake与ndk-build

当我们基于NDK开发出native功能后,通常需要编译成库文件,给Android项目使用。

目前,有两种主流的编译方式:CMake__与__ndk-build


__CMake__与__ndk-build__是两种不同的编译工具(与Android代码和C/C++代码无关)


CMake


CMake是Androidstudio2.2之后引入的跨平台编译工具(特点:简单易用,2.2之后是默认的NDK编译工具)


如何配置:

   1、创建CMakeLists.txt文件,配置CMake必要参数;

   2、使用gradle配置CMakeLists.txt以及native相关参数;


如何编译库文件:

   1、Android Studio执行Build即可;

ndk-build


ndk-build是NDK中包含的脚本工具(可在NDK目录下找到该工具,为了方便使用,通常配置NDK的环境变量)


如何配置:

   1、创建Android.mk文件,配置ndk-build必要参数;

   2、可选创建application.mk文件,配置ndk-build参数 (该文件的配置项可使用gradle的配置替代);

   3、使用gradle配置Android.mk以及native相关参数;


2、如何编译库文件(两种方式):

   1、Android Studio执行Build即可(执行了:Android.mk + gradle配置);

   2、也可在Terminal、Mac终端、cmd终端中通过ndk-build命令直接构建库文件(执行了:Android.mk)

2、环境搭建

JNI安装

JNI 是JDK里的内容,电脑上正确安装并配置JDK即可 (JDK1.1之后就正式支持了);


NDK安装

可从官网自行下载、解压到本地,也可基于AndroidStudio下载解压到默认目录;


编译工具安装

cmake 可基于AndroidStudio下载安装;

ndk-build 是NDK里的脚本工具,NDK安装好即可使用ndk-build;


当前演示,使用的Android Studio版本如下(当前最新版):

1.png



启动Android Studio --> 打开SDK Manager --> SDK Tools,如下图所示:

1.png



我们选择NDK、CMake、LLDB(调试Native时才会使用),选择Apply进行安装,等安装成功后,NDK开发所依赖的环境也就都齐全了。


3、Native C++ 项目(HelloWord案例)

3.1、项目创建(java / kotlin)

新建项目,选择 Native C++,如下图:


1.png


1.png


1.png


新创建的项目,默认已包含完整的native 示例代码、cmake配置 ,如下图:

1.png


1.png



这样,我们就可以自己定义Java native方法,并在cpp目录中写native实现了,很方便。


但是,当我们写完native的实现代码,希望运行APP,查看JNI的交互效果,此时,就需要使用编译工具了,咱们还是先看一下Android Studio默认的Native编译方式吧:CMake


3.2、CMake的应用

在CMake编译之前,咱们应该先做哪些准备工作?


1、NDK环境是否配置正确?

-- 如果未配置正确是无法进行C/C++开发的,更不用说CMake编译了


2、C/C++功能是否实现? 

-- 此次演示主要使用系统默认创建的native-lib.cpp文件,关于具体如何实现:后续文章再详细讲解


3、CMakeLists.txt是否创建并正确配置? 

-- 该文件是CMake工具编译的基础,未配置或配置项错误,均会影响编译结果


4、gradle是否正确配置?

-- gradle配置也是CMake工具编译的基础,未配置或配置项错误,均会影响编译结果

除此之外,咱们还应该学习CMake的哪些重要知识?


1、CMake工具编译生成的库文件默认在什么位置?apk中库文件又是在什么位置?

2、CMake工具如何指定编译生成的库文件位置?

3、CMake工具如何指定生成不同CPU平台对应的库文件?

带着这些问题,咱们开始CMake之旅吧:


3.2.1、NDK环境检查

编译前,建议先检查下工程的NDK配置情况(不然容易报一些乱七八糟的错误):

File --> Project Structure --> SDK Location,如下图(我本地的Android Studio默认没有给配置NDK路径,那么,需要自己手动指定一下):


1.png


1.png


3.2.2、C/C++功能实现

因为本节主讲CMake编译工具,代码就不单独写了,咱们直接使用工程默认生成的native-liv.cpp,简单调整一下native实现方法的代码吧(修改返回文本信息):

1.png



因Native C++工程默认已配置好了CMakeLists.txt和gradle,所以咱们可直接运行工程看效果,如下图:

1.jpg



JNI交互效果我们已经看到了,说明CMake编译成功了。那么,这究竟是怎么做到的呢?咱们接着分析一下吧:


3.2.3、CMake生成的库文件与apk中的库文件

安卓工程编译时,会执行CMake编译,在 工程/app/build/.../cmake/ 中会产生对应的so文件,如下图:


1.png


继续编译安卓工程,会根据build中的内容,生成我们的*.apk安装包文件。我们找到、反编译apk安装包文件,查找so库文件。原来在apk安装包中,so库都被存放在lib目录中,如下图:

1.png



3.2.4、CMake是如何编译生成so库的呢?

在前面介绍CMake定义时,提到了CMake是基于CMakeLists.txt文件和gradle配置实现编译Native类的。那么,咱们先来看一下CMakeLists.txt文件吧:


#cmake最低版本要求

cmake_minimum_required(VERSION 3.4.1)


#添加库

add_library(

        # 库名

        native-lib


        # 类型:

        # SHARED 是指动态库,对应的是.so文件

        # STATIC 是指静态库,对应的是.a文件

        # 其他类型:略

        SHARED


        # native类路径

        native-lib.cpp)


# 查找依赖库

find_library( 

        # 依赖库别名

        log-lib


        # 希望加到本地的NDK库名称,log指NDK的日志库

        log)



# 链接库,建立关系( 此处就是指把log-lib 链接给 native-lib使用 )

target_link_libraries( 

        # 目标库名称(native-lib 是咱们要生成的so库)

        native-lib


        # 要链接的库(log-lib 是上面查找的log库)

        ${log-lib})

实际上,CMakeList.txt可配置的内容远不止这些,如:so输出目录,生成规则等等,有需要的同学可查下官网。


接着,咱们再看一下app的gradle又是如何配置CMake的呢?


apply plugin: 'com.android.application'


android {

    compileSdkVersion 29

    buildToolsVersion "29.0.1"

    defaultConfig {

        applicationId "com.qxc.testnativec"

        minSdkVersion 21

        targetSdkVersion 29

        versionCode 1

        versionName "1.0"

        testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"

        //定义cmake默认配置属性

        externalNativeBuild {

            cmake {

                cppFlags ""

            }

        }

    }

    

    //定义cmake对应的CMakeList.txt路径(重要)

    externalNativeBuild {

        cmake {

            path "src/main/cpp/CMakeLists.txt"

        }

    }

}


dependencies {

    implementation fileTree(dir: 'libs', include: ['*.jar'])

    implementation 'androidx.appcompat:appcompat:1.1.0'

    implementation 'androidx.constraintlayout:constraintlayout:1.1.3'

    testImplementation 'junit:junit:4.12'

    androidTestImplementation 'androidx.test.ext:junit:1.1.1'

    androidTestImplementation 'androidx.test.espresso:espresso-core:3.2.0'

}

实际上,gradle可配置的cmake内容也远不止这些,如:abi、cppFlags、arguments等,有需要的同学可查下官网。


3.2.5、如何指定库文件的输出目录?

如果希望将so库生成到特定目录,并让项目直接使用该目录下的so,应该如何配置呢?

比较简单:需要在CMakeList.txt中配置库的输出路径信息,即:


CMAKE_LIBRARY_OUTPUT_DIRECTORY


# cmake最低版本要求

cmake_minimum_required(VERSION 3.4.1)


# 配置库生成路径

# CMAKE_CURRENT_SOURCE_DIR是指 cmake库的源路径,通常是build/.../cmake/

# /../jniLibs/是指与CMakeList.txt所在目录的同级目录:jniLibs (如果没有会新建)

# ANDROID_ABI 生成库文件时,采用gradle配置的ABI策略(即:生成哪些平台对应的库文件)

set(CMAKE_LIBRARY_OUTPUT_DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR}/../jniLibs/${ANDROID_ABI})


# 添加库

add_library( # 库名

        native-lib


        # 类型:

        # SHARED 是指动态库,对应的是.so文件

        # STATIC 是指静态库,对应的是.a文件

        # 其他类型:略

        SHARED


        # native类路径

        native-lib.cpp)


# 查找依赖库

find_library(

        # 依赖库别名

        log-lib


        # 希望加到本地的NDK库名称,log指NDK的日志库

        log)



# 链接库,建立关系( 此处就是指把log-lib 链接给native-lib使用 )

target_link_libraries(

        # 目标库名称(native-lib就是咱们要生成的so库)

        native-lib


        # 要链接的库(上面查找的log库)

        ${log-lib})

还需要在gradle中配置 jniLibs.srcDirs 属性(即:指定了lib库目录):


sourceSets {

        main {

            jniLibs.srcDirs = ['jniLibs']//指定lib库目录

        }

    }

接着,重新build就会在cpp相同目录级别位置生成jniLibs目录,so库也在其中了:

1.png



注意事项:

1、配置了CMAKE_CURRENT_SOURCE_DIR,并非表示编译时直接将so生成在该目录中,实际编译时,so文件仍然是

先生成在build/.../cmake/中,然后再拷贝到目标目录中的


2、如果只配置了CMAKE_CURRENT_SOURCE_DIR,并未在gradle中配置 jniLibs.srcDirs,也会有问题,如下:

More than one file was found with OS independent path 'lib/arm64-v8a/libnative-lib.so'


此问题是指:在编译生成apk时,发现了多个so目录,android studio不知道使用哪一个了,所以需要咱们

告诉android studio当前工程使用的是jniLibs目录,而非build/.../cmake/目录

3.2.5、如何生成指定CPU平台对应的库文件呢?

我们可以在cmake中设置abiFilters,也可在ndk中设置abiFilters,效果是一样的:


defaultConfig {

        applicationId "com.qxc.testnativec"

        minSdkVersion 21

        targetSdkVersion 29

        versionCode 1

        versionName "1.0"

        testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"

        externalNativeBuild {

            cmake {

                cppFlags ""

                abiFilters "arm64-v8a"

            }

        }

    }

defaultConfig {

        applicationId "com.qxc.testnativec"

        minSdkVersion 21

        targetSdkVersion 29

        versionCode 1

        versionName "1.0"

        testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"

        externalNativeBuild {

            cmake {

                cppFlags ""

            }

        }

        ndk {

            abiFilters "arm64-v8a"

        }

    }

按照新的配置,我们重新运行工程,如下图:

1.jpg



再反编译看下工程,果然只有arm64-v8a的so库了,不过库文件在apk中仍然是放在lib目录,而非jniLibs(其实也很好理解,jniLibs只是我们本地的目录,便于我们管理库文件,真正生成apk时,仍然会按照lib目录放置库文件),如下图:


1.png


至此,CMake的主要技术点都讲完了,接下来咱们看下NDK-Build吧~


3.3、ndk-build的应用

在ndk-build编译之前,咱们又应该先做哪些准备工作?


1、ndk-build环境变量是否正确配置?

-- 如果未配置,是无法在cmd、Mac终端、Terminal中使用ndk-build命令的(会报错:找不到命令)


2、NDK环境是否配置正确?

-- 如果未配置正确是无法进行C/C++开发的,更不用说ndk-build编译了


3、C/C++功能是否实现?

-- 此次演示主要使用系统默认创建的native-lib.cpp文件,关于具体如何实现:后续文章再详细讲解

-- 注意:为了与CMake区分,咱们新建一个“jni”目录存放C/C++文件、配置文件吧


4、Android.mk是否创建并正确配置? 

-- 该文件是ndk-build工具编译的基础,未配置或配置项错误,均会影响编译结果


5、gradle是否正确配置?(可选项,如果通过cmd、Mac终端、Terminal执行ndk-build,可忽略)

-- gradle配置也是ndk-build工具编译的基础,未配置或配置项错误,均会影响编译结果


6、Application.mk是否创建并正确配置?(可选项,一般配ABI、版本,这些项均可在gradle中配置)

-- 该文件也是ndk-build工具编译的基础,未配置或配置项错误,均会影响编译结果

除此之外,咱们还应该学习ndk-build的哪些重要知识?


1、ndk-build工具如何指定编译生成的库文件位置?

2、ndk-build工具如何指定生成不同CPU平台对应的库文件?

带着这些问题,咱们继续ndk-build之旅吧:


3.3.1、环境变量配置

介绍NDK-Build定义时,提到了其实它是NDK的脚本工具。那么,咱们还是先进NDK目录找一下吧,ndk-build工具的位置如下图:


1.png


如果我们希望任意情况下都能便捷的使用这种脚本工具,通常做法是配置其环境变量,否则我们在cmd、Mac终端、Terminal中执行 ndk-build 命令时,会报错:“未找到命令”


配置NDK的环境变量,也很简单,以Mac电脑举例(如果是Windows电脑,网上也有很多关于配置环境变量的文章,如果有需要可自行查下):


1、打开命令终端,输入命令: open -e .bash_profile,打开bash_profile配置文件


2、写入如下内容(NDK_HOME指向 ndk-build 所在路径):

export NDK_HOME=/Users/xc/SDK/android-sdk-macosx/ndk/20.1.5948944

export PATH=$PATH:$NDK_HOME


3、生效.bash_profile配置

source .bash_profile

1.png


当我们在cmd、Mac终端、Terminal中执行 ndk-build 命令时,如果出现下图所示内容,则代表配置成功了:

1.png



3.3.2、C/C++功能实现

咱们使用比较常用的一种ndk-build方式吧:ndk-build + Android.mk + gradle配置


项目中新建jni目录,拷贝一份CMake的代码实现吧:


1、新建jni目录

2、拷贝cpp/native-lib.cpp 至 jni目录下

3、重命名为haha.cpp (与CMake区分)

4、调整一下native实现方法的文本(与CMake运行效果区分)

5、新建Android.mk文件

1.png


接着,编写Android.mk文件内容:


#表示Android.mk所在目录

LOCAL_PATH := $(call my-dir)


#CLEAR_VARS变量指向特殊 GNU Makefile,用于清除部分LOCAL_变量

include $(CLEAR_VARS)


#模块名称

LOCAL_MODULE    := haha

#构建系统用于生成模块的源文件列表

LOCAL_SRC_FILES := haha.cpp


#BUILD_SHARED_LIBRARY 表示.so动态库

#BUILD_STATIC_LIBRARY 表示.a静态库

include $(BUILD_SHARED_LIBRARY)

配置gradle:


apply plugin: 'com.android.application'

android {

    compileSdkVersion 28

    defaultConfig {

        applicationId "com.aaa.testnative"

        minSdkVersion 16

        targetSdkVersion 28

        versionCode 1

        versionName "1.0"

        testInstrumentationRunner "android.support.test.runner.AndroidJUnitRunner"


        //定义ndkBuild默认配置属性

        externalNativeBuild {

            ndkBuild {

                cppFlags ""

            }

        }

    }

   

    //定义ndkBuild对应的Android.mk路径(重要)

    externalNativeBuild {

        ndkBuild{

            path "src/main/jni/Android.mk"

        }

    }

}


dependencies {

    implementation fileTree(dir: 'libs', include: ['*.jar'])

    implementation 'com.android.support:appcompat-v7:28.0.0'

    implementation 'com.android.support.constraint:constraint-layout:1.1.3'

    testImplementation 'junit:junit:4.12'

    androidTestImplementation 'com.android.support.test:runner:1.0.2'

    androidTestImplementation 'com.android.support.test.espresso:espresso-core:3.0.2'

}


现在,native代码、ndk-build配置都完成了,咱们运行看一下效果吧,如下图:


1.jpg


3.3.4、如何指定库文件的输出目录?

通常,可在Android.mk文件中配置NDK_APP_DST_DIR

指定源目录与输出目录(与CMake类似)


#表示Android.mk所在目录

LOCAL_PATH := $(call my-dir)


#设置库文件的输入目录

#输出目录 ../jniLibs/

#源目录 $(TARGET_ARCH_ABI)

NDK_APP_DST_DIR=../jniLibs/$(TARGET_ARCH_ABI)


#CLEAR_VARS变量指向特殊 GNU Makefile,用于清除部分LOCAL_变量

include $(CLEAR_VARS)


#模块名称

LOCAL_MODULE    := haha

#构建系统用于生成模块的源文件列表

LOCAL_SRC_FILES := haha.cpp


#BUILD_SHARED_LIBRARY 表示.so动态库

#BUILD_STATIC_LIBRARY 表示.a静态库

include $(BUILD_SHARED_LIBRARY)


3.3.5、如何生成指定CPU平台对应的库文件呢?

可在gradle中配置abiFilters(与Cmake类似)


externalNativeBuild {

            ndkBuild {

                cppFlags ""

                abiFilters "arm64-v8a"

            }

        }

externalNativeBuild {

            ndkBuild {

                cppFlags ""

            }

        }

  ndk {

            abiFilters "arm64-v8a"

        }

3.3.6、如何在Terminal中直接通过ndk-build命令构建库文件呢?

除了执行AndroidStudio的build命令,基于gradle配置 + Android.mk编译生成库文件,我们还可以在cmd、Mac 终端、Terminal中直接通过ndk-build命令构建库文件,此处以Terminal为例进行演示吧:


先进入包含Android.mk文件的jni目录(Android Studio中可直接选中jni目录并拖拽到Terminal中,会自动跳转到该目录),再执行ndk-build命令,如下图:


1.png


同样,编译也成功了,如下图:

1.png



因是直接在Terminal中执行了ndk-build命令,所以只会根据Android.mk进行编译(不包含gradle配置内容,也就不会执行abiFilters过滤),生成了所有默认CPU平台的so库文件。


ndk-build命令其实也可以配上一些参数使用,此处就不再详解了。日常开发时,还是建议选择CMake作为Native编译工具,因为是安卓主推的,而且更简单一些。



第二节讲解点击查看

当通过AndroidStudio创建了Native C++工程后,首先面对的是*.cpp文件,对于不熟悉C/C++的开发人员而言,往往是望“类”兴叹,无从下手。为此,咱们系统的梳理一下JNI的用法,为后续Native开发做铺垫。


1、JNI函数

#include <jni.h>

#include <string>


extern "C" JNIEXPORT jstring JNICALL

Java_com_qxc_testnativec_MainActivity_stringFromJNI(

        JNIEnv* env,

        jobject /* this */) {

    std::string hello = "Hello from C++";

    return env->NewStringUTF(hello.c_str());

}

通常,大家看到的JNI方法如上图所示,方法结构与Java方法类似,同样包含方法名、参数、返回类型,只不过多了一些修饰词、特定参数类型而已。


1.1、extern "C"

作用:避免编绎器按照C++的方式去编绎C函数


该关键字可以删掉吗?

我们不妨动手测试一下:去掉extern “C” , 重新生成so,运行app,结果直接闪退了:




咱们反编译so文件看一下,原来去掉extern “C” 后,函数名字竟然被修改了:


//保留extern "C"

000000000000ea98 T 

Java_com_qxc_testnativec_MainActivity_stringFromJNI


//去掉extern "C"

000000000000eab8 T 

_Z40Java_com_qxc_testnativec_MainActivity_stringFromJNIP7_JNIEnvP8_jobject

原因是什么呢?

其实这跟C和C++的函数重载差异有关系:


1、C不支持函数的重载,编译之后函数名不变;

2、C++支持函数的重载(这点与Java一致),编译之后函数名会改变;


原因:在C++中,存在函数的重载问题,函数的识别方式是通过:函数名,函数的返回类型,函数参数列表

三者组合来完成的。

所以,如果希望编译后的函数名不变,应通知编译器使用C的编译方式编译该函数(即:加上关键字:extern “C”)。


扩展:

如果即想去掉关键字 extern “C”,又希望方法能被正常调用,真的不能实现吗?


非也,还是有解决办法的:“函数的动态注册”,这个后面再介绍吧!!!

1.2、JNIEXPORT、JNICALL

作用:

JNIEXPORT 用来表示该函数是否可导出(即:方法的可见性)

JNICALL 用来表示函数的调用规范(如:__stdcall)


我们通过JNIEXPORT、JNICALL关键字跳转到jni.h中的定义,如下图:




通过查看 jni.h 中的源码,原来JNIEXPORT、JNICALL是两个宏定义


对于安卓开发者来说,宏可这样理解:


├── 宏 JNIEXPORT 代表的就是右侧的表达式: __attribute__ ((visibility ("default")))

├── 或者也可以说: JNIEXPORT 是右侧表达式的别名


宏可表达的内容很多,如:一个具体的数值、一个规则、一段逻辑代码等;

attribute___((visibility ("default"))) 描述的是“可见性”属性 visibility


1、default :表示外部可见,类似于public修饰符 (即:可以被外部调用)

2、hidden :表示隐藏,类似于private修饰符 (即:只能被内部调用)

3、其他 :略

如果,我们想使用hidden,隐藏我们写的方法,可这么写:


#include <jni.h>

#include <string>


extern "C" __attribute__ ((visibility ("hidden"))) jstring JNICALL

Java_com_qxc_testnativec_MainActivity_stringFromJNI(

        JNIEnv* env,

        jobject /* this */) {

    std::string hello = "Hello from C++";

    return env->NewStringUTF(hello.c_str());

}

重新编译、运行,结果闪退了。

原因:函数Java_com_qxc_testnativec_MainActivity_stringFromJNI已被隐藏,而我们在java中调用该函数时,找不到该函数,所以抛出了异常,如下图:




宏JNICALL 右边是空的,说明只是个空定义。上面讲了,宏JNICALL代表的是右边定义的内容,那么,我们代码也可直接使用右边的内容(空)替换调JNICALL(即:去掉JNICALL关键字),编译后运行,调用so仍然是正确的:


#include <jni.h>

#include <string>


extern "C" JNIEXPORT jstring

Java_com_qxc_testnativec_MainActivity_stringFromJNI(

        JNIEnv* env,

        jobject /* this */) {

    std::string hello = "Hello from C++";

    return env->NewStringUTF(hello.c_str());

}

JNICALL 知识扩展:


JNICALL的定义,并非所有平台都像Linux一样是空的,如windows平台:

#ifndef _JAVASOFT_JNI_MD_H_  

#define _JAVASOFT_JNI_MD_H_  

#define JNIEXPORT __declspec(dllexport)  

#define JNIIMPORT __declspec(dllimport)  

#define JNICALL __stdcall  

typedef long jint;  

typedef __int64 jlong;  

typedef signed char jbyte;  

#endif

1.3、函数名

看到.cpp中的函数"Java_com_qxc_testnativec_MainActivity_stringFromJNI",大部分开发人员都会有疑问:我们定义的native函数名stringFromJNI,为什么对应到cpp中函数名会变成这么长呢?


public native String stringFromJNI();

这跟JNI native函数的注册方式有关


JNI Native函数有两种注册方式(后面会详细介绍):

1、静态注册:按照JNI接口规范的命名规则注册;

2、动态注册:在.cpp的JNI_OnLoad方法里注册;

JNI接口规范的命名规则:


Java_<PackageName>_<ClassName>_<MethodName> 

当我们在Java中调用native方法时,JVM 也会根据这种命名规则来查找、调用native方法对应的 C 方法。


1.4、JNIEnv

JNIEnv 代表了Java环境,通过JNIEnv*就可以对Java端的代码进行操作,如:

├──创建Java对象

├──调用Java对象的方法

├──获取Java对象的属性等


我们跳转、查看JNIEnv的源码实现,如下图:




JNIEnv指向_JNIEnv,而_JNIEnv是定义的一个C++结构体,里面包含了很多通过JNI接口(JNINativeInterface)对象调用的方法。


那么,我们通过JNIEnv操作Java端的代码,主要使用哪些方法呢?


函数名称 作用

NewObject 创建Java类中的对象

NewString 创建Java类中的String对象

NewArray 创建类型为Type的数组对象

GetField 获得类型为Type的字段

SetField 设置类型为Type的字段

GetStaticField 获得类型为Type的static的字段

SetStaticField 设置类型为Type的static的字段

CallMethod 调用返回值类型为Type的static方法

CallStaticMethod 调用返回值类型为Type的static方法

具体用法,后面案例再进行演示。

1.5、jobject

jobject 代表了定义native函数的Java类 或 Java类的实例:


├── 如果native函数是static,则代表类Class对象

├── 如果native函数非static,则代表类的实例对象


我们可以通过jobject访问定义该native方法的成员方法、成员变量等。


2、Java、JNI、C/C++基本类型映射关系

上面,已经介绍了.cpp方法的基本结构、主要关键字。当我们定义了具体方法,写C/C++方法实现时,会用到各种参数类型。那么,在JNI开发中,这些类型应该是怎么写呢?

举例:定义加、减、乘、除的方法


//加

jint addNumber(JNIEnv *env,jclass clazz,jint a,jint b){

     return a+b;

}

//减

jint subNumber(JNIEnv *env,jclass clazz,jint a,jint b){

     return a-b;

}

//乘

jint mulNumber(JNIEnv *env,jclass clazz,jint a,jint b){

     return a*b;

}

//除

jint divNumber(JNIEnv *env,jclass clazz,jint a,jint b){

     return a/b;

}

通过上面案例可以看到,几个方法的后两个参数、返回值,类型都是 jint


jint 是JNI中定义的类型别名,对应的是Java、C++中的int类型

我们先源码跟踪、看下jint的定义,jint 原来是 jni.h中 定义的 int32_t 的别名,如下图:




根据 int32_t 查找,发现 int32_t 是 stdint.h中定义的 __int32_t的别名,如下图:




再根据 __int32_t 查找,发现 __int32_t 是 stdint.h中定义的 int 的别名(这个也就是C/C++中的int类型了),如下图:




Java 、C/C++都有一些常用的数据类型,分别是如何与JNI类型对应的呢?如下所示:


Java 、C/C++中的常用数据类型的映射关系表(通过源码跟踪查找列出来的)

JNI中定义的别名 Java类型 C/C++类型

jint / jsize int int

jshort short short

jlong long long / long long (__int64)

jbyte byte signed char

jboolean boolean unsigned char

jchar char unsigned short

jfloat float float

jdouble double double

jobject Object _jobject*

3、JNI描述符 (签名)

JNI开发时,我们除了写本地C/C++实现,还可以通过 JNIEnv *env 调用Java层代码,如获得某个字段、获取某个函数、执行某个函数等:


//获得某类中定义的字段id

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

    { return functions->GetFieldID(this, clazz, name, sig); }


//获得某类中定义的函数id

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

    { return functions->GetMethodID(this, clazz, name, sig); }

上面的函数与Java的反射比较类似,参数:


clazz : 类的class对象

name : 字段名、函数名

sig : 字段描述符(签名)、函数描述符(签名)


写过反射的开发人员对clazz、name这两个参数应该比较熟悉,对sig稍微陌生一些。


sig 此处是指的:


1、如果是字段,表示字段类型的描述符

2、如果是函数,表示函数结构的描述符,即:每个参数类型描述符 + 返回值类型描述符

举例( int 类型的描述符是 大写的 I ):


Java代码:


public class Hello{

     public int property;

     public int fun(int param, int[] arr){

          return 100;

     }

}

JNI C/C++代码:


JNIEXPORT void Java_Hello_test(JNIEnv* env, jobject obj){

    jclass myClazz = env->GetObjectClass(obj);

    jfieldId fieldId_prop = env -> GetFieldId(myClazz, "property", "I");

    jmethodId methodId_fun = env -> GetMethodId(myClazz, "fun", "(I[I)I");

}

由上面的示例可以看到,Java类中的字段类型、函数定义分别对应的描述符:


int  类型 对应的是  I

fun  函数 对应的是  (I[I)I

其他类型的描述符(签名)如下表:


Java类型 字段描述符(签名) 备注

int I int的首字母、大写

float F float的首字母、大写

double D double的首字母、大写

short S short的首字母、大写

long L long的首字母、大写

char C char的首字母、大写

byte B byte的首字母、大写

boolean Z 因B已被byte使用,所以JNI规定使用Z

object L + /分隔完整类名 String 如: Ljava/lang/String

array [ + 类型描述符 int[] 如:[I

Java函数 函数描述符(签名) 备注

void V 无返回值类型

Method (参数字段描述符...)返回值字段描述符 int add(int a,int b) 如:(II)I

4、函数静态注册、动态注册

JNI开发中,我们一般定义了Java native方法,又写了对应的C方法实现。

那么,当我们在Java代码中调用Java native方法时,虚拟机是怎么知道并调用SO库的对应的C方法的呢?


Java native方法与C方法的对应关系,其实是通过注册实现的,Java native方法的注册形式有两种,一种是静态注册,另一种是动态注册:


静态注册:按照JNI规范书写函数名:java_类路径_方法名(路径用下划线分隔)

动态注册:JNI_OnLoad中指定Java Native函数与C函数的对应关系


两种注册方式的使用对比:


静态注册:

1、优缺点:

系统默认方式,使用简单;

灵活性差(如果修改了java native函数所在类的包名或类名,需手动修改C函数名称(头文件、源文件));


2、实现方式:

1)函数名可以根据规则手写

2)也可使用javah命令自动生成


3、示例:

extern "C" JNIEXPORT jstring

Java_com_qxc_testnativec_MainActivity_stringFromJNI(

        JNIEnv* env,

        jobject /* this */) {

    std::string hello = "Hello from C++";

    return env->NewStringUTF(hello.c_str());

}

动态注册:

1、优缺点:

函数名看着舒服一些,但是需要在C代码中维护Java Native函数与C函数的对应关系;

灵活性稍高(如果修改了java native函数所在类的包名或类名,仅调整Java native函数的签名信息)


2、实现方式

env->RegisterNatives(clazz, gMethods, numMethods)


3、示例:

Java类定义Native函数:


package com.qxc.testpage;

public class JNITools {

    static {

        System.loadLibrary("jnidemo");

    }


    //加法

    public static native int  add(int a,int b);


    //减法

    public static native int sub(int a,int b);


    //乘法

    public static native int mul(int a,int b);


    //除法

    public static native int div(int a,int b);

}


.cpp中动态注册:


JNIEXPORT jint JNI_OnLoad(JavaVM* vm, void* reserved){

    //打印日志

    __android_log_print(ANDROID_LOG_DEBUG,"JNITag","enter jni_onload");

    JNIEnv* env = NULL;

    jint result = -1;

    // 判断是否正确

    if((*vm)->GetEnv(vm,(void**)&env,JNI_VERSION_1_6)!= JNI_OK){

        return result;

    }

    // 定义函数映射关系(参数1:java native函数,参数2:函数描述符,参数3:C函数)

    const JNINativeMethod method[]={

            {"add","(II)I",(void*)addNumber},

            {"sub","(II)I",(void*)subNumber},

            {"mul","(II)I",(void*)mulNumber},

            {"div","(II)I",(void*)divNumber}

    };

    //找到对应的JNITools类

    jclass jClassName=(*env)->FindClass(env,"com/qxc/testpage/JNITools");

    //开始注册

    jint ret = (*env)->RegisterNatives(env,jClassName,method, 4);

     //如果注册失败,打印日志

    if (ret != JNI_OK) {

        __android_log_print(ANDROID_LOG_DEBUG, "JNITag", "jni_register Error");

        return -1;

    }

    return JNI_VERSION_1_6;

}


//加

jint addNumber(JNIEnv *env,jclass clazz,jint a,jint b){

     return a+b;

}

//减

jint subNumber(JNIEnv *env,jclass clazz,jint a,jint b){

     return a-b;

}

//乘

jint mulNumber(JNIEnv *env,jclass clazz,jint a,jint b){

     return a*b;

}

//除

jint divNumber(JNIEnv *env,jclass clazz,jint a,jint b){

     return a/b;

}

上面,带着大家了解了两种注册方式的基本知识。接下来,咱们再深入了解一下动态注册和静态注册的底层差异、以及实现原理。


4.1、动态注册原理

动态注册是Java代码调用中System.loadLibray()时完成的


那么,我们先了解一下System.loadLibray加载动态库时,底层究竟做了哪些操作:


System.loadLibray的流程图(为了便于大家理解,此图省略了部分流程)


底层源码:/dalvik/vm/Native.cpp


dvmLoadNativeCode() -> JNI_OnLoad()

//省略的代码......

//将pNewEntry保存到gDvm全局变量nativeLibs中,下次可以直接通过缓存获取

SharedLib* pActualEntry = addSharedLibEntry(pNewEntry);

//省略的代码......

//第一次加载so时,调用so中的JNI_OnLoad方法

vonLoad = dlsym(handle, "JNI_OnLoad");

通过System.loadLibray的流程图,不难看出,Java中加载.so动态库时,最终会调用so中的JNI_OnLoad方法,这也是为什么我们要在C的JNIEXPORT jint JNI_OnLoad(JavaVM vm, void* reserved)方法中注册的原因。


接下来,咱们再深入了解一下动态注册的具体流程:


动态注册的具体流程图(为了便于大家理解,此图省略了部分流程)


如上图所示:


流程1:是指执行 System.loadLibray函数;

流程2:是指底层默认调用so中的JNI_OnLoad函数;

流程3:是指开发人员在JNI_OnLoad中写的注册方法,例如: (*env)->RegisterNatives(env,.....)

流程4:需要重点讲解一下:

├── 在Android中,不管是Java函数还是Java Native函数,它在虚拟机中对应的都是一个Method*对象

├── 如果是Java Native函数,那么Method*对象的nativeFunc会指向一个bridge函数dvmCallJNIMethod

├── 当调用Java Native函数时,就会执行该bridge函数,bridge函数的作用是调用该Java Native方法对应的

JNI方法,即: method.insns


流程4的主要作用,如图所示,为Java Native函数对应的Method*对象,绑定属性,建立对应关系:

├── nativeFunc 指向函数 dvmCallJNIMethod(通常情况下)

├── insns 指向native层的C函数指针 (我们写的C函数)

我们再从源码层面,重点分析一下动态注册的流程3和流程4吧。


流程3:开发人员在JNI_OnLoad中写的注册方法,注册对应的C函数


JNIEXPORT jint JNI_OnLoad(JavaVM* vm, void* reserved){

    //打印日志

    __android_log_print(ANDROID_LOG_DEBUG,"JNITag","enter jni_onload");

    JNIEnv* env = NULL;

    jint result = -1;

    // 判断是否正确

    if((*vm)->GetEnv(vm,(void**)&env,JNI_VERSION_1_6)!= JNI_OK){

        return result;

    }

    // 定义函数映射关系(参数1:java native函数,参数2:函数描述符,参数3:C函数)

    const JNINativeMethod method[]={

            {"add","(II)I",(void*)addNumber},

            {"sub","(II)I",(void*)subNumber},

            {"mul","(II)I",(void*)mulNumber},

            {"div","(II)I",(void*)divNumber}

    };

    //找到对应的JNITools类

    jclass jClassName=(*env)->FindClass(env,"com/qxc/testpage/JNITools");

    //开始注册

    jint ret = (*env)->RegisterNatives(env,jClassName,method, 4);

     //如果注册失败,打印日志

    if (ret != JNI_OK) {

        __android_log_print(ANDROID_LOG_DEBUG, "JNITag", "jni_register Error");

        return -1;

    }

    return JNI_VERSION_1_6;

}


//加

jint addNumber(JNIEnv *env,jclass clazz,jint a,jint b){

     return a+b;

}

//减

jint subNumber(JNIEnv *env,jclass clazz,jint a,jint b){

     return a-b;

}

//乘

jint mulNumber(JNIEnv *env,jclass clazz,jint a,jint b){

     return a*b;

}

//除

jint divNumber(JNIEnv *env,jclass clazz,jint a,jint b){

     return a/b;

}

C函数的定义比较简单,共加减乘除4个函数。当动态注册时,需调用函数 RegisterNatives(env,jClassName,method, 4)(该方法有不同参数的多个方法重载),我们主要关注的参数:jclass clazz、JNINativeMethod* methods、jint nMethods


clazz 表示:定义Java Native方法的Java类;

methods 表示:Java Native方法与C方法的对应关系;

nMethods 表示:methods注册方法的数量,一般设置成methods数组的长度;


JNINativeMethod如何表示Java Native方法与C方法的对应关系的呢?查看其源码定义:


jni.h


//结构体

typedef struct {

    const char* name;   //Java 方法名称

    const char* signature;  //Java 方法描述符(签名)

    void*       fnPtr;  //C/C++方法实现

} JNINativeMethod;

了解了JNINativeMethod结构,那么,JNINativeMethod对象是如何与虚拟机中的Method*对象对应的呢?这个有点复杂了,咱们通过流程图简单描述一下吧:


动态注册的源码流程图(为了便于大家理解,此图省略了部分流程)


dvmSetNativeFunc源码分析

如果还希望更清晰的了解底层源码的实现逻辑,可下载Android源码,自行分析一下吧。


4.2、静态注册原理

静态注册是在首次调用Java Native函数时完成的


静态注册的具体流程图(为了便于大家理解,此图省略了部分流程)

如上图所示:


流程1:Java代码中调用Java Native函数;

流程2:获得Method*对象,默认为该函数的Method*设置nativeFunc(dvmResolveNativeMethod);

流程3:dvmResolveNativeMethod函数中按照特定名称查找对应的C方法;

流程4:如果找到了对应的C方法,重新为该方法设置Method*属性;


注意:当Java代码中第二次再调用Java Native函数时,Method*的nativeFunc已经有值了

(即:dvmCallJNIMethod,可参考动态注册流程内容),会直接执行Method*的nativeFunc的函数,不会在

重新执行特定名称查找了。

静态注册流程2 源码分析


静态注册流程3、4 源码分析


4.3、Java调用native的流程

Java代码中调用Java native的流程图(为了便于大家理解,此图省略了部分流程)

经过对动态注册、静态注册的实现原理的梳理之后,再看Java代码中调用Java native方法的流程图,就比较简单了:


1、如果是动态注册的Java native函数,System.loadLibray时就已经设置好了Java native函数与C函数的对应关系,当Java代码中调用Java native方法时,直接执行dvmCallJNIMethod桥函数即可(该函数中执行C函数)。


2、如果是静态注册的Java native函数,当Java代码中调用Java native方法时,默认为Method.nativeFunc赋值为dvmResolveNativeMethod,并按特定名称查找C方法,重新赋值Method*,最终仍然是执行dvmCallJNIMethod桥函数(只不过Java代码中第二次再调用静态注册的Java native函数时,不会再执行黄色部分的流程图了)


Top