Android JNI 原理
JNI 是 Java Native Interface 的缩写,译为 Java 本地接口,是 Java 与其他语言通信的桥梁(Android 中更多的是为了方便 Java 调用 C,C++ 等本地代码所封装的一层接口),可用它来解决一些 Java 无法处理的任务(Java 的跨平台特性导致其本地交互能力不够强大,一些和操作系统相关的特性 Java 无法完成),一般在以下情况需要用到 JNI 技术:
需要调用 Java 语言不支持的依赖于操作系统平台特性的一些功能。例如:需要调用当前的 UNIX 系统的某个功能。
为了整合一些以前的非 Java 语言开发的系统。例如:需要用到早期实现的 C/C++ 语言开发的一些功能或系统,将这些功能整合到当前的系统或新的版本中。
为了节省程序的运行时间,必须采用其他语言(比如C/C++)来提升运行效率。例如:游戏、音视频开发涉及的音视频编解码和图像绘制需要更快的处理速度。
为了更方便地使用 JNI 技术,Android 还提供了 NDK 这个工具集合,NDK 开发是基于 JNI 的,通过 NDK 可以 Android 中更加方便地通过 JNI 来访问本地代码,比如 C 或 C++,它和 JNI 开发本质上并没有区别,NDK 还提供了交叉编译器,开发人员只需要简单地修改 mk 文件就可以生成特定 CPU 平台的动态库。使用 NDK 的好处:
- 提高代码安全性。因为 so 库反编译比较困难。
- 可方便使用已有的 C/C++ 开源库。
- 便于平台间的移植。通过 C/C++ 实现的动态库可方便地在其他平台使用。
- 提高程序在某些特定情形下的执行效率,但是并不能明显提升 Android 程序的性能。
JNI 的开发流程
JNI 调用 Java 方法的流程是,先通过类名找到类,然后根据方法名找到方法,最后就可以调用这个方法了。
JNI 的开发流程:
1、在 Java 中声明 native 方法
1 | package com.example.testapplication; |
2、编译 Java 源文件得到 class 文件,然后通过 javah 命令导出 JNI 的头文件(.h 文件)
cd 到 testapplication 目录下,执行命令:javac JNITest.java -h .
(注意后面的那个点),会在同级目录下生成 .class 文件和 .h 文件。(关于命令,我是 Mac 系统,并且我的 jdk 版本取消了 javah 命令,所以用上面的命令代替)
3、实现 JNI 方法
JNI 方法指的是 Java 中声明的 native 方法。接下来分别使用 C 和 C++ 来实现,它们的实现过程是类似的,只有少量区别。
需要三个文件:com_example_testapplication_JNITest.h 是刚才生成的 .h 文件。。通过 test.c 和 test.cpp 文件来分别实现 C 和 C++。
1 | /* DO NOT EDIT THIS FILE - it is machine generated */ |
1 | // test.c |
1 | // test.cpp |
4、编译 so 库并在 Java 中调用
将 C/C++ 代码编译成本地动态库文件,动态库文件名命名规则:lib+动态库文件名+后缀(操作系统不一样,后缀名也不一样)如:
- Mac OS X : libHelloWorld.jnilib
- Windows :HelloWorld.dll(不需要 lib 前缀)
- Linux/Unix:libHelloWorld.so
1 | gcc -dynamiclib -o /Users/jianghouren/libHelloWorld.jnilib jni/test.c -framework JavaVM -I/$JAVA_HOME/include -I/$JAVA_HOME/include/darwin |
$JAVA_HOME目录在:Mac下查看已安装的 jdk 版本及其安装目录,打开终端,输入:/usr/libexec/java_home -V
参数选项说明:
- -dynamiclib:表示编译成动态链接库
- -o:指定动态链接库编译后生成的路径及文件名
- -framework JavaVM -I:编译 JNI 需要用到 JVM 的头文件(
jni.h
),第一个目录是平台无关的,第二个目录是与操作系统平台相关的头文件
调用这里出了问题,暂时记录下,一直提示找不到类 JNITest。
NDK 的开发流程
NDK 的开发流程:
1、下载并配置 NDK
2、创建一个 Android 项目,并声明所需的 native 方法
3、实现 Android 项目中所有声明的 native 方法
4、切换到 jni 目录的父目录,然后通过 ndk-build 命令编译产生 so 库
系统源码中的 JNI
Android 系统按语言可划分为 Java 世界和 Native 世界,而 JNI 就是连接它们的桥梁。通过 JNI,它们就可以互相访问对方的代码了。
MediaRecorder 框架中的 JNI
MediaRecorder 用于录音和录像。MediaRecorder 框架中的 JNI 分为:
- Java Framework 层。对应的是 MediaRecorder.java ,也就是在应用开发中直接调用的类。
- JNI 层。对应的是 libmedia_jni.so,它是一个 JNI 的动态库。
- Native 层。对应的是 libmedia.so,这个动态库完成了实际的调用功能。
各层级的 MediaRecorder 源码理解:
Java Framework 层
只需要加载对应的 JNI 库,并将相关方法声明为 native(表示它是一个 native 方法,由 JNI 来实现),剩下的工作由 JNI 层来完成。
JNI 层
在 Java Framework 层声明为 native 的方法在 JNI 层都会有对应的方法实现,而这种对应关系是通过 JNI 方法注册实现的,Java Native 方法注册分为静态注册和动态注册,前者多用于 NDK 开发,后者多用于 Framework 开发。
Java Native 方法注册
静态注册
示例(Java):仿照系统,新建一个 MediaRecorder.java 。
1 | package com.example.testapplication; |
cd 到 testapplication 目录下,执行命令:javac MediaRecorder.java -h .
,会在同级目录下生成 .class 文件和 .h 文件。(关于命令,我是 Mac 系统,并且我的 jdk 版本取消了 javah 命令,所以用上面的命令代替)
1 | /* DO NOT EDIT THIS FILE - it is machine generated */ |
当在 Java 中调用 native_init() 时,会从 JNI 中寻找 Java_com_example_testapplication_MediaRecorder_native_1init 函数,如果没有就会报错,如果找到就会为这两个方法建立关联,其实是保存 JNI 的函数指针,这样再次调用 native_init() 时直接使用这个函数指针就可以了。
静态注册就是根据方法名,将 Java 方法和 JNI 函数建立关联,但是它有一些缺点:
- JNI 层的函数名称过长。
- 声明 Native 方法的类需要用 javah 生成头文件。
- 初次调用 Native 方法时需要建立关联,影响效率。
静态注册就是 Java 的 Native 方法通过方法指针来与 JNI 进行关联,如果 Java 的 Native 方法知道它在 JNI 中对应的函数指针,就可以避免上述的缺点,这就是动态注册。动态注册要比静态注册复杂一些,但是一劳永逸。
动态注册
JNI 中有一种结构用来记录 Java 的 Native 和 JNI 方法的关联关系,它就是 JNINativeMethod,它在 jni.h 中被定义:
1 | typedef struct{ |
系统的 MediaRecorder 采用的就是动态注册,查看它的 JNI 层实现:
1 | // 定义了一个 JNINativeMethod 类型的数组,里面存储的就是 MediaRecorder 的 Native 方法与 JNI 层函数的对应关系。 |
定义了 JNINativeMethod 类型的数组后,还需要注册它,注册的函数为 register_android_media_MediaRecorder,这个函数被调用在 android_media_MediaPlayer.cpp 的 JNI_OnLoad 函数中。
因为多媒体框架中很多都要进行 JNINativeMethod 数组注册,所以整个多媒体框架的注册函数被统一定义在 android_media_MediaPlayer.cpp 的 JNI_OnLoad 函数中, JNI_OnLoad 函数会在调用 System.loadLibrary 函数后调用。
register_android_media_MediaRecorder 函数通过一系列的方法调用,最终在 JNI 帮助类 JNIHelp.cpp 中通过调用 JNIEnv 的 RegisterNatives 函数完成 JNI 的注册。(JNIEnv 在 JNI 中十分重要)
数据类型转换
Java 的数据类型到了 JNI 层需要转换为 JNI 层的数据类型。Java 的数据类型分为基本数据类型和引用数据类型,JNI 层对于这两种类型也做了区分。
基本数据类型的转换
基本数据类型转换,除了最后一行的 void,其他的数据类型只需要在前面加上 “j” 就可以了。第三列的 Signature 代表签名格式。
引用数据类型的转换
从表 9-2 可以看出,数组的 JNI 层数据类型需要以 ”Array“ 结尾,签名格式的开头都会有 ”[“ 。需要注意有些数据类型的签名以 ”;“ 结尾。引用数据类型还具有继承关系,如图 9-3 所示,从图中看出 jclass、jstring、jarray 和 jthrowable 都继承 jobject,而 jobjectArray、jintArray 和 jlongArray 等类型都继承 jarray。
方法签名
方法签名是由签名格式(Signature)组成的,它的作用在于:
因为 Java 是有重载方法的,可以定义方法名相同,但参数不同的方法,正因如此,在 JNI 中仅仅通过方法名是无法找到 Java 中对应的具体方法的,所以 JNI 将参数类型和返回值类组合在一起作为方法签名。通过方法签名和方法名就可以找到对应的 Java 方法,JNI 的方法签名的格式为:
(参数签名格式 … )返回值签名格式
手动组织方法签名麻烦且容易出错,Java 提供了 javap 命令来自动生成方法签名,在上面的例子中添加 native_setup 方法:
1 | package com.example.testapplication; |
cd 到 testapplication 目录下,执行命令:
1、首先执行 javac MediaRecorder.java:生成 .class 文件
2、然后执行 javap -s -p MediaRecorder.class(s 表示输出内部类型签名,p 表示打印出所有的方法和成员(默认打印 public 成员))
3、最终在终端内的打印结果:
1 | jianghouren@localhost testapplication % javap -s -p MediaRecorder.class |
解析 JNIEnv
JNIEnv 是 Native 世界中 Java 环境的代表,通过 JNIEnv * 指针就可以在 Native 世界中访问 Java 世界的代码进行操作,它只在创建它的线程中有效,不能跨线程传递,因此不同线程的 JNIEnv 是彼此独立的,JNIEnv 的主要作用有以下两点:
- 调用 Java 的方法。
- 操作 Java(操作 Java 中的变量和对象等)。
在 JNIEnv 的定义中,没有定义就是 C 代码,如果定义了就是 C++ 代码。
1 | // 使用预定义宏 _cplusplus 来区分 C 和 C++ 两种代码 |
实际上,无论是 C 还是 C++,JNIEnv 的类型都和 JNINativeInterface 结构有关。查看源代码中关于它们的定义(8.0 版本):
1 | /** |
1 | /** |
JavaVM 是虚拟机在 JNI 层的代表,在一个虚拟机进程中只有一个 JavaVM,因此,该进程的所有线程都可以使用这个 JavaVM。通过 JavaVM 的 AttachCurrentThread 函数可以获取这个线程的 JNIEnv,这样就可以在不同的线程中调用 Java 方法了。还要记得在使用 AttachCurrentThread 函数的线程退出前,务必要调用 DetachCurrentThread 函数来释放资源。
jfieldID 和 jmethodID
在 _JNIEnv 结构体中定义了很多函数,这些函数都会有不同的返回值,以上面例子中的 GetFieldID 和 GetMethodID 为例,这两个函数的返回值分别为 jfieldID 和 jmethodID。(当然还有一些其它函数的其它返回值)
接下来查看 MediaRecorder 框架的 JNI 层是如何使用 GetFieldID 和 GetMethodID 这两个方法的:
1 | static void |
fields 的定义如下:
1 | struct fields_t { |
将这些成员变量和方法赋值给 jfieldID 和 jmethodID 类型的变量有两个原因:
- 为了效率考虑,如果每次调用相关方法时都要查询方法和变量,显然会效率很低。
- 这些成员变量和方法都是本地引用,在 android_media_MediaRecorder_native_init 函数返回时这些本地引用会被自动释放,因此用 fields 来进行保存,以便后续使用。
综合上面两方面原因,在 MediaRecorder 框架 JNI 层的初始化方法 android_media_MediaRecorder_native_init 中将这些 jfieldID 和 jmethodID 类型的变量保存起来,是为了更高效率地供后续使用。
使用 jfieldID 和 jmethodID
保存了 jfieldID 和 jmethodID 类型的变量,接下来看如何使用它们。
首先查看如何使用了 jmethodID:
1 | void JNIMediaRecorderListener::notify(int msg, int ext1, int ext2) |
在 postEventFromNative 的内部会创建一个 Message 消息,并将它发送给 MediaRecorder 内部类 mEventHandler 来处理,这样做的目的是将代码逻辑运行在应用程序的主线程中。
JNIEnv 的 CallStaticVoidMethod 函数可以访问静态方法,如果想要访问 Java 的方法则可以使用 JNIEnv 的 CallVoidMethod 函数。
接下来查看如何使用了 jfieldID:
1 | static void |
引用类型
和 Java 的引用类型一样,JNI 也有引用类型,它们分别是本地引用(Local References)、全局引用(Global References)和弱全局引用(Weak Global References)。
本地引用
JNIEnv 提供的函数所返回的引用基本上都是本地引用,因此本地引用也是 JNI 中最常见的引用类型。本地引用主要有以下几个特点:
- 当 Native 函数返回时,这个本地引用就会被自动释放。
- 只在创建它的线程中有效,不能够跨线程使用。
- 局部引用是 JVM 负责的引用类型,受 JVM 管理。
以 android_media_MediaRecorder_native_init 函数举例:
1 | static void |
全局引用
它和本地引用几乎是相反的,它主要有以下特点:
- 在 native 函数返回时不会被自动释放,因此全局引用需要手动来进行释放,并且不会被 GC 回收。
- 全局引用是可以跨线程使用的。
- 全局引用不受到 JVM 管理。
JNIEnv 的 NewGlobalRef 函数用来创建全局引用,调用 JNIEnv 的 DeleteGlobalRef 函数来释放全局引用。
1 | JNIMediaRecorderListener::JNIMediaRecorderListener(JNIEnv* env, jobject thiz, jobject weak_thiz) |
全局引用 mClass 的释放时机,查看 JNIMediaRecorderListener 的析构函数;
1 | // 此析构函数用来释放全局引用 |
弱全局引用
弱全局引用是一种特殊的全局引用,它和全局引用的特点相似,不同的是弱全局引用是可以被 GC 回收的,弱全局引用被 GC 回收之后会指向 NULL。
JNIEnv 的 NewWeakGlobalRef 函数用来创建弱全局引用,调用 JNIEnv 的 DeleteWeakGlobalRef 函数来释放弱全局引用。
由于弱全局引用可能被 GC 回收,因此在使用它之前要先判断它是否被回收了,方法就是调用 JNIEnv 的 IsSameObject 函数来判断;
1 | // weakGlobalRef 是一个弱全局引用,使用它之前需要调用 JNIEnv 的 IsSameObject 函数来判断,这个函数会判断传入的两个引用是否相等,如果相等返回 JNI_TRUE,不相等返回 JNI_FALSE。 |
备注
参考资料: