速來!Android極簡入門開發之NDK

JNI 簡介

JNI (Java Native Interface英文縮寫),譯為Java本地接口。是Java眾多開發技術中的一門技術,意在利用本地代碼,為Java程序提供更高效、更靈活的拓展。盡管Java一貫以其良好的跨平臺性而著稱,但真正的跨平臺非C/C++莫屬,因為當前世上90%的系統都是基于C/C++編寫的。同時,Java的跨平臺是以犧牲效率換來對多種平臺的兼容性,因而JNI就是這種跨平臺的主流實現方式之一。

總之,JNI是一門技術,是Java 與C/C++ 溝通的一門技術。首先,來回顧下Android的系統架構圖。

![<meta charset="utf-8">

<article class="_2rhmJa" deep="5">

JNI 簡介

JNI (Java Native Interface英文縮寫),譯為Java本地接口。是Java眾多開發技術中的一門技術,意在利用本地代碼,為Java程序提供更高效、更靈活的拓展。盡管Java一貫以其良好的跨平臺性而著稱,但真正的跨平臺非C/C++莫屬,因為當前世上90%的系統都是基于C/C++編寫的。同時,Java的跨平臺是以犧牲效率換來對多種平臺的兼容性,因而JNI就是這種跨平臺的主流實現方式之一。

總之,JNI是一門技術,是Java 與C/C++ 溝通的一門技術。首先,來回顧下Android的系統架構圖。

image

我們來簡單介紹下每一層的作用。

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字節碼格式,轉化過程如下圖。

image

如上所示,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,如下所示。

image

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

image

然后勾選【Include C++ support】選項,點擊【下一步】,到達【Customize C++ Support】設置頁,如下所示。

image

然后,點擊【Finish】按鈕即可。

NDK 項目目錄

打開新建的NDK工程,目錄如下圖所示。

image

我們接下來看一下,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的都應該知道,在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, "<init>", "()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。其中獲取構造方法時,方法名稱固定寫法就是<init>,然后后面是方法簽名。
  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交叉編譯器工具鏈的目錄結構如下所示。

image

然后,我們再看一下ndk的文件目錄,如下所示。

image

其中,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干貨,交流Android技術。
對文章有何見解,或者有何技術問題,都可以在評論區一起留言討論,我會虔誠為你解答。
最后,如果你想知道更多Android的知識或需要其他資料我這里均免費分享,只需你多多支持我即可哦!

——可以直接點這里可以看到全部資料內容免費打包領取。

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。

推薦閱讀更多精彩內容