JNI開發流程與引用資料型別的處理

juexingzhe發表於2018-05-04

今天我們來看下Java JNI,先看下維基百科給的定義,

JNI, Java Native Interface, Java本地介面,是一種程式設計框架,使得Java虛擬機器中的Java程式可以呼叫本地應用或庫,也可以被其他程式呼叫。本地程式一般是用其它語言(C、C++或組合語言)編寫的,並且被編譯為基於本地硬體和作業系統的程式。

本文就是分析下Java呼叫C++程式的步驟和JNI開發訪問陣列和字串的問題。

先看下Android中JNI的開發步驟。簡單寫了個Demo,看下效果:

Demo.png

呼叫方式:

button.setOnClickListener(new View.OnClickListener() {
            @Override
            public void onClick(View view) {
                Toast.makeText(MainActivity.this, HelloWorld.sayHello("JNIEnjoy!"), Toast.LENGTH_LONG).show();
            }
});
複製程式碼

點選SUM會對陣列進行求和和列印出Native傳遞過來的二維陣列

SUM.png

Log.png

呼叫程式碼:

btn2.setOnClickListener(new View.OnClickListener() {
            @Override
            public void onClick(View view) {
                btn2.setText(String.valueOf(ArrayJni.arraySum(get())));

                int[][] arr = ArrayJni.getArray(3);
                for (int i = 0; i < 3; i++) {
                    for (int j = 0; j < 3; j++) {
                        Log.d("JNILOG", String.valueOf(arr[i][j]));
                    }
                }
            }
});

private int[] get() {
        int[] array = new int[10];
        for (int i = 0; i < array.length; i++) {
            array[i] = i;
        }
        return array;
}
複製程式碼

接下來看下JNI開發步驟:

1.JNI開發步驟

第一步,在Java層先建立JNI Class,需要呼叫Native 方法的地方需要關鍵字native宣告,其中方法sayHello就是需要底層實現的,這個Demo中會用C實現。

public class HelloWorld {

    public static native String sayHello(String name);
}
複製程式碼

第二步, Make Project,這樣會在app/build/intermediates/classes/debug下生成class檔案,如下圖所示,當然需要的就是Hello World.class這個檔案。

Make Class.png

第三步, 在終端中切換目錄到app\build\intermediates\classes\debug, 通過命令生成.h標頭檔案javah -jni juexingzhe.com.hello.HelloWorld,juexingzhe.com.hello是包名,需要換成小夥伴自己的包名。juexingzhe.com.hello.HelloWorld.h檔案。

Make h.png

看下檔案內容,預設生成的函式名規則是:

Java_包名_類名_Native方法名
複製程式碼

其中JNIEnv是執行緒相關的,即在每個執行緒中都有一個JNIEnv指標, 每個JNIEnv都是執行緒專有,執行緒A不能呼叫執行緒B的JNIEnv。

jclass就是HelloWorld這個類,因為在這個例子中方法是靜態的,所以預設生成的是jclass,如果方法不是靜態的,預設生成的就會傳入jobject,指向呼叫這個native方法時的物件例項。

jstring就是定義方法時傳入的引數。

/* DO NOT EDIT THIS FILE - it is machine generated */
#include <jni.h>
/* Header for class juexingzhe_com_hello_HelloWorld */

#ifndef _Included_juexingzhe_com_hello_HelloWorld
#define _Included_juexingzhe_com_hello_HelloWorld
#ifdef __cplusplus
extern "C" {
#endif
/*
 * Class:     juexingzhe_com_hello_HelloWorld
 * Method:    sayHello
 * Signature: ()Ljava/lang/String;
 */
JNIEXPORT jstring JNICALL Java_juexingzhe_com_hello_HelloWorld_sayHello
  (JNIEnv *, jclass, jstring);

#ifdef __cplusplus
}
#endif
#endif
複製程式碼

第四步,在main目錄下新建jni資料夾,將上面生成的.h檔案剪下過來。

New JNI Folder.png

第五步,終於到了寫c程式碼的時候了,注意在標頭檔案中,C和C++寫法是不一樣的。

C中(*env)->NewStringUTF(env, "string)

C++中env->NewStringUTF("string")

複製程式碼

最終的juexingzhe.com.hello.HelloWorld.c檔案如下:

#include "juexingzhe_com_hello_HelloWorld.h"
#include <stdio.h>
/* Header for class juexingzhe_com_hello_HelloWorld */

/*
 * Class:     juexingzhe_com_hello_HelloWorld
 * Method:    sayHello
 * Signature: ()Ljava/lang/String;
 */
JNIEXPORT jstring JNICALL Java_juexingzhe_com_hello_HelloWorld_sayHello
        (JNIEnv *env, jclass jcls, jstring jstr)
{
    const char *c_str = NULL;
    char buff[128] = { 0 };
    c_str = (*env)->GetStringUTFChars(env, jstr, NULL);
    if (c_str == NULL)
    {
        printf("out of memory.\n");
        return NULL;
    }
    sprintf(buff, "hello %s", c_str);
    (*env)->ReleaseStringUTFChars(env, jstr, c_str);
    return (*env)->NewStringUTF(env, buff);
}
複製程式碼

對上面的程式碼有幾點需要注意的, 參考後面字串處理。

經過上面五步寫程式碼的步驟就差不多了,還有一個問題,Java層怎麼調到這個C檔案呢?這就需要第六步配置ndk

第六步,配置ndk,在module包下面的build.gradle中的defaultConfig新增,其中moduleName就是最終打包出來的so庫的名字

ndk {
     moduleName 'HelloWorld'
}
複製程式碼

最終android這個task是下面這樣的

android {
    compileSdkVersion 26
    defaultConfig {
        applicationId "juexingzhe.com.hello"
        minSdkVersion 15
        targetSdkVersion 26
        versionCode 1
        versionName "1.0"
        testInstrumentationRunner "android.support.test.runner.AndroidJUnitRunner"
        ndk {
            moduleName 'HelloWorld'
        }
    }
    buildTypes {
        release {
            minifyEnabled false
            proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
        }
    }
}

複製程式碼

還需要在工程目錄下的gradle.properties中新增下面這句話

android.useDeprecatedNdk=true
複製程式碼

重新Make Project就可以生成.so檔案,這裡沒有配置平臺,所以會預設生成所有平臺的so庫,包括arm/x86/mips等

Make SO.png

第七步,需要在Java層載入這個.so檔案,在第一步編寫的HelloWorld.Java中新增,其中HelloWorld就是上面NDK配置生成的so庫名字。

public class HelloWorld {

    static {
        System.loadLibrary("HelloWorld");
    }

    public static native String sayHello(String name);
}
複製程式碼

2.字串處理

再回顧一下上面.c檔案的內容:

#include "juexingzhe_com_hello_HelloWorld.h"
#include <stdio.h>
/* Header for class juexingzhe_com_hello_HelloWorld */

/*
 * Class:     juexingzhe_com_hello_HelloWorld
 * Method:    sayHello
 * Signature: ()Ljava/lang/String;
 */
JNIEXPORT jstring JNICALL Java_juexingzhe_com_hello_HelloWorld_sayHello
        (JNIEnv *env, jclass jcls, jstring jstr)
{
    const char *c_str = NULL;
    char buff[128] = { 0 };
    c_str = (*env)->GetStringUTFChars(env, jstr, NULL);
    if (c_str == NULL)
    {
        printf("out of memory.\n");
        return NULL;
    }
    sprintf(buff, "hello %s", c_str);
    (*env)->ReleaseStringUTFChars(env, jstr, c_str);
    return (*env)->NewStringUTF(env, buff);
}
複製程式碼
  • 1.jstring型別是指向JVM內部的一個字串,和基本型別不一樣,C程式碼中不能直接拿來用,需要通過JNI函式來訪問JVM內部的字串資料結構。

  • 2.簡單看下GetStringUTFChars(env, jstr, &isCopy), jstr是Java傳遞給原生程式碼的字串指標,isCopy取值JNI_TRUE和JNI_FALSE,如果值為JNI_TRUE,表示返回JVM內部源字串的一份拷貝,併為新產生的字串分配記憶體空間。如果值為JNI_FALSE,表示返回JVM內部源字串的指標,意味著可以通過指標修改源字串的內容,不推薦這麼做,因為這樣做就打破了Java字串不能修改的規定。但我們在開發當中,並不關心這個值是多少,通常情況下這個引數填NULL即可。

  • 3.Java預設使用Unicode編碼,而C/C++預設使用UTF編碼,所以在原生程式碼中操作字串的時候,必須使用合適的JNI函式把jstring轉換成C風格的字串。JNI支援字串在Unicode和UTF-8兩種編碼之間轉換,GetStringUTFChars可以把一個jstring指標(指向JVM內部的Unicode字元序列)轉換成一個UTF-8格式的C字串。在上例中sayHello函式中我們通過GetStringUTFChars正確取得了JVM內部的字串內容

  • 4.異常檢查。呼叫完GetStringUTFChars需要進行安全檢查,因為JVM需要為新誕生的字串分配記憶體,分配失敗會返回NULL,並丟擲OutOfMemoryError異常。Java中如果遇到異常沒有捕獲程式會立即停止執行。而JNI遇到未處理的異常不會改變程式的執行流程,回繼續往下走,這樣後面對這個字串的所有操作都是危險的。所以如果NULL,需要return跳過後面的程式碼。

  • 5.釋放字串。C和Java不一樣,需要手動釋放記憶體,通過ReleaseStringUTFChars函式通知JVM這塊記憶體不需要了。注意GetXXX和ReleaseXXX要配套呼叫。

  • 6.呼叫NewStringUTF函式會構建一個新的java.lang.String字串物件,這個物件會自動轉換成Java支援的Unicode編碼。如果JVM不能為構造java.lang.String分配足夠的記憶體,NewStringUTF會丟擲一個OutOfMemoryError異常,並返回NULL。

當然,JNI提供操作字串的函式很多,這裡就不一一解釋了,主要需要注意記憶體的分配和跨執行緒的問題。

3.陣列處理

陣列和上面的字串類似,沒辦法直接操作,需要通過JNI函式從JVM中獲取到對應的指標或者拷貝到記憶體緩衝區再進行操作。

按照上面步驟再新增一個陣列的例子,看下Java程式碼,兩個Native函式,一個求和一個獲取二維陣列。

public class ArrayJni {

    static {
        System.loadLibrary("HelloWorld");
    }

    //求和
    public static native int arraySum(int[] array);

    //獲取二維陣列
    public static native int[][] getArray(int size);

}
複製程式碼

接下來先看下arraySum的C程式碼,Java層定義的引數是int型別的陣列對應到Native就是jintArray, 通過GetArrayLength獲取引數陣列的長度,然後通過GetIntArrayRegion將引數陣列拷貝到記憶體緩衝區buffer,之後就可以進行求和操作了。操作完成記得釋放記憶體。

/*
 * Class:     juexingzhe_com_hello_ArrayJni
 * Method:    arraySum
 * Signature: ([I)I
 */
JNIEXPORT jint JNICALL Java_juexingzhe_com_hello_ArrayJni_arraySum
        (JNIEnv *env, jclass jcls, jintArray jarr)
{
    jint i, sum = 0, len;
    jint *buffer;
    //1.獲取陣列長度
    len = (*env)->GetArrayLength(env, jarr);

    //2.分配緩衝區
    buffer = (jint*) malloc(sizeof(jint) * len);
    memset(buffer, 0, sizeof(jint) * len);

    //3.拷貝Java陣列中所有元素到緩衝區
    (*env)->GetIntArrayRegion(env, jarr, 0, len, buffer);

    //4.求和
    for (int i = 0; i < len; ++i) {
        sum += buffer[i];
    }

    //5.釋放記憶體
    free(buffer);

    return sum;
}
複製程式碼

再看下生成二維陣列的程式碼, 小夥伴們都知道二維陣列中每一個元素其實是一維陣列,所以需要先構造一維陣列的引用,通過FindClass,再通過NewObjectArray構造二維陣列。

通過NewIntArray構造一維陣列,然後SetIntArrayRegion賦值int型別陣列元素,當然也有GetIntArrayRegion函式,可以將Java陣列中的所有元素拷貝到C緩衝區中。

二維陣列通過SetObjectArrayElement進行賦值。

為了避免在迴圈內建立大量的JNI區域性引用,造成JNI引用表溢位,在外層迴圈中每次都要呼叫DeleteLocalRef將新建立的jintArray引用從引用表中移除。在JNI中,只有jobject以及子類屬於引用變數,會佔用引用表的空間,jint,jfloat,jboolean等都是基本型別變數,不會佔用引用表空間,即不需要釋放。引用表最大空間為512個,如果超出這個範圍,JVM就會掛掉。

/*
 * Class:     juexingzhe_com_hello_ArrayJni
 * Method:    getArray
 * Signature: (I)[[I
 */
JNIEXPORT jobjectArray JNICALL Java_juexingzhe_com_hello_ArrayJni_getArray
        (JNIEnv *env, jclass jcls, jint size)
{
    jobjectArray result;
    jclass onearray;

    //1.獲取一維陣列引用
    onearray = (*env)->FindClass(env, "[I");
    if (onearray == NULL){
        return NULL;
    }

    //2.構造二維陣列
    result = (*env)->NewObjectArray(env, size, onearray, NULL);
    if (result == NULL){
        return NULL;
    }

    //3.構造一維陣列
    for (int i = 0; i < size; ++i) {

        int j;
        jint buffer[256];
        //構造一維陣列
        jintArray array = (*env)->NewIntArray(env, size);
        if (array == NULL){
            return NULL;
        }
        //準備資料
        for (int j = 0; j < size; ++j) {
            buffer[j] = i + j;
        }

        //設定一維陣列資料
        (*env)->SetIntArrayRegion(env, array, 0, size, buffer);

        //賦值一維陣列給二維陣列
        (*env)->SetObjectArrayElement(env, result, i, array);

        //刪除一維陣列引用
        (*env)->DeleteLocalRef(env, array);
    }

    return result;
}
複製程式碼

同樣地, 陣列操作的函式也有很多,這裡不可能每個都進行說明,有需要的小夥伴可以自行搜尋,差別不會太大。

4.總結

本文只是對Android開發JNI的一點點理解總結,包括JNI開發的步驟,字串和陣列的處理,在JNI Native開發過程中都沒辦法直接操作引用型別的資料,需要通過JNI提供的函式來獲取JVM中的資料,提供的函式有的會進行原資料的拷貝有的會返回原資料的指標,根據自己需要進行不同的選擇。

後面有可能會對JNI再出一些內容,比如Native呼叫Java層的物件方法欄位等,有需要的小夥伴們歡迎關注。

相關文章