上一篇:NDK 开发:环境配置
本文章所用的工具版本
Android Studio 3.6.3
Gradle 5.6.4
NDK 21.3.6528147
CMake 3.10.2
什么是 JNI?
- JNI 的全称是 Java Native Interface,从名称上面翻译,它是一个 Java 和 C 语言的接口,通过这个翻译我们基本可以判定,这个 JNI 其实就是 Java 语言和 C 语言之间通讯的桥梁。
为什么要有 JNI?
- 因为 Java 和 C 之间无法直接通讯,Java 和 JavaScript 也同理,无法直接通过代码显式调用,这中间需要一个翻译官来做这件事,而 JNI 出现的目的就是为了解决 Java 和 C 这两个不同语言之间的通讯问题。
开胃菜
- 在正式进入主题之前,我们先讲一下如何将一个普通的项目改造成一个 NDK 项目
- 创建一个 cpp 文件夹,这个文件夹和 java 是同级目录
然后在这个文件夹下面创建一个 cpp 文件
cpp 文件其实就是 c++ 源码,到了这里可能大多数人又有一个疑问涌上心头,刚刚不是说 Java 和 C,怎么到这里就变成 C++ 了呢?
这里解释一下,C++ 是 C 的超集,兼容大部分 C 语法,我们可以理解 C++ 是 C 的子类,拥有 C 的特性,同时又在这上面扩展了另外的一些特性。
那么 C++ 相比 C 又有什么不同呢?其实最大的不同在于,C 语法的设计思想是面向过程的,而 C++ 语法的设计思想是面向对象。
之所以用 C++ 而不用 C 的目的很简单,Java 也是面向对象的语言,C++ 语言对于 Java 程序员来说比较容易接受,看 C++ 的代码就像在看 Java 代码差不多。
- 在 cpp 文件夹下再创建一个 CMake 文件
- 在 CMake 文件中配置一些 NDK 开发相关的参数
在 Gradle 中配置一些 CMake 相关的参数
到这里就结束了?其实还有关键一步,如果我们没有配置好的话,会直接导致我们无法对 C++ 的代码进行断点调试
在项目配置选择 Debug 类型,Studio 提供了四种配置
Java Only:只断点 Java 层的代码
Native Only:只断点 Native 层的代码
Detect Automatically:自动检测
Dual(Java + Native):两种都用
默认是 Java Only,这样会导致我们无法直接在项目中断点 C/C++ 的代码,所以在这里我们应该选择 Detect Automatically 或者 Dual(Java + Native)选项
到这里就已经成功将一个普通的项目改造成 NDK 项目了,这只是一个开胃菜,接下来让我们正式进入主题
主菜
- 我们创建一个 Java 类,在静态代码块中加载 so 库
- 需要注意的是:这里的 so 库的名称不是根据 cpp 文件的名称来定的,而是根据 CMake 中的配置而定的,只是现在为了演示(偷懒),定义成同一个名称而已。但是 so 库生成的文件名称最终会以 CMake 文件配置的为准。
- 另外系统 API 给我们提供了两种加载 so 的方式,第一种直接加载 apk 中的 so 文件,第二种是通过文件地址来加载 so 文件,一般情况下我们用第一种就可以了,第二种一般是在用在热修复框架上面,它的实现方式也很简单,通过修改静态代码块中的代码,将要加载的 so 的文件重新指向,加载目标从 apk 包转移到应用的内部存储中(data/data/包名/lib),在这之前热修复框架会提前下载好 so 文件存放到此处。
为了演示 Java 和 C++ 之间的相互调用,我们创建了两个方法,第一个方法是 Java 调用 C++ 的代码,第二个方法是 C++ 回调 Java 代码
需要留意的是,Java 调用 C++ 的方法要被 native 修饰,表明这是一个本地方法,方法体不需要有任何实现
然后我们在 Native 层中创建一个跟 Java 层对应的方法
C++ 代码?大多数人看到这里就望而止步了,其实这里面的代码很简单,接下来让我们一步步解析这个 这些代码的含义和作用
- 这个 include 在 Java 层上其实跟 import 差不多,但是在 C++ 文件中它不叫导包,而是叫引入头文件
- 这块我们可以理解成
- Java 中的 JNI 方法要被 native 修饰,那 Native 层中的 JNI 方法同样也不例外
- 需要特别留意的是,Native 层方法的返回值类型的定义位置有点奇特,和 Java 是不太一样的,至于为何 Java 上的返回值是 String 类型,而到了 Native 上的返回值却是 jstring 类型,这个问题待会会讲到。
- Native 层中的 JNI 方法要和 Java 层中的 JNI 方法要对应上,在 Native 层中 JNI 方法的命名格式为
Java_包名_类名_方法名
,之所以用下划线而不用小数点是因为方法名不能带特殊符号,无论是在 Java 代码上还是 C/C++ 代码上,这种情况都是不允许出现的,否则无法编译通过。
- 接下来让我们先看一下这两个参数的含义,我相信大多数人的心里已经有答案了
- 这个 jobject 其实就是外层的 Java 对象,具体是什么对象,代码提示已经告诉我们了
- 而 jstring 其实就是 Java 方法中传入的参数,只不过在 Java 上叫 String,而在 Native 叫 jstring,参数这块也是一一对应的
Java 类型 | JNI 别名 | C 类型 |
---|---|---|
boolean | jboolean | unsigned char |
byte | jbyte | signed char |
char | jchar | unsigned short |
short | jshort | short |
int | jint | int |
long | jlong | long |
float | jfloat | float |
double | jdouble | double |
String | jstring | char* |
Class | jclass | / |
Object | jobject | / |
我们先来看一张表,关于 Java 类型、JNI 别名、C 类型之间的对应表
由于 Java 和 C 语言之间无法直接调用,但是这两种语言的基本数据类型是不一样的,例如 Java 中有 boolean 类型, 而在 C 中就没有这种类型,但是 C 语言还是有 if else 判断的,那么它是怎么判断 true 或者 false 的呢?正如表上所示,使用 char 类型,当 char 的值是 0 就是 false,非 0 就是 true。
两种语言的数据结构存在巨大差异,基于这种情况,JNI 重新定义了一些类型,以便和 Java 上的类对应上,而这些类本质上还是属于 C 语言中的类。
看了这几句代码,忽然心中出现一种似曾相识的感觉,但是始终说不出来是什么
这种实现其实很类似于我们使用 Java 中的反射,属于隐式调用,由于 Native 无法显式调用 Java 代码,所以也采用了隐式调用。而这里面的 API 和 Java 的其实差不多,换汤不换药,这里不再多讲。
JNIEnv 可以说是整个 JNI 的核心类,是 Java 和 C 通讯的桥梁,它可以协助我们将 JNI 类型转换成 C 类型,不仅如此,调用 Java 对象的方法,获取或者修改属性,都是由 JNIEnv 来做。
JNIEnv 是一个结构体的一级指针,与其他类型的对象不一样的地方是,类型后面带了星号,使用的时候不能通过对象点方法名来调用,而是只能通过对象->方法名来调用。
看完了普通 Java 方法调用 Native 方法,接下来看一下静态 Java 方法是如何调用 Native 方法的
- 通过仔细对比,和之前的那种方式其实都差不多,但是有一个地方不太一样
如果是 Java 层的 Native 方法是静态的,那么 Native 层中的方法第二个参数类型就是 jclass,这个 jclass 我们可以看做 Java 上面的 Class 类型。这种模式其实跟我们在 Java 方法体上面定义同步锁的差不多,如果被 synchronized 修饰的方法是非静态方法,那么同步锁的锁对象就是
类名.this
,如果被 synchronized 修饰的方法是静态方法,那么同步锁的锁对象就是类名.class
上面就是 Java 和 Native 方法之间的互相调用,接下来让我们简单看一下 Native 层是如何获取和修改 Java 对象的属性值
- 这些代码已经不用再讲了,我相信大部分人都懂
甜点
char* 和 jstring 互转
jstring string;
// jstring 转 char*
const char* cc = env->GetStringUTFChars(string, 0);
// char* 转 jstring
jstring ss = env->NewStringUTF(cc);
打印日志
加入头文件
#include <android/log.h>
打印 char*
const char* cc = "6666666";
__android_log_print(ANDROID_LOG_DEBUG, "TAG", cc, NULL);
日志等级
ANDROID_LOG_VERBOSE
ANDROID_LOG_DEBUG
ANDROID_LOG_INFO
ANDROID_LOG_WARN
ANDROID_LOG_ERROR