Android NDK开发入门

JNI 简介

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

总之,JNI是一门技术,是Java 与C/C++ 沟通的一门技术。首先,来回顾下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字节码格式,转化过程如下图。
在这里插入图片描述
如上所示,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,如下所示。
在这里插入图片描述
然后,点击【Finish】按钮即可。

NDK 项目目录

打开新建的NDK工程,目录如下图所示。
在这里插入图片描述
我们接下来看一下,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 
#include 

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的都应该知道,在JVM中运行一个Java程序时,会先将运行时需要用到的所有相关class文件加载到JVM中,并按需加载,提高性能和节约内存。当我们调用一个类的静态方法之前,JVM会先判断该类是否已经加载,如果没有被ClassLoader加载到JVM中,会去classpath路径下查找该类。找到了则加载该类,没有找到则报ClassNotFoundException异常。

6.1 Native调用Java静态方法

首先,我们编写一个MyJNIClass.java类,代码如下。

public class MyJNIClass {

    public int age = 30;

    public int getAge() {
        return age;
    }

    public void setAge(int age) {
        this.age = age;
    }

    public static String getDes(String text) {
        if (text == null) {
            text = "";
        }
        return "传入的字符串长度是 :" + text.length() + "  内容是 : " + text;
    }

}

然后,在native中调用getDes()方法,为了复杂一点,这个getDes()方法不仅有入参,还有返参,如下所示。

extern "C"
JNIEXPORT void JNICALL
Java_com_xzh_allinone_jni_CallMethodActivity_callJavaStaticMethod(JNIEnv *env, jobject thiz) {
    //调用某个类的static方法
    //1. 从classpath路径下搜索MyJNIClass这个类,并返回该类的Class对象
    jclass clazz = env->FindClass("com/xzh/jni/jni/MyJNIClass");
    //2. 从clazz类中查找getDes方法 得到这个静态方法的方法id
    jmethodID mid_get_des = env->GetStaticMethodID(clazz, "getDes", "(Ljava/lang/String;)Ljava/lang/String;");
    //3. 构建入参,调用static方法,获取返回值
    jstring str_arg = env->NewStringUTF("我是xzh");
    jstring result = (jstring) env->CallStaticObjectMethod(clazz, mid_get_des, str_arg);
    const char *result_str = env->GetStringUTFChars(result, NULL);
    LOGI("获取到Java层返回的数据 : %s", result_str);

    //4. 移除局部引用
    env->DeleteLocalRef(clazz);
    env->DeleteLocalRef(str_arg);
    env->DeleteLocalRef(result);
}

可以发现,Native调用Java静态方法还是比较简单的,主要会经历以下几个步骤。

  1. 首先,调用FindClass函数传入Class描述符(Java类的全类名,这里在AS中输入MyJNIClass时会有提示补全,直接enter即可补全),找到该类并得到jclass类型。
  2. 然后,通过GetStaticMethodID找到该方法的id,传入方法签名,得到jmethodID类型的引用。
  3. 构建入参,然后调用CallStaticObjectMethod去调用Java类里面的静态方法,然后传入参数,返回的直接就是Java层返回的数据。其实,这里的CallStaticObjectMethod是调用的引用类型的静态方法,与之相似的还有:CallStaticVoidMethod(无返参),CallStaticIntMethod(返参是Int),CallStaticFloatMethod等。
  4. 移除局部引用。

6.2 Native调用Java实例方法

接下来,我们来看一下在Native层创建Java实例并调用该实例的方法,大致上是和上面调用静态方法差不多的。首先,我们修改下cpp文件的代码,如下所示。

extern "C"
JNIEXPORT void JNICALL
Java_com_xzh_allinone_jni_CallMethodActivity_createAndCallJavaInstanceMethod(JNIEnv *env, jobject thiz) {
    
    jclass clazz = env->FindClass("com/xzh/allinone/jni/MyJNIClass");
    //获取构造方法的方法id
    jmethodID mid_construct = env->GetMethodID(clazz, "", "()V");
    //获取getAge方法的方法id
    jmethodID mid_get_age = env->GetMethodID(clazz, "getAge", "()I");
    jmethodID mid_set_age = env->GetMethodID(clazz, "setAge", "(I)V");
    jobject jobj = env->NewObject(clazz, mid_construct);

    //调用方法setAge
    env->CallVoidMethod(jobj, mid_set_age, 20);
    //再调用方法getAge 获取返回值 打印输出
    jint age = env->CallIntMethod(jobj, mid_get_age);
    LOGI("获取到 age = %d", age);

    //凡是使用是jobject的子类,都需要移除引用
    env->DeleteLocalRef(clazz);
    env->DeleteLocalRef(jobj);
}

如上所示,Native调用Java实例方法的步骤如下:

  1. Native调用Java实例方法。
  2. 获取构造方法的id,获取需要调用方法的id。其中获取构造方法时,方法名称固定写法就是,然后后面是方法签名。
  3. 使用NewObject()函数构建一个Java对象。
  4. 调用Java对象的setAge和getAge方法,获取返回值,打印结果。
  5. 删除引用。

NDK错误定位

由于NDK大部分的逻辑是在C/C++完成的,当NDK发生错误某种致命的错误的时候导致APP闪退。对于这类错误问题是非常不好排查的,比如内存地址访问错误、使用野指针、内存泄露、堆栈溢出等native错误都会导致APP崩溃。

虽然这些NDK错误不好排查,但是我们在NDK错误发生后也不是毫无办法可言。具体来说,当拿到Logcat输出的堆栈日志,再结合addr2line和ndk-stack两款调试工具,就可以很够精确地定位到相应发生错误的代码行数,进而迅速找到问题。

首先,我们打开ndk目录下下的sdk/ndk/21.0.6113669/toolchains/目录,可以看到NDK交叉编译器工具链的目录结构如下所示。
在这里插入图片描述
其中,ndk-stack放在$NDK_HOME目录下,与ndk-build同级目录。addr2line在ndk的交叉编译器工具链目录下。同时,NDK针对不同的CPU架构实现了多套工具,在使用addr2line工具时,需要根据当前手机cpu架构来选择。比如,我的手机是aarch64的,那么需要使用aarch64-linux-android-4.9目录下的工具。Android NDK提供了查看手机的CPU信息的命令,如下所示。

adb shell cat /proc/cpuinfo

在正式介绍两款调试工具之前,我们可以先写好崩溃的native代码方便我们查看效果。首先,我们修复native-lib.cpp里面的代码,如下所示。

void willCrash() {
    JNIEnv *env = NULL;
    int version = env->GetVersion();
}

extern "C"
JNIEXPORT void JNICALL
Java_com_xzh_allinone_jni_CallMethodActivity_nativeCrashTest(JNIEnv *env, jobject thiz) {
    LOGI("崩溃前");
    willCrash();
    //后面的代码是执行不到的,因为崩溃了
    LOGI("崩溃后");
    printf("oooo");
}

上面的这段代码是很明显的空指针异常,运行后错误日志如下。

2020-10-07 17:05:25.230 12340-12340/? A/DEBUG: *** *** *** *** *** *** *** *** *** *** *** *** *** *** *** ***
2020-10-07 17:05:25.230 12340-12340/? A/DEBUG: Build fingerprint: 'Xiaomi/dipper/dipper:10/QKQ1.190828.002/V11.0.8.0.QEACNXM:user/release-keys'
2020-10-07 17:05:25.230 12340-12340/? A/DEBUG: Revision: '0'
2020-10-07 17:05:25.230 12340-12340/? A/DEBUG: ABI: 'arm64'
2020-10-07 17:05:25.237 12340-12340/? A/DEBUG: Timestamp: 2020-06-07 17:05:25+0800
2020-10-07 17:05:25.237 12340-12340/? A/DEBUG: pid: 11527, tid: 11527, name: m.xfhy.allinone  >>> com.xfhy.allinone <<<
2020-10-07 17:05:25.237 12340-12340/? A/DEBUG: uid: 10319
2020-10-07 17:05:25.237 12340-12340/? A/DEBUG: signal 11 (SIGSEGV), code 1 (SEGV_MAPERR), fault addr 0x0
2020-10-07 17:05:25.237 12340-12340/? A/DEBUG: Cause: null pointer dereference
2020-10-07 17:05:25.237 12340-12340/? A/DEBUG:     x0  0000000000000000  x1  0000007fd29ffd40  x2  0000000000000005  x3  0000000000000003
2020-10-07 17:05:25.237 12340-12340/? A/DEBUG:     x4  0000000000000000  x5  8080800000000000  x6  fefeff6fb0ce1f1f  x7  7f7f7f7fffff7f7f
2020-10-07 17:05:25.237 12340-12340/? A/DEBUG:     x8  0000000000000000  x9  a95a4ec0adb574df  x10 0000007fd29ffee0  x11 000000000000000a
2020-10-07 17:05:25.237 12340-12340/? A/DEBUG:     x12 0000000000000018  x13 ffffffffffffffff  x14 0000000000000004  x15 ffffffffffffffff
2020-10-07 17:05:25.237 12340-12340/? A/DEBUG:     x16 0000006fc6476c50  x17 0000006fc64513cc  x18 00000070b21f6000  x19 000000702d069c00
2020-10-07 17:05:25.237 12340-12340/? A/DEBUG:     x20 0000000000000000  x21 000000702d069c00  x22 0000007fd2a00720  x23 0000006fc6ceb127
2020-10-07 17:05:25.237 12340-12340/? A/DEBUG:     x24 0000000000000004  x25 00000070b1cf2020  x26 000000702d069cb0  x27 0000000000000001
2020-10-07 17:05:25.237 12340-12340/? A/DEBUG:     x28 0000007fd2a004b0  x29 0000007fd2a00420
2020-10-07 17:05:25.237 12340-12340/? A/DEBUG:     sp  0000007fd2a00410  lr  0000006fc64513bc  pc  0000006fc64513e0
2020-10-07 17:05:25.788 12340-12340/? A/DEBUG: backtrace:
2020-10-07 17:05:25.788 12340-12340/? A/DEBUG:       #00 pc 00000000000113e0  /data/app/com.xfhy.allinone-4VScOmUWz8wLqqwBWZCP2w==/lib/arm64/libnative-lib.so (_JNIEnv::GetVersion()+20) (BuildId: b1130c28a8b45feda869397e55c5b6d754410c8d)
2020-10-07 17:05:25.788 12340-12340/? A/DEBUG:       #01 pc 00000000000113b8  /data/app/com.xfhy.allinone-4VScOmUWz8wLqqwBWZCP2w==/lib/arm64/libnative-lib.so (willCrash()+24) (BuildId: b1130c28a8b45feda869397e55c5b6d754410c8d)
2020-10-07 17:05:25.788 12340-12340/? A/DEBUG:       #02 pc 0000000000011450  /data/app/com.xfhy.allinone-4VScOmUWz8wLqqwBWZCP2w==/lib/arm64/libnative-lib.so (Java_com_xfhy_allinone_jni_CallMethodActivity_nativeCrashTest+84) (BuildId: b1130c28a8b45feda869397e55c5b6d754410c8d)
2020-10-07 17:05:25.788 12340-12340/? A/DEBUG:       #03 pc 000000000013f350  /apex/com.android.runtime/lib64/libart.so (art_quick_generic_jni_trampoline+144) (BuildId: 2bc2e11d57f839316bf2a42bbfdf943a)
2020-10-07 17:05:25.788 12340-12340/? A/DEBUG:       #04 pc 0000000000136334  /apex/com.android.runtime/lib64/libart.so (art_quick_invoke_stub+548) (BuildId: 2bc2e11d57f839316bf2a42bbfdf943a)

首先,找到关键信息Cause: null pointer dereference,但是我们不知道发生在具体哪里,所以接下来我们需要借助addr2line和ndk-stack两款工具来协助我们进行分析。

7.1 addr2line

现在,我们使用工具addr2line来定位位置。首先,执行如下命令。

/Users/xzh/development/sdk/ndk/21.0.6113669/toolchains/aarch64-linux-android-4.9/prebuilt/darwin-x86_64/bin/aarch64-linux-android-addr2line -e /Users/xzh/development/AllInOne/app/libnative-lib.so 00000000000113e0 00000000000113b8

作者:潇风寒月
链接:https://juejin.im/post/6844904190586650632
来源:掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。

其中-e是指定so文件的位置,然后末尾的00000000000113e0和00000000000113b8是出错位置的汇编指令地址。

/Users/xzh/development/sdk/ndk/21.0.6113669/toolchains/llvm/prebuilt/darwin-x86_64/sysroot/usr/include/jni.h:497
/Users/xzh/development/AllInOne/app/src/main/cpp/native-lib.cpp:260

可以看到,是native-lib.cpp的260行出的问题,我们只需要找到这个位置然后修复这个文件即可。

7.2 ndk-stack

除此之外,还有一种更简单的方式,直接输入命令。

adb logcat | ndk-stack -sym /Users/xzh/development/AllInOne/app/build/intermediates/cmake/debug/obj/arm64-v8a

末尾是so文件的位置,执行完命令后就可以在手机上产生native错误,然后就能在这个so文件中定位到这个错误点。

********** Crash dump: **********
Build fingerprint: 'Xiaomi/dipper/dipper:10/QKQ1.190828.002/V11.0.8.0.QEACNXM:user/release-keys'
#00 0x00000000000113e0 /data/app/com.xfhy.allinone-oVu0tjta-aW9LYa08eoK1Q==/lib/arm64/libnative-lib.so (_JNIEnv::GetVersion()+20) (BuildId: b1130c28a8b45feda869397e55c5b6d754410c8d)
                                                                                                        _JNIEnv::GetVersion()
                                                                                                        /Users/xzh/development/sdk/ndk/21.0.6113669/toolchains/llvm/prebuilt/darwin-x86_64/sysroot/usr/include/jni.h:497:14
#01 0x00000000000113b8 /data/app/com.xfhy.allinone-oVu0tjta-aW9LYa08eoK1Q==/lib/arm64/libnative-lib.so (willCrash()+24) (BuildId: b1130c28a8b45feda869397e55c5b6d754410c8d)
                                                                                                        willCrash()
                                                                                                        /Users/xzh/development/AllInOne/app/src/main/cpp/native-lib.cpp:260:24
#02 0x0000000000011450 /data/app/com.xfhy.allinone-oVu0tjta-aW9LYa08eoK1Q==/lib/arm64/libnative-lib.so (Java_com_xzh_allinone_jni_CallMethodActivity_nativeCrashTest+84) (BuildId: b1130c28a8b45feda869397e55c5b6d754410c8d)
                                                                                                        Java_com_xzh_allinone_jni_CallMethodActivity_nativeCrashTest
                                                                                                        /Users/xzh/development/AllInOne/app/src/main/cpp/native-lib.cpp:267:5

可以看到,上面的日志明确指出了是willCrash()方法出的错,它的代码行数是260行。

8,JNI引用

众所周知,Java在新创建对象的时候,不需要考虑JVM是怎么申请内存的,也不需要在使用完之后去释放内存。而C++不同,需要我们手动申请和释放内存(new->delete,malloc->free)。在使用JNI时,由于本地代码不能直接通过引用操作JVM内部的数据结构,要进行这些操作必须调用相应的JNI接口间接操作JVM内部的数据内容。我们不需要关心JVM中对象的是如何存储的,只需要学习JNI中的三种不同引用即可。

8.1 JNI 局部引用

通常,本地函数中通过NewLocalRef或调用FindClass、NewObject、GetObjectClass、NewCharArray等创建的引用,就是局部引用。局部引用具有如下一些特征:

  • 会阻止GC回收所引用的对象
  • 不能跨线程使用
  • 不在本地函数中跨函数使用
  • 释放: 函数返回后局部引用所引用的对象会被JVM自动释放,也可以调用DeleteLocalRef释放。

通常是在函数中创建并使用的就是局部引用, 局部引用在函数返回之后会自动释放。那么我们为啥还需要去手动调用DeleteLocalRef进行释放呢?

比如,开了一个for循环,里面不断地创建局部引用,那么这时就必须得使用DeleteLocalRef手动释放内存。不然局部引用会越来越多,最终导致崩溃(在Android低版本上局部引用表的最大数量有限制,是512个,超过则会崩溃)。

还有一种情况,本地方法返回一个引用到Java层之后,如果Java层没有对返回的局部引用使用的话,局部引用就会被JVM自动释放。

8.2 JNI 全局引用

全局引用是基于局部引用创建的,使用NewGlobalRef方法创建。全局引用具有如下一些特性:

  • 会阻止GC回收所引用的对象
  • 可以跨方法、跨线程使用
  • JVM不会自动释放,需调用DeleteGlobalRef手动释放

8.3 JNI 弱全局引用

弱全局引用是基于局部引用或者全局引用创建的,使用NewWeakGlobalRef方法创建。弱全局引用具有如下一些特性:

  • 不会阻止GC回收所引用的对象
  • 可以跨方法、跨线程使用
  • 引用不会自动释放,只有在JVM内存不足时才会进行回收而被释放.,还有就是可以调用DeleteWeakGlobalRef手动释放。

参考:
Android Developers NDK 指南 C++ 库支持
JNI/NDK开发指南
Android 内存泄露之jni local reference table overflow

你可能感兴趣的