Android NDK开发入门

Posted wx5aae83353cec4

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了Android NDK开发入门相关的知识,希望对你有一定的参考价值。


JNI 简介

JNI (Java Native Interface英文缩写),译为Java本地接口。是Java众多开发技术中的一门技术,意在利用本地代码,为Java程序提供更高效、更灵活的拓展。尽管Java一贯以其良好的跨平台性而著称,但真正的跨平台非C/C++莫属,因为当前世上90%的系统都是基于C/C++编写的。同时,Java的跨平台是以牺牲效率换来对多种平台的兼容性,因而JNI就是这种跨平台的主流实现方式之一。

总之,JNI是一门技术,是Java 与C/C++ 沟通的一门技术。首先,来回顾下android的系统架构图。

Android


我们来简单介绍下每一层的作用。

Linux层

Linux 内核

由于Android 系统是基础Linux 内核构建的,所以Linux是Android系统的基础。事实上,Android 的硬件驱动、进程管理、内存管理、网络管理都是在这一层。

硬件抽象层

硬件抽象层(Hardware Abstraction Layer缩写),硬件抽象层主要为上层提供标准显示界面,并向更高级别的 Java API 框架提供显示设备硬件功能。HAL 包含多个库模块,其中每个模块都为特定类型的硬件组件实现一个界面,例如相机或蓝牙模块。当框架 API 要求访问设备硬件时,Android 系统将为该硬件组件加载对应的库模块。

系统运行库和运行环境层

Android Runtime

Android 5.0(API 21)之前,使用的是Dalvik虚拟机,之后被ART所取代。ART是Android操作系统的运行环境,通过运行虚拟机来执行dex文件。其中,dex文件是专为安卓设计的的字节码格式,Android打包和运行的就是dex文件,而Android toolchain(一种编译工具)可以将Java代码编译为dex字节码格式,转化过程如下图。

Android


如上所示,Jack就是一种编译工具链,可以将Java 源代码编译为 DEX 字节码,使其可在 Android 平台上运行。

原生C/C++ 库

很多核心 Android 系统组件和服务都是使用C 和 C++ 编写的,为了方便开发者调用这些原生库功能,Android的Framework提供了调用相应的API。例如,您可以通过 Android 框架的 Java OpenGL API 访问 OpenGL ES,以支持在应用中绘制和操作 2D 和 3D 图形。

应用程序框架层

Android平台最常用的组件和服务都在这一层,是每个Android开发者必须熟悉和掌握的一层,是应用开发的基础。

Application层

Android系统App,如电子邮件、短信、日历、互联网浏览和联系人等系统应用。我们可以像调用Java API Framework层一样直接调用系统的App。

接下来我们看一下如何编写Android JNI ,以及需要的流程。

NDK

NDK是什么

NDK(Native Development Kit缩写)一种基于原生程序接口的软件开发工具包,可以让您在 Android 应用中利用 C 和 C++ 代码的工具。通过此工具开发的程序直接在本地运行,而不是虚拟机。

在Android中,NDK是一系列工具的集合,主要用于扩展Android SDK。NDK提供了一系列的工具可以帮助开发者快速的开发C或C++的动态库,并能自动将so和Java应用一起打包成apk。同时,NDK还集成了交叉编译器,并提供了相应的mk文件隔离CPU、平台、ABI等差异,开发人员只需要简单修改mk文件(指出“哪些文件需要编译”、“编译特性要求”等),就可以创建出so文件。

NDK配置

创建NDK工程之前,请先保证本地已经搭建好了NDK的相关环境。依次选择【Preferences…】->【Android SDK】下载配置NDK,如下所示。

Android


然后,新建一个Native C++工程,如下所示。

Android


然后勾选【Include C++ support】选项,点击【下一步】,到达【Customize C++ Support】设置页,如下所示。

Android


然后,点击【Finish】按钮即可。

NDK 项目目录

打开新建的NDK工程,目录如下图所示。

Android


我们接下来看一下,Android的NDK工程和普通的Android应用工程有哪些不一样的地方。首先,我们来看下build.gradle配置。

apply plugin: com.android.application

android
compileSdkVersion 30
buildToolsVersion "30.0.2"

defaultConfig
applicationId "com.xzh.ndk"
minSdkVersion 16
targetSdkVersion 30
versionCode 1
versionName "1.0"

testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
externalNativeBuild
cmake
cppFlags ""




buildTypes
release
minifyEnabled false
proguardFiles getDefaultProguardFile(proguard-android-optimize.txt), proguard-rules.pro


externalNativeBuild
cmake
path "src/main/cpp/CMakeLists.txt"
version "3.10.2"




dependencies
// 省略引用的第三方库

可以看到,相比普通的Android应用,build.gradle配置中多了两个externalNativeBuild配置项。其中,defaultConfig里面的的externalNativeBuild主要是用于配置Cmake的命令参数,而外部的
externalNativeBuild的主要是定义了CMake的构建脚本CMakeLists.txt的路径。

然后,我们来看一下CMakeLists.txt文件,CMakeLists.txt是CMake的构建脚本,作用相当于ndk-build中的Android.mk,代码如下。

# 设置Cmake最小版本
cmake_minimum_required(VERSION 3.4.1)

# 编译library
add_library( # 设置library名称
native-lib

# 设置library模式
# SHARED模式会编译so文件,STATIC模式不会编译
SHARED

# 设置原生代码路径
src/main/cpp/native-lib.cpp )

# 定位library
find_library( # library名称
log-lib

# 将library路径存储为一个变量,可以在其他地方用这个变量引用NDK库
# 在这里设置变量名称
log )

# 关联library
target_link_libraries( # 关联的library
native-lib

# 关联native-lib和log-lib
$log-lib )

关于CMake的更多知识,可以查看​​CMake官方手册​​。

官方示例

默认创建Android NDK工程时,Android提供了一个简单的JNI交互示例,返回一个字符串给Java层,方法名的格式为:​​Java_包名_类名_方法名​​ 。首先,我们看一下native-lib.cpp的代码。

#include <jni.h>
#include <string>

extern "C" JNIEXPORT jstring JNICALL
Java_com_xzh_ndk_MainActivity_stringFromJNI(
JNIEnv* env,
jobject /* this */)
std::string hello = "Hello from C++";
return env->NewStringUTF(hello.c_str());

然后,我们在看一下Android的MainActivity.java 的代码。

package com.xzh.ndk;

import androidx.appcompat.app.AppCompatActivity;

import android.os.Bundle;
import android.widget.TextView;

public class MainActivity extends AppCompatActivity

static
System.loadLibrary("native-lib");


@Override
protected void onCreate(Bundle savedInstanceState)
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
TextView tv = findViewById(R.id.sample_text);
tv.setText(stringFromJNI());


public native String stringFromJNI();

初识Android JNI

1,JNI开发流程

  1. 编写java类,声明了native方法;
  2. 编写native代码;
  3. 将native代码编译成so文件;
  4. 在java类中引入so库,调用native方法;

2,native方法命名

extern "C"
JNIEXPORT void JNICALL
Java_com_xfhy_jnifirst_MainActivity_callJavaMethod(JNIEnv *env, jobject thiz)

函数命名规则: ​​Java_类全路径_方法名​​,涉及的参数的含义如下:

  • JNIEnv*是定义任意native函数的第一个参数,表示指向JNI环境的指针,可以通过它来访问JNI提供的接口方法。
  • jobject表示Java对象中的this,如果是静态方法则表示jclass。
  • JNIEXPORT和JNICALL: 它们是JNI中所定义的宏,可以在jni.h这个头文件中查找到。

3,JNI数据类型与Java数据类型的对应关系

首先,我们在Java代码里编写一个native方法声明,然后使用【alt+enter】快捷键让AS帮助我们创建一个native方法,如下所示。

public static native void ginsengTest(short s, int i, long l, float f, double d, char c,
boolean z, byte b, String str, Object obj, MyClass p, int[] arr);


//对应的Native代码
Java_com_xfhy_jnifirst_MainActivity_ginsengTest(JNIEnv *env, jclass clazz, jshort s, jint i, jlong l, jfloat f, jdouble d, jchar c,
jboolean z, jbyte b, jstring str, jobject obj, jobject p, jintArray arr)

下面,我们整理下Java和JNI的类型对照表,如下所示。

Java 类型

Native类型

有无符合

字长

boolean

jboolean

无符号

8字节

byte

jbyte

有符号

8字节

char

jchar

无符号

16字节

short

jshort

有符号

16字节

int

jint

有符号

32字节

long

jlong

有符号

64字节

float

jfloat

有符号

32字节

double

jdouble

有符号

64字节

对应的引用类型如下表所示。

Java 类型

Native类型

java.lang.Class

jclass

java.lang.Throwable

jthrowable

java.lang.String

jstring

jjava.lang.Object[]

jobjectArray

Byte[]

jbyteArray

Char[]

jcharArray

Short[]

jshortArray

int[]

jintArray

long[]

jlongArray

float[]

jfloatArray

double[]

