1. 程式人生 > 實用技巧 >Android NDK開發入門

Android NDK開發入門

轉載自:https://segmentfault.com/a/1190000037594523

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

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


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

然後,點選【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 <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交叉編譯器工具鏈的目錄結構如下所示。


然後,我們再看一下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手動釋放。