C、C++、Java?Java Native Interface(JNI)特辑——C反射java函数
排版不佳建议点击查看原文
java反射机制回顾
在上篇特辑中我们回顾了C语言的基本内容,这次我们正式聊聊JNI。在此之前我觉得有必要回顾一下java的反射机制。
关于java反射机制的基本概念及API我就不重复了,百度讲的比我好。简单的来说,反射机制指的是程序在运行时能够获取自身的信息。在java中,只要给定类的名字, 那么就可以通过反射机制来获得类的所有信息。
为什么要用反射机制?直接创建对象不就可以了吗,这就涉及到了动态与静态的概念:
静态编译:在编译时确定类型,绑定对象,即通过。
动态编译:运行时确定类型,绑定对象。动态编译最大限度发挥了java的灵活性,体现了多态的应用,有以降低类之间的藕合性。一句话,反射机制的优点就是可以实现动态创建对象和编译,体现出很大的灵活性,特别是在J2EE的开发中它的灵活性就表现的十分明显。比如,一个大型的软件,不可能一次就把把它设计的很完美,当这个程序编译后,发布了,当发现需要更新某些功能时,我们不可能要用户把以前的卸载,再重新安装新的版本,假如
这样的话,这个软件肯定是没有多少人用的。采用静态的话,需要把整个程序重新编译一次才可以实现功能的更新,而采用反射机制的话,它就可以不用卸载,只需要在运行时才动态的创建和编译,就可以实现该功能。
它的缺点是对性能有影响。使用反射基本上是一种解释操作,我们可以告诉JVM,我们希望做什么并且它满足我们的要求。这类操作总是慢于只直接执行相同的操作。
JNI开发流程
我们开发的宗旨是不依赖任何开发工具,所以我们eclipse创建安卓工程,使用命令行编译C代码,虽然不太方便但这是一种通用的方式,不依赖开发工具,不管是androidStudio、还是eclipse都可以使用。
我们在工程目录下创建了jni文件夹,并创建了fork.c文件、Application.mk文件、Android.mk文件,内容暂时不实现。
新建MyJni类,我们声明了两个本地方法getJninumber、Calljni。注意本地方法使用native关键字,内容在C代码的对应函数中现。System.loadLibrary("fork");作用是加载本地.so链接库(我们在jni中的C代码编译后会生成.so本地代码,这是交叉编译的概念:在一个平台上去编译另一个平台上可以执行的本地代码。我们的工程最终调用的并非是C文件,而是.so本地代码)。
在MainActivity中我们在Button的点击事件中调用了我们上面声明的native方法并接收了相应的返回值在ToString方法中将int[]转化为String,由于过程简单这里不再上图。
Android.mk解析
接下来我们单独聊聊jni目录下的Android.mk文件。Android.mk如果是底层开发的工程师一定再熟悉不过了,基本概念依然是留你自己百度去吧。通俗简单的说就是告诉编译器.c的源文件在什么地方,要生成的编译对象的名字是什么。
LOCAL_PATH := $(call my-dir)
每个Android.mk文件必须以定义LOCAL_PATH为开始。它用于在开发tree中查找源文件。
宏my-dir 则由Build System提供。返回包含Android.mk的目录路径。
include $(CLEAR_VARS)
CLEAR_VARS 变量由Build System提供。并指向一个指定的GNU Makefile,由它负责清理很多LOCAL_xxx.
例如:LOCAL_MODULE, LOCAL_SRC_FILES, LOCAL_STATIC_LIBRARIES等等。但不清理LOCAL_PATH.
这个清理动作是必须的,因为所有的编译控制文件由同一个GNU Make解析和执行,其变量是全局的。所以清理后才能避免相互影响。
LOCAL_MODULE := fork
LOCAL_MODULE模块必须定义,以表示Android.mk中的每一个模块。名字必须唯一且不包含空格。
Build System会自动添加适当的前缀和后缀。例如,fork,要产生动态库,则生成libfork.so. 但请注意:如果模块名被定为:libfork.则生成libfork.so. 不再加前缀。简单来说就是指定了生成的动态链接库的名字。
LOCAL_SRC_FILES := fork.c
LOCAL_SRC_FILES变量必须包含将要打包如模块的C/C++ 源码。
不必列出头文件,build System 会自动帮我们找出依赖文件。
缺省的C++源码的扩展名为.cpp. 也可以修改,通过LOCAL_CPP_EXTENSION。
简单来说就是指定了C的源文件叫什么名字。
include $(BUILD_SHARED_LIBRARY)
BUILD_SHARED_LIBRARY:是Build System提供的一个变量,指向一个GNU Makefile Script。它负责收集自从上次调用include $(CLEAR_VARS)后的所有LOCAL_XXX信息。并决定编译为什么。
BUILD_STATIC_LIBRARY:编译为静态库。
BUILD_SHARED_LIBRARY:编译为动态库。
BUILD_EXECUTABLE:编译为Native C可执行程序。
Application.mk解析
Application.mk是用来描述你的应用程序需要哪些模块,以及这些模块所要具有的一些特性。
Application.mk文件一般是放在$PROJECT/jni/目录下的($PROJECT代表你所写程序的项目目录),这样ndk-build命令可以自动搜索到它。当然,Application.mk文件其实是可选的。默认情况下,如果ndk-build命令找不到Application.mk文件的话,就会使用如下规则进行编译:
1)会编译全部在Android.mk文件中列出的模块;
2)对于所有模块,NDK编译系统会根据“armeabi” ABI来生成机器代码,即指令集是ARMv5TE。
具体来说,Application.mk文件中,可以包含对下面几个变量的定义:
APP_PROJECT_PATH
APP_MODULES
APP_OPTIM
APP_CFLAGS
APP_CPPFLAGS
APP_CXXFLAGS
APP_BUILD_SCRIPT
APP_ABI
这里我们只用到了APP_ABI默认情况下,NDK编译系统会根据“armeabi” ABI来生成机器代码,即一个使用ARMv5TE指令集并且支持软件浮点操作的CPU。
你可以通过定义APP_ABI变量来选择一个不同的ABI。这里我们使用了all,表示我们选择编译全平台的机器代码,也可以有针对的写x86则会只编译x86平台处理器的代码。
编写C代码
我们在MyJni类中声明了两个本地方法,所以我们的C代码需要新建两个函数对应java的本地方法。本地函数命名规则: 返回值 Java_包名_类名_本地方法名。按照此命名规则我们当然是可以创建对应的函数的,可是如果java本地方法数量过多,这时候就需要生成.h头文件来完成函数的声明。
首先我们进入项目工程的src目录下,输入javah命令系统有相应的提示,我们选择-jni生成JNI样式的标头文件,最后接上java本地方法所在类的全类名即可在项目src目录下生成头文件xxx.h。
在头文件中我们发现,java本地方法对应的函数名已经帮我们生成好了,我们只需要拷贝到C文件中作为我们的函数即可。
我们来到C代码,这是java本地方法getJninumber所对应的函数。我们发现有三个值:JNIEnv*、jclass、jintArray,这都是啥呢?别急我们一个一个聊。
首先注意到我们引入了这个函数包,jni.h其实是我们android开发中NDK开发包提供的(详细目录android-ndk-r9d\platforms\android-19\arch-arm\usr\include\jni.h)。
我们打开jni.h源码找到JNIEnv所定义的位置,#if defined(_cplusplus)意思是如果是C++文件则JNIEnv是_JNIEnv的自定义类型,#else否则JNIEnv 是JniNativeInterface这个结构体的一级指针!由于我们是C代码文件所以是后者。
回到我们的C代码中JNIenv* env,实际上就是JniNativeInterface这个结构体的二级指针。我们通过(*env)就可以方便的调用JniNativeInterface结构体中定义的函数指针。
接下来我们追踪第二个参数jclass,发现在源码中jclass其实是jobject的自定义类型,jobject又是void*的自定义类型。调用本地方法的java对象就是这个jobject,在这里我们的本地方法的java对象是MyJni,所以jclass就是MyJni类的实例对象。
最后一个参数jintArray实际上和上一个类似,它是jarray的自定义类型,最终也是void*。在这里jintArray对应我们java本地方法getJninumber传入的int[]数组。
明白了函数的三个参数后我们要开始实现我们的函数逻辑,我们需要将java中传入的int[]数组处理更改后给它作为jintArray返回。
我们通过(*env)可以直接调用结构体中的GetArrayLength函数,函数接收JNIEnv*和jintArray类型的参数返回数组的长度,jsize实际上是int的自定义类型。
通过GetIntArrayElements函数获取array数组的指针,最后一个参数传布尔值标志数组是否被复制。这里我们并不关心,所以传NULL。
数组长度有了,指针有了,我们遍历数组,通过指针位运算更改了数组中每一个元素的值(+10),最终return回去。
运行ndk-build即可开始编译C程序,编译完成后可在libs文件夹下看到编译完成的.so动态链接库。(前提是你已经配置了NDK环境变量)
在点击事件中我们调用了getJninumber传入int[]{1,2,3,4,5}数组,并接收了返回值然后吐司。完成了一次java传入数据给C处理后返回java的操作。
C函数反射调用java方法
接下来我们聊聊下一个函数Calljni的实现,看看他是如何实现回掉java方法的。
JniCallMe便是C函数Calljni需要回掉的java方法,它在MainActivity中定义。
这是我们Calljni函数的实现,一起来看看:
找到字节码对象,在java中万物皆对象Class也是对象,我们需要反射的方法在MainActivity中,所以我们需要获取MainActivit的Class对象。当然JniNativeInterface这个结构体帮我们定义好了对应的函数,我们只需要调用FindClass函数,最后一个参数是我们的Class的全路径用斜杠隔开即可。
找到方法所在类的对象,我们的方法定义在MainActiviy中,所以我们需要获取MainActiviy对象,注意本函数的第二个参数jclass obj并不能直接使用,因为它是native方法所在类的对象即是MyJni类的对象,并不是我们要的!通过AllocObject函数,最后一个参数把第一步获取MainActivit的Class对象传入即可。
获取方法对象,通过GetMethodID函数,最后两个参数传入方法的名称、方法签名(由于java的方法允许重载,GetMethodID函数需要通过方法签名才能区分,怎么查看方法签名?我们晚点聊)。
最后一步,反射java方法,CallVoidMethod函数可以帮我们做到,它是针对无返回值的java方法反射,传入env、MainActivity对象、方法对象、还有java方法的形参...这里我们传入int值6。
至此,函数就编写完成,我们在Button点击事件中调用Calljni本地方法,C函数便会反射JniCallMe方法并传入形参6完成控制台打印。
为什么Toast会崩溃
细心的小伙伴发现我为啥把Toast注释了改用控制台输出?
因为会报错!!!通过log我们发现是空指针异常,Context对象为Null。可是我们明明通过MainActivity.this传入Cantext。
原因是这样,由于我们在C函数中第2步通过AllocObject函数获取的MainActiviy对象其实是new出来的。Android程序与Java程序不一样,并不是随随便便写一个类,在main()方法里面就能运行。Android是基于组件化设计的,组件的运行需要一套完整的Android的环境的,在这个环境下Activity,Service才能运行,而这些组件不能以new的方式创建实例,它需要相应的上下文环境,也就是我们Context。可以说Context是这些Android组件运行的一个核心类。所以我们并不能获取到Context对象,从而导致了空指针异常。
方法签名
在上面的GetMethodID函数中的最后一个参数需要传入方法签名,那么方法签名应该如何获取?
在命令行进入项目的bin\classes目录运行javap,会看到有帮助提示,我们输入javap -s 方法所在的全类名 即可看到方法签名。复制到代码中即可。
至此本篇所聊的内容都结束了,下篇我们来聊聊关于使用JNI调用cfork子进程的话题。
欢迎长按下图-识别图中二维码或者扫一扫,搜索微信公众号:黄君华。关注我的公众号:
如果你有不同意见或建议或者有好的技术文章想和大家分享欢迎投稿,可以把你的文章使用附件的形式发送到我的邮箱[email protected]
谢谢阅读!