jdoubleArray

3.1基本数据类型

Native的基本数据类型其实就是将C/C++中的基本类型用typedef重新定义了一个新的名字,在JNI中可以直接访问,如下所示。

typedef uint8_t  jboolean; /* unsigned 8 bits */
typedef int8_t jbyte; /* signed 8 bits */
typedef uint16_t jchar; /* unsigned 16 bits */
typedef int16_t jshort; /* signed 16 bits */
typedef int32_t jint; /* signed 32 bits */
typedef int64_t jlong; /* signed 64 bits */
typedef float jfloat; /* 32-bit IEEE 754 */
typedef double jdouble; /* 64-bit IEEE 754 */

3.2 引用数据类型

如果使用C++语言编写,则所有引用派生自jobject根类,如下所示。

class _jobject ;
class _jclass : public _jobject ;
class _jstring : public _jobject ;
class _jarray : public _jobject ;
class _jobjectArray : public _jarray ;
class _jbooleanArray : public _jarray ;
class _jbyteArray : public _jarray ;
class _jcharArray : public _jarray ;
class _jshortArray : public _jarray ;
class _jintArray : public _jarray ;
class _jlongArray : public _jarray ;
class _jfloatArray : public _jarray ;
class _jdoubleArray : public _jarray ;
class _jthrowable : public _jobject ;

JNI使用C语言时,所有引用类型都使用jobject。

4,JNI的字符串处理

4.1 native操作JVM

JNI会把Java中所有对象当做一个C指针传递到本地方法中,这个指针指向JVM内部数据结构,而内部的数据结构在内存中的存储方式是不可见的.只能从JNIEnv指针指向的函数表中选择合适的JNI函数来操作JVM中的数据结构。

比如native访问java.lang.String 对应的JNI类型jstring时,不能像访问基本数据类型那样使用,因为它是一个Java的引用类型,所以在本地代码中只能通过类似GetStringUTFChars这样的JNI函数来访问字符串的内容。

4.2 字符串操作的示例

//调用
String result = operateString("待操作的字符串");
Log.d("xfhy", result);

//定义
public native String operateString(String str);

然后在C中进行实现,代码如下。

extern "C"
JNIEXPORT jstring JNICALL
Java_com_xfhy_jnifirst_MainActivity_operateString(JNIEnv *env, jobject thiz, jstring str)
//从java的内存中把字符串拷贝出来 在native使用
const char *strFromJava = (char *) env->GetStringUTFChars(str, NULL);
if (strFromJava == NULL)
//必须空检查
return NULL;


//将strFromJava拷贝到buff中,待会儿好拿去生成字符串
char buff[128] = 0;
strcpy(buff, strFromJava);
strcat(buff, " 在字符串后面加点东西");

//释放资源
env->ReleaseStringUTFChars(str, strFromJava);

//自动转为Unicode
return env->NewStringUTF(buff);

4.2.1 native中获取JVM字符串

在上面的代码中,operateString函数接收一个jstring类型的参数str,jstring是指向JVM内部的一个字符串,不能直接使用。首先,需要将jstring转为C风格的字符串类型char*后才能使用,这里必须使用合适的JNI函数来访问JVM内部的字符串数据结构。

GetStringUTFChars(jstring string, jboolean* isCopy)对应的参数的含义如下:

  • string : jstring,Java传递给native代码的字符串指针。
  • isCopy : 一般情况下传NULL,取值可以是JNI_TRUE和JNI_FALSE,如果是JNI_TRUE则会返回JVM内部源字符串的一份拷贝,并为新产生的字符串分配内存空间。如果是JNI_FALSE则返回JVM内部源字符串的指针,意味着可以在native层修改源字符串,但是不推荐修改,因为Java字符串的原则是不能修改的。

Java中默认是使用Unicode编码,C/C++默认使用UTF编码,所以在native层与java层进行字符串交流的时候需要进行编码转换。GetStringUTFChars就刚好可以把jstring指针(指向JVM内部的Unicode字符序列)的字符串转换成一个UTF-8格式的C字符串。

4.2.2 异常处理

在使用GetStringUTFChars的时候,返回的值可能为NULL,这时需要处理一下,否则继续往下面走的话,使用这个字符串的时候会出现问题.因为调用这个方法时,是拷贝,JVM为新生成的字符串分配内存空间,当内存空间不够分配的时候就会导致调用失败。调用失败就会返回NULL,并抛出OutOfMemoryError。JNI遇到未决的异常不会改变程序的运行流程,还是会继续往下走。

4.2.3 释放字符串资源

native不像Java,我们需要手动释放申请的内存空间。GetStringUTFChars调用时会新申请一块空间用来装拷贝出来的字符串,这个字符串用来方便native代码访问和修改之类的。既然有内存分配,那么就必须手动释放,释放方法是ReleaseStringUTFChars。可以看到和GetStringUTFChars是一一对应配对的。

4.2.4 构建字符串

使用NewStringUTF函数可以构建出一个jstring,需要传入一个char *类型的C字符串。它会构建一个新的java.lang.String字符串对象,并且会自动转换成Unicode编码。如果JVM不能为构造java.lang.String分配足够的内存,则会抛出一个OutOfMemoryError异常并返回NULL。

4.2.5 其他字符串操作函数

  1. GetStringChars和ReleaseStringChars:这对函数和Get/ReleaseStringUTFChars函数功能类似,用于获取和释放的字符串是以Unicode格式编码的。
  2. GetStringLength:获取Unicode字符串(jstring)的长度。 UTF-8编码的字符串是以\\0结尾,而Unicode的不是,所以这里需要单独区分开。
  3. 「GetStringUTFLength」: 获取UTF-8编码字符串的长度,就是获取C/C++默认编码字符串的长度.还可以使用标准C函数「strlen」来获取其长度。
  4. strcat: 拼接字符串,标准C函数。如​​strcat(buff, "xfhy");​​ 将xfhy添加到buff的末尾。
  5. GetStringCritical和ReleaseStringCritical: 为了增加直接传回指向Java字符串的指针的可能性(而不是拷贝).在这2个函数之间的区域,是绝对不能调用其他JNI函数或者让线程阻塞的native函数.否则JVM可能死锁. 如果有一个字符串的内容特别大,比如1M,且只需要读取里面的内容打印出来,此时比较适合用该对函数,可直接返回源字符串的指针。
  6. GetStringRegion和GetStringUTFRegion: 获取Unicode和UTF-8字符串中指定范围的内容(如: 只需要1-3索引处的字符串),这对函数会将源字符串复制到一个预先分配的缓冲区(自己定义的char数组)内。

通常,GetStringUTFRegion会进行越界检查,越界会抛StringIndexOutOfBoundsException异常。GetStringUTFRegion其实和GetStringUTFChars有点相似,但是GetStringUTFRegion内部不会分配内存,不会抛出内存溢出异常。由于其内部没有分配内存,所以也没有类似Release这样的函数来释放资源。

4.2.6 小结

  • Java字符串转C/C++字符串: 使用GetStringUTFChars函数,必须调用ReleaseStringUTFChars释放内存。
  • 创建Java层需要的Unicode字符串,使用NewStringUTF函数。
  • 获取C/C++字符串长度,使用GetStringUTFLength或者strlen函数。
  • 对于小字符串,GetStringRegion和GetStringUTFRegion这2个函数是最佳选择,因为缓冲区数组可以被编译器提取分配,不会产生内存溢出的异常。当只需要处理字符串的部分数据时,也还是不错。它们提供了开始索引和子字符串长度值,复制的消耗也是非常小
  • 获取Unicode字符串和长度,使用GetStringChars和GetStringLength函数。

数组操作

5.1 基本类型数组

基本类型数组就是JNI中的基本数据类型组成的数组,可以直接访问。例如,下面是int数组求和的例子,代码如下。

//MainActivity.java
public native int sumArray(int[] array);
extern "C"
JNIEXPORT jint JNICALL
Java_com_xfhy_jnifirst_MainActivity_sumArray(JNIEnv *env, jobject thiz, jintArray array)
//数组求和
int result = 0;

//方式1 推荐使用
jint arr_len = env->GetArrayLength(array);
//动态申请数组
jint *c_array = (jint *) malloc(arr_len * sizeof(jint));
//初始化数组元素内容为0
memset(c_array, 0, sizeof(jint) * arr_len);
//将java数组的[0-arr_len)位置的元素拷贝到c_array数组中
env->GetIntArrayRegion(array, 0, arr_len, c_array);
for (int i = 0; i < arr_len; ++i)
result += c_array[i];

//动态申请的内存 必须释放
free(c_array);

return result;

C层拿到jintArray之后首先需要获取它的长度,然后动态申请一个数组(因为Java层传递过来的数组长度是不定的,所以这里需要动态申请C层数组),这个数组的元素是jint类型的。malloc是一个经常使用的拿来申请一块连续内存的函数,申请之后的内存是需要手动调用free释放的。然后就是调用GetIntArrayRegion函数将Java层数组拷贝到C层数组中并进行求和。

接下来,我们来看另一种求和方式,代码如下。

extern "C"
JNIEXPORT jint JNICALL
Java_com_xfhy_jnifirst_MainActivity_sumArray(JNIEnv *env, jobject thiz, jintArray array)
//数组求和
int result = 0;

//方式2
//此种方式比较危险,GetIntArrayElements会直接获取数组元素指针,是可以直接对该数组元素进行修改的.
jint *c_arr = env->GetIntArrayElements(array, NULL);
if (c_arr == NULL)
return 0;

c_arr[0] = 15;
jint len = env->GetArrayLength(array);
for (int i = 0; i < len; ++i)
//result += *(c_arr + i); 写成这种形式,或者下面一行那种都行
result += c_arr[i];

//有Get,一般就有Release
env->ReleaseIntArrayElements(array, c_arr, 0);

return result;

在上面的代码中,我们直接通过GetIntArrayElements函数拿到原数组元素指针,直接操作就可以拿到元素求和。看起来要简单很多,但是这种方式我个人觉得是有点危险,毕竟这种可以在C层直接进行源数组修改不是很保险的。GetIntArrayElements的第二个参数一般传NULL,传递JNI_TRUE是返回临时缓冲区数组指针(即拷贝一个副本),传递JNI_FALSE则是返回原始数组指针。

5.2 对象数组

对象数组中的元素是一个类的实例或其他数组的引用,不能直接访问Java传递给JNI层的数组。操作对象数组稍显复杂,下面举一个例子:在native层创建一个二维数组,且赋值并返回给Java层使用。

public native int[][] init2DArray(int size);

//交给native层创建->Java打印输出
int[][] init2DArray = init2DArray(3);
for (int i = 0; i < 3; i++)
for (int i1 = 0; i1 < 3; i1++)
Log.d("xfhy", "init2DArray[" + i + "][" + i1 + "]" + " = " + init2DArray[i][i1]);

extern "C"
JNIEXPORT jobjectArray JNICALL
Java_com_xzh_jnifirst_MainActivity_init2DArray(JNIEnv *env, jobject thiz, jint size)
//创建一个size*size大小的二维数组

//jobjectArray是用来装对象数组的 Java数组就是一个对象 int[]
jclass classIntArray = env->FindClass("[I");
if (classIntArray == NULL)
return NULL;

//创建一个数组对象,元素为classIntArray
jobjectArray result = env->NewObjectArray(size, classIntArray, NULL);
if (result == NULL)
return NULL;

for (int i = 0; i < size; ++i)
jint buff[100];
//创建第二维的数组 是第一维数组的一个元素
jintArray intArr = env->NewIntArray(size);
if (intArr == NULL)
return NULL;

for (int j = 0; j < size; ++j)
//这里随便设置一个值
buff[j] = 666;

//给一个jintArray设置数据
env->SetIntArrayRegion(intArr, 0, size, buff);
//给一个jobjectArray设置数据 第i索引,数据位intArr
env->SetObjectArrayElement(result, i, intArr);
//及时移除引用
env->DeleteLocalRef(intArr);


return result;

接下来,我们来分析下代码。

  1. 首先,是利用FindClass函数找到java层int[]对象的class,这个class是需要传入NewObjectArray创建对象数组的。调用NewObjectArray函数之后,即可创建一个对象数组,大小是size,元素类型是前面获取到的class。
  2. 进入for循环构建size个int数组,构建int数组需要使用NewIntArray函数。可以看到我构建了一个临时的buff数组,然后大小是随便设置的,这里是为了示例,其实可以用malloc动态申请空间,免得申请100个空间,可能太大或者太小了。整buff数组主要是拿来给生成出来的jintArray赋值的,因为jintArray是Java的数据结构,咱native不能直接操作,得调用SetIntArrayRegion函数,将buff数组的值复制到jintArray数组中。
  3. 然后调用SetObjectArrayElement函数设置jobjectArray数组中某个索引处的数据,这里将生成的jintArray设置进去。
  4. 最后需要将for里面生成的jintArray及时移除引用。创建的jintArray是一个JNI局部引用,如果局部引用太多的话,会造成JNI引用表溢出。

6,Native调Java方法

熟悉JVM的都应该知道,在

以上是关于Android NDK开发入门的主要内容,如果未能解决你的问题,请参考以下文章

Android NDK 入门开发例子

Android NDK开发入门基础

Android NDK开发入门基础

Android NDK 从入门到精通

android -------- NDK 入门指南

NDK开发 从入门到放弃(七:Android Studio 2.2 CMAKE 高效NDK开发)