【Android】NDK的基本使用,这一篇就够了

JNI

概念

JNI是Java Native Interface的简写,它可以使Java与其他语言(如C、C++)进行交互。

它是Java调用Native语言的一种特性,属于Java语言的范畴,与Android无关。

为何需要JNI

  • Java的源文件非常容易被反编译,而通过Native语言生成的.so库文件则不容易被反编译。
  • 有时候会有一种情况就是当我们使用Java时需要使用到一些库来实现某些功能,但又由于这些库仅仅提供了一些Native语言的接口。
  • 使用Native语言编写的代码运行效率高,尤其体现在音频视频图片的处理等需要大量复杂运算的操作上。充分利用了硬件的性能。

由于上述原因,此时我们就需要让Java与Native语言交互。而由于Java的特点,与Native语言的交互能力很弱。因此在此时,我们就需要用到JNI特性增强Java与Native方法的交互能力。

实现的步骤

  1. 在Java中声明Native方法(需要调用的本地方法)
  2. 通过 javac 编译 Java源文件( 生成.class文件)
  3. 通过 javah 命令生成JNI头文件(生成.h文件)
  4. 通过Native语言实现在Java源码中声明的Native方法
  5. 编译成.so库文件
  6. 通过Java命令执行 Java程序,最终实现Java调用本地代码(借助so库文件)

NDK

概念

Native是Native Development Kit的简写,是Android的开发工具包,属于Android,与Java无关系。

它可以快速开发C/C++的动态库,自动将.so和应用一起打包为APK。因此我们可以通过NDK来在Android开发中通过JNI与Native方法交互。

使用方式

  1. 配置 Android NDK环境(在SDK Manager中下载NDK、CMake、LLDB)
  2. 创建 Android 项目,与 NDK进行关联(创建项目时选择C++ support)
  3. 在 Android 项目中声明所需要调用的 Native方法
  4. 用Native语言实现在Android中声明的Native方法
  5. 通过 ndk-bulid 命令编译产生.so库文件

将Android项目与NDK关联

配置NDK路径

local.properties中加入如下一行即可

ndk.dir=<ndk路径>

添加配置

在Gradle的 gradle.properties中加入如下一行,目的是对旧版本的NDK支持

android.useDeprecatedNdk=true

添加ndk节点

在build.gradle中的defaultConfigandroid中加入如下的externalNativeBuild节点

开发Native代码

在Java文件中声明native方法

我们首先需要在Java代码的类中通过static块来加载我们的Native库。可以通过如下代码,其中loadLibrary的参数是在CMakeList.txt中定义的Native库的名称

之后,我们便可以在这个类中声明Native方法

创建CMakeList.txt

我们还需要在src中创建一个CMakeList.txt文件,这个文件约束了Native语言源文件的编译规则。比如下面

add_library方法中定义了一个so库,它的名称是native-lib,也就是我们在Java文件中用到的字符串,而后面则跟着这个库对应的Native文件的路径

find_library则是定义了一个路径变量,经过了这个方法,log-lib这个变量中的值就是Android中log库的路径

target_link_libraries则是将native-lib这个库和log库连接了起来,这样我们就能在native-lib中使用log库的方法。

创建Native方法文件

在前面的CMake文件中可以看到,我们把文件放在了src/main/cpp/,因此我们创建cpp这个目录,在里面创建C++源文件native-lib.cpp。

然后, 我们便可以开始编写如下的代码:

此处我们使用的是C++语言,让我们来看看具体的代码。

首先我们引入了jni需要的jni.h,这个头文件中声明了各个jni需要用到的函数。同时我们引入了C++中的string.h。

然后我们看到extern “C”。为了了解这里为什么使用了extern “C”,我们首先需要知道下面的知识:

在C中,编译时的函数签名仅仅是包含了函数的名称,因此不同参数的函数都是同样的签名。这也就是为什么C不支持重载。

而C++为了支持重载,在编译的时候函数的签名除了包含函数的名称,还携带了函数的参数及返回类型等等。

试想此时我们有个C的函数库要给C++调用,会因为签名的不同而找不到对应的函数。因此,我们需要使用extern "C"来告诉编译器使用编译C的方式来连接。

接下来我们看看JNIEXPORT和JNICALL关键字,这两个关键字是两个宏定义,他主要的作用就是说明该函数为JNI函数。

而jstring则对应了Java中的String类,JNI中有很多类似jstring的类来对应Java中的类,下面是Java中的类与JNI类型的对照表

img

我们继续看到函数名Java_com_n0texpecterr0r_ndkdemo_MainActivity_getStringFromJNI。其实函数名中的_相当于Java中的 . 也就是这个函数名代表了java.com.n0texpecterr0r.ndkdemo.MainActivity.java中的getStringFromJNI方法,也就是我们之前定义的native方法。

格式大概如下:

Java_包名_类名_需要调用的方法名

其中,Java必须大写,包名里的.要改成__要改成_1

接下来我们看到这个函数的两个参数:

  • JNIEnv* env:代表了JVM的环境,Native方法可以通过这个指针来调用Java代码
  • jobject obj:它就相当于定义了这个JNI方法的类 (MainActivity) 的this引用

然后可以看到后面我们创建了一个string hello,之后通过env->NewStringUTF(hello.c_str())方法创建了一个jstring类型的变量并返回。

在Java代码中调用native方法

接着,我们便可以在MainActivty中像调用Java方法一样调用这个native方法

我们尝试运行,可以看到,我们成功用C++构建了一个字符串并返回给Java调用:

CMake

我们在NDK开发中使用CMake的语法来编写简单的代码描述编译的过程,由于这篇文章是讲NDK的,所以关于CMake的语法就不再赘述了。。。如果想要了解CMake语法可以学习这本书《CMake Practice

JNI与Java代码交互

方法签名

概念

在我们JNI层调用一个方法时,需要传递一个参数——方法签名。

为什么要使用方法签名呢?因为在Java中的方法是可以重载的,两个方法可能名称相同而参数不同。为了区分调用的方法,就引入了方法签名的概念。

签名规则

对于基本类型的参数,每个类型对应了一个不同的字母:

  • boolean Z
  • byte B
  • char C
  • short S
  • int I
  • long J
  • float F
  • double D
  • void V

对于类,则使用 L+类名 的方式,其中(.)用(/)代替,最后加上分号

比如 java.lang.String就是 Ljava/lang/String;

对于数组,则在前面加 [ ,然后加类型的签名,几维数组就加几个。

比如 int[]对应的就是[Iboolean[][]对应的则是[[Z,而java.lang.String[]就是[Ljava/lang/String;

打印方法签名

我们可以通过 javap -s 命令来打印方法的签名。

例子

比如下面的方法

对应的方法签名分别为:

可以看到,前面括号中表示的是方法的参数列表,后面表示的则是返回值。

调用Java方法

调用静态方法

调用思路

在Java中,我们调用一个方法的思路一般是先要找到对应的类,然后找到类中对应的方法,之后进行方法的调用。

在JNI中,其实也是一致的:

  1. 我们先需要同JVM环境的函数找到对应的jclass,对应Java中的类。
  2. 拿到了对应的类之后,我们可以通过JVM环境中的函数拿到需要调用的方法的jmethodID类型的变量。
  3. 获取到jmethodID之后,我们就可以调用JVM环境中的对应函数调用对应方法来进行函数的调用。
  4. 调用完成后,释放相关资源即可。
具体过程

假设我们在MainActivity定义了如下两个方法:

然后我们为native方法生成了如下的C++代码:

我们首先通过env的FindClass方法找到对应的类:

可以看到,FindClass需要的参数是这个类的全路径。获取到的jclass我们最好判下空

接下来我们需要拿到我们要调用的方法对应的jmethodID

由于logMessage方法是static的,因此我们调用了env的GetStaticMethodID方法,需要传入的参数分别是 jclass 方法名 以及方法对应的签名。

仍然判下空,之后我们可以通过env->CallXXXXXMethod方法来调用对应方法。

由于我们的logMessage方法是static,返回值为void的方法,因此我们使用CallStaticVoidMethod方法即可

调用完成后,我们可以通过env的DeleteLocalRef方法来释放刚刚声明的变量。

这样,在MainActivity中调用callStaticMethod方法后,便会打印这样一条log

D/NDK: 这是从JNI调用的Log

完整代码如下:

有了之前的jclass之后,我们还可以通过获取jfieldID,之后调用env的SetStaticObjectField方法来修改static变量。与前面类似。

调用实例方法

假设我们有了下面这样一个类

调用思路

调用实例方法相比调用静态方法会复杂一些

  1. 找到对应的jclass
  2. 调用其构造方法
  3. 创建其对象
  4. 调用其方法
具体过程

可以看到,在获取构造函数的id时,指定的方法名为<init>

对象引用的处理

为什么要处理对象的引用

当我们在Java中使用new创建了一个对象后,可以随意使用这个对象,不需要关注它什么时候被回收,因为这个对象的回收托管给了GC。但是当使用JNI传递给Native层的对象时该如何处理呢?

将对象传递给Native语言后,Native层会持有Java对象,如果我们不妥善处理会导致内存泄漏。 因此在Native层使用Java对象时,需要释放这个引用。

引用的处理

下面可以看看对于数组引用的处理

array对象传递给C++, C++中的变量将持有array这个引用,因为数组和对象在java中都是引用,都会在堆内存中开辟一块空间 , 但我们使用完对象之后需要将引用释放掉,不然会导致内存泄漏 。

释放数组元素时最后一个参数可以传入两个值:

  • JNI_ABORT, Java数组不进行更新,但是释放C/C++数组
  • JNI_COMMIT,Java数组进行更新,不释放C/C++数组(函数执行完,数组还是会释放)

其实,只要是Java对象,在Native层中都需要释放(包括在Native层创建的对象引用)。

引用分级

在Java中引用有强弱之分,在C/C++中也不例外,C/C++中也有一套全局引用局部引用弱全局引用等等 。

局部引用

局部引用指的是C/C++使用到或自行创建的Java对象,需要告知虚拟机在合适的时候回收对象。(通过DeleteLocalRef手动释放对象)

全局引用

全局引用的特点是共享(可以跨多个线程),手动控制内存使用

而弱全局引用的特点是节省内存,在内存不足时可以释放所引用的对象,可以用来引用一个不常用的对象,如果为NULL,临时创建。

创建:NewWeakGlobalRef

销毁:DeleteWeakGlobalRef

异常的处理

异常的例子

下面我们先展示一个出现错误的例子:

首先,我们在MainActivity中做出了如下改变:

然后编写如下的Native方法:

运行程序,发现程序crash了。我们查看logcat:

/com.n0texpecterr0r.ndkdemo D/NDKException: flag1

可以看到虽然抛出异常但是下面的代码仍然可以执行。可是当我们尝试使用fid时,就导致了程序crash。

我们看看logcat中的错误信息:

可以看到,这里抛出了NoSuchFieldError异常,信息为” no “Ljava/lang/String;” field “key1” in class “Lcom/n0texpecterr0r/ndkdemo/MainActivity;” or its superclasses”,提示我们MainActivity及其父类中没有key1这个filed

Native层在出现错误时会抛出Error类型的异常,可以通过Throwable或者Error来捕获,捕获异常后Java代码可以继续执行。

补救措施

为了确保Java、Native代码可以正常执行下去,我们需要:

  1. 在Native层手动清空异常信息(ExceptionClear),保证代码可以运行。
  2. 实行补救措施保证C/C++代码继续运行。

比如下面的方式:

运行程序可以发现,我们成功地补救了这个问题,Java代码成功执行了。

手动抛出异常

我们其实也可以通过ThrowNew方法来手动抛出异常。如下:

可以看到,我们成功抛出了一个IllegalArgumentException。之后就可以在Java中捕获这个异常:

查看Logcat可以看到我们所捕获的异常:

E/Exception: key value is invalid!

NDK使用Java中的Bitmap对象

简述

Bitmap是Android开发中非常常用的图片操作类,它包含了图像的宽、高、格式、以及每个像素点的信息。NDK提供了bitmap.h来给我们使用Android中的Bitmap类。

bitmap.h

NDK为我们提供了Bitmap的头文件,也就是<android/bitmap.h>。我们可以先看看这个头文件中包含了哪些信息:

AndroidBitmapInfo

AndroidBitmapInfo是一个包含了Bitmap常用的信息的类,它的定义如下:

AndroidBitmapFormat

AndroidBitmapFormat是一个枚举,表示了Bitmap的图片格式,与Java端一一对应。它的定义如下:

接口返回码

这个地方定义了对Bitmap进行操作的结果,分别对应成功,错误参数,JNI异常,内存分配错误。可能大家会发现最后还有一个异常,其实是因为Google的程序员在写代码的时候把Result给写错了导致的。

Bitmap操作函数声明

这里声明了对Bitmap操作的函数,它们分别是

  • AndroidBitmap_getInfo:获取当前位图信息。
  • AndroidBitmap_lockPixels:锁定当前位图像素,在锁定期间该Bitmap对象不会被回收,使用完成之后必须调用AndroidBitmap_unlockPixels函数来解除对像素的锁定。
  • AndroidBitmap_unlockPixels:解除像素锁定。

编译问题

我们需要注意的是,如果我们要使用Bitmap,需要链接jnigraphics库,否则可能出现下面的错误

E:\test\NdkBitmap\app\src\main\cpp/native-lib.cpp:52: undefined reference to AndroidBitmap_getInfo
E:\test\NdkBitmap\app\src\main\cpp/native-lib.cpp:53: undefined reference to AndroidBitmap_getInfo
E:\test\NdkBitmap\app\src\main\cpp/native-lib.cpp:59: undefined reference to AndroidBitmap_lockPixels
E:\test\NdkBitmap\app\src\main\cpp/native-lib.cpp:64: undefined reference to AndroidBitmap_lockPixels
E:\test\NdkBitmap\app\src\main\cpp/native-lib.cpp:86: undefined reference to AndroidBitmap_unlockPixels
E:\test\NdkBitmap\app\src\main\cpp/native-lib.cpp:87: undefined reference to AndroidBitmap_unlockPixels

因此我们需要在cmake中添加-ljnigraphics

动态注册

其实,我们之前所采用的注册Java中的native方法的方式叫做静态注册,也就是通过Java_<包名>_<类名>_<方法名>这样的函数名来使得该native方法可以被找到。这样的函数名非常长,不便于管理,如果我们使用动态注册,就可以不受这种命名的限制。

同时我们需要清楚,Java类是通过VM来调用Native方法,调用时需要通过VM在so库中寻找Native函数,如果该函数需频繁调用,会有大量的时间消耗。因此我们可以通过动态注册,在JNI_Onload函数中把native函数注册到VM中,减少寻找花费的时间。

JNI_Onload

在介绍动态注册之前

我们在调用System.loadLibrary()方法时,会自动在该库中查找并调用一个叫JNI_Onload的函数。它的函数原型如下:

由于这个函数是在JNI被加载时调用,所以它有如下的几个用途:

  • 在JNI_Onload通知VM当前so库使用的JNI版本(默认会返回最老的版本1.1)
  • 在JNI_Onload中进行数据的初始化
  • 在JNI_Onload中对Java类中的Native方法进行动态注册。

Android系统加载JNI依赖库的方式有下面的两种:

  1. 如果JNI_Lib定义了JNI_Onload函数,则通过JNI_Onload函数
  2. 如果JNI_Lib没有定义JNI_Onload函数,则dvm调用dvmResolveNativeMethod进行动态解析。

动态注册的实现

我们可以通过在JNI_Onload中调用registerNativeMethods方法来进行Native方法的动态注册。

比如假设我们有如下的一个静态注册的Native文件

我们可以将其改写为如下的方式,即可完成方法的动态注册

可以看到,在上述方法中,我们创建了一个方法对应表,将Java中的Native方法与Native层中的函数一一对应。

其中,JNINativeMethod是一个JNI中定义的结构体

其中,name代表了Java中的函数的名字,而signature代表了这个函数的函数签名,fnPtr则是一个函数指针,指向一个C函数,也就是native方法对应的函数。

.so文件

我们都知道,在项目中使用NDK时,它会生成so文件供我们的Java代码调用。我们使用某些Java语言编写的库时,它可能内部也用到了.so文件。由此可见so文件对我们Android开发者十分重要,那么它到底是什么呢?

ABI

目前Android系统支持了七种不同的CPU架构:ARMv5、ARMv7、x86、MIPS、ARMv8、MIPS64、x86_64。而这些CPU架构每个都关联了一个ABI。

ABI,即应用程序二进制接口(Application Binary Interface)。它里面定义了二进制文件(如.so文件)如何在相应平台上运行。从使用的指令集、内存对齐到可用系统函数库都有涉及。每个CPU架构对应了一个API:armeabi,armeabi-v7a,x86,mips,arm64-v8a,mips64,x86_64

什么是.so文件

so是shared object的缩写,也就是共享对象的意思。它是一种二进制文件,里面存放的是机器可以直接运行的二进制代码。正是因为如此,所以反编译so库的难度会比反编译普通Java代码的难度更大。so主要应用在Unix和Linux操作系统中,大到操作系统,小到一个专用的软件,都离不开so。其实Windows中有和so类似的东西,也就是我们常见的dll(动态链接库),其实它们是相同的事物,只是名字不同而已。

使用so的好处

  • 让开发者最大化利用已有的C和C++代码,达到重用的效果,利用软件世界积累了几十年的优秀代码
  • 二进制,没有解释编译的开销,实现同样的功能比纯粹Java实现要快
  • 内存不受VM单个应用限制,可以减少OOM。

需要注意的问题

虽然很多设备都支持多于一种的ABI(比如ARM64和x86设备也可以同时运行armeabi-v7a和armeabi的二进制包),但这种往往是要经过一种模拟层,(如x86设备模拟arm的模拟层),导致性能有所损耗。因此我们最好是针对特定的平台提供特定的so文件,从而避开模拟层,得到更好的性能。

评 论 区

  1. 还没有任何评论,你来说两句吧

发表评论

%d 博主赞过: