在 Android 中使用 JNI 的總結

WngShhng發表於2019-03-02

最近在研究 Android 相機相關的東西,因為想要對相機做一個封裝,於是想到要提供支援濾鏡和影象動態識別相關的介面。在我找到一些資料中,它們的實現:一個是基於 OpenGL 的,一個是基於 OpenCV 的。兩者都可以直接使用 Java 進行開發,受制於 Java 語言的限制,所以當對程式的效能要求很高的時候,Java 就有些心有餘力不足了。所以,有些實現 OpenCV 的方式是在 Native 層進行處理的。這就需要涉及 JNI 的一些知識。

當然,JNI 並非 Android 中提出的概念,而是在 Java 中本來提供的。所以,在這篇文章中,我們先嚐試在 IDEA 中使用 JNI 進行開發,以瞭解 JNI 執行的原理和一些基礎知識。然後,再介紹下 AS 中使用更高效的開發方式。

1、宣告 native 方法

1.1 靜態註冊

首先,宣告 Java 類,

package me.shouheng.jni;

public class JNIExample {

    static {
        // 函式System.loadLibrary()是載入dll(windows)或so(Linux)庫,只需名稱即可,
        // 無需加入檔名字尾(.dll或.so)
        System.loadLibrary("JNIExample");
        init_native();
    }

    private static native void init_native();

    public static native void hello_world();

    public static void main(String...args) {
        JNIExample.hello_world();
    }
}
複製程式碼

native 的方法可以定義成 static 的和非 static 的,使用上和普通的方法沒有區別。這裡使用 System.loadLibrary("JNIExample") 載入 JNI 的庫。在 Window 上面是 dll,在 Linux 上面是 so. 這裡的 JNIExample 只是庫的名稱,甚至都沒有包含檔案型別的字尾,那麼 IDEA 怎麼知道到哪裡載入庫呢?這就需要我們在執行 JVM 的時候,通過虛擬機器引數來指定。在 IDEA 中的方式是使用 Edit Configuration...,然後在 VM options 一欄中輸入 -Djava.library.path=F:\Codes\Java\Project\Java-advanced\java-advanced\lib,這裡的路徑是我的庫檔案所在的位置。

使用 JNI 第一步是生成標頭檔案,我們可以使用如下的指令,

javah -jni -classpath (搜尋類目錄) -d (輸出目錄) (類名)
複製程式碼

或者簡單一些,先把 java 檔案編譯成 class,然後使用 class 生成 h 標頭檔案,

javac me/shouheng/jni/JNIExample.java
javah me.shouheng.jni.JNIExample
複製程式碼

上面的兩個命令是可行的,只是要注意下檔案的路徑的問題。(也許我們可以使用 Java 或者其他的語言寫些程式呼叫這些可執行檔案來簡化它的使用!)

生成的標頭檔案程式碼如下,

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

#ifndef _Included_me_shouheng_jni_JNIExample
#define _Included_me_shouheng_jni_JNIExample
#ifdef __cplusplus
extern "C" {
#endif
/*
 * Class:     me_shouheng_jni_JNIExample
 * Method:    init_native
 * Signature: ()V
 */
JNIEXPORT void JNICALL Java_me_shouheng_jni_JNIExample_init_1native
  (JNIEnv *, jclass);

/*
 * Class:     me_shouheng_jni_JNIExample
 * Method:    hello_world
 * Signature: ()V
 */
JNIEXPORT void JNICALL Java_me_shouheng_jni_JNIExample_hello_1world
  (JNIEnv *, jclass);

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

可以看出,它跟普通的 c 標頭檔案多了 JNIEXPORT 和 JNICALL 兩個指令,剩下的東西完全符合一般 c 標頭檔案的規則。這裡的 Java_me_shouheng_jni_JNIExample_init_1native 對應 Java 層的程式碼,可見它的規則是 Java_Java層的方法路徑 只是方法路徑使用了下劃線取代了逗號,並且 Java 層的下劃線使用 _1 替代,這是因為 Native 層的下劃線已經用來替代 Java 層的逗號了,所以 Java 層的下劃線只能用 _1 表示了。

這裡的 JNIEnv 是一個指標型別,我們可以用它訪問 Java 層的程式碼,它不能跨程式被呼叫。你可以在 JDK 下面的 include 資料夾中的 jni.h 中找到它的定義。jclass 對應 Java 層的 Class 類。Java 層的類和 Native 層的類之間按照指定的規則進行對映,當然還有方法簽名的對映關係。所謂方法簽名,比如上面的 ()V,當你使用 javap 反編譯 class 的時候可以看到這種符號。它們實際上是 class 檔案中的一種簡化的描述方式,主要是為了節省 class 檔案的記憶體。此外,方法簽名還被用來進行動態註冊 JNI 方法。

Native-Java 型別對應關係

引用型別的對應關係如下,

引用型別的對應關係

上面註冊 JNI 的方式屬於靜態註冊,可以理解為在 Java 層註冊 Native 的方法;此外,還有動態註冊,就是在 Native 層註冊 Java 層的方法。

1.2 動態註冊

除了按照上面的方式靜態註冊 native 方法,我們還可以動態進行註冊。動態註冊的方式需要我們使用方法的簽名,下面是 Java 型別與方法簽名之間的對映關係:

JNI方法簽名

注意這裡的全限定類名以 / 分隔,而不是用 ._ 分隔。方法簽名的規則是:(引數1型別簽名引數2型別簽名……引數n型別簽名)返回型別簽名。比如,long fun(int n, String str, int[] arr) 對應的方法簽名為 (ILjava/lang/String;[I)J

一般 JNI 方法動態註冊的流程是:

  1. 利用結構體 JNINativeMethod 陣列記錄 java 方法與 JNI 函式的對應關係;
  2. 實現 JNI_OnLoad 方法,在載入動態庫後,執行動態註冊;
  3. 呼叫 FindClass 方法,獲取 java 物件;
  4. 呼叫 RegisterNatives 方法,傳入 java 物件,以及 JNINativeMethod 陣列,以及註冊數目完成註冊。

比如上面的程式碼如果使用動態註冊將會是如下形式:

void init_native(JNIEnv *env, jobject thiz) {
    printf("native_init\n");
    return;
}

void hello_world(JNIEnv *env, jobject thiz) {
    printf("Hello World!");
    return;
}

static const JNINativeMethod gMethods[] = {
        {"init_native", "()V", (void*)init_native},
        {"hello_world", "()V", (void*)hello_world}
};

JNIEXPORT jint JNI_OnLoad(JavaVM* vm, void* reserved) {
    __android_log_print(ANDROID_LOG_INFO, "native", "Jni_OnLoad");
    JNIEnv* env = NULL;
    if(vm->GetEnv((void**)&env, JNI_VERSION_1_4) != JNI_OK) // 從 JavaVM 獲取JNIEnv,一般使用 1.4 的版本
        return -1;
    jclass clazz = env->FindClass("me/shouheng/jni/JNIExample");
    if (!clazz){
        __android_log_print(ANDROID_LOG_INFO, "native", "cannot get class: com/example/efan/jni_learn2/MainActivity");
        return -1;
    }
    if(env->RegisterNatives(clazz, gMethods, sizeof(gMethods)/sizeof(gMethods[0])))
    {
        __android_log_print(ANDROID_LOG_INFO, "native", "register native method failed!\n");
        return -1;
    }
    return JNI_VERSION_1_4;
}
複製程式碼

2、執行 JNI 程式

瞭解瞭如何載入,剩下的就是如何得到 dll 和 so. 在 Window 平臺上面,我們使用 VS 或者 GCC 將程式碼編譯成 dll. GCC 有兩種選擇,MinGW 和 Cygwin。這裡注意下 GCC 和 JVM 的位數必須一致,即要麼都是 32 位的要麼都是 64 位的,否則將有可能丟擲 Can't load IA 32-bit .dll on a AMD 64-bit platform 異常。

檢視虛擬機器的位數使用 java -version,其中有明確寫明 64-bit 的是 64 位的,否則是 32 位的。(參考:如何識別JKD的版本號和位數,作業系統位數.)MinGW 的下載可以到如下的連結:MinGW Distro - nuwen.net。安裝完畢之後輸入 gcc -v,能夠輸出版本資訊就說明安裝成功。

有了標頭檔案,我們還要實現 native 層的方法,我們新建一個 c 檔案 JNIExample.c 然後實現各個函式如下,

#include<jni.h>
#include <stdio.h>
#include "me_shouheng_jni_JNIExample.h"

JNIEXPORT void JNICALL Java_me_shouheng_jni_JNIExample_init_1native(JNIEnv * env, jclass cls) {
    printf("native_init\n");
    return;
}

JNIEXPORT void JNICALL Java_me_shouheng_jni_JNIExample_hello_1world(JNIEnv * env, jclass cls) {
    printf("Hello World!");
    return;
}
複製程式碼

看上去還是比較清晰的,除去 JNIEXPORT 和 JNICALL 兩個符號之外,剩下的都是基本的 c 語言的東西。然後我們在方法中簡單輸出一個老朋友 Hello World. 注意下,這裡除了基本的輸入輸出標頭檔案 stdio.h 之外,我們還引入了剛才生成的標頭檔案,以及 jni.h,後者定義在 JDK 當中,當我們使用 gcc 生成 dll 的時候就需要引用這個標頭檔案。

我們使用如下的命令來先生成 o 檔案,

gcc -c -I"E:\JDK\include" -I"E:\JDK\include\win32" jni/JNIExample.c
複製程式碼

這裡的兩個 -I 後面指定的是 JDK 中的標頭檔案的路徑。因為,按照我們上面說的,我們在 c 檔案中引用了 jni.h,而該檔案就位於 JDK 的 include 目錄中。因為 include 中的標頭檔案又引用了目錄 win32 中的標頭檔案,所以,我們需要兩個都引用進來(心累)。

然後,我們使用如下的命令將上述 o 檔案轉成 dll 檔案,

gcc -Wl,--add-stdcall-alias -shared -o JNIExample.dll JNIExample.o
複製程式碼

如果你發現使用了 , 之後 PowerShell 無法執行,那麼可以將 , 替換為 "," 再執行。

生成 dll 之後,我們將其放入自定義的 lib 目錄中。如我們上述所說的,需要在虛擬機器的引數中指定這個目錄。

然後執行並輸出久違的 Hello world! 即可。

3、進一步接觸 JNI:在 Native 中呼叫 Java 層的方法

我們定義如下的類,

public class JNIInteraction {

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

    private static native String outputStringFromJava();

    public static String getStringFromJava(String fromString) {
        return "String from Java " + fromString;
    }

    public static void main(String...args) {
        System.out.println(outputStringFromJava());
    }
}
複製程式碼

這裡我們希望的結果是,Java 層呼叫 Native 層的 outputStringFromJava() 方法。在 Native 層中,該方法呼叫到 Java 層的靜態方法 getStringFromJava() 並傳入字串,最後整個拼接的字串通過 outputStringFromJava() 傳遞給 Java 層。

以上是 Java 層的程式碼,下面是 Native 層的程式碼。Native 層去呼叫 Java 層的方法的步驟基本是固定的:

  1. 通過 JNIEnv 的 FindClass() 函式獲取要呼叫的 Java 層的類;
  2. 通過 JNIEnv 的 GetStaticMethodID() 函式和上述 Java 層的類、方法名稱和方法簽名,得到 Java 層的方法的 id;
  3. 通過 JNIEnv 的 CallStaticObjectMethod() 函式、上述得到的類和上述方法的 id,呼叫 Java 層的方法。

這裡有兩點地方需要說明:

  1. 這裡因為我們要呼叫 Java 層的靜態函式,所以我們使用的函式是 GetStaticMethodID()CallStaticObjectMethod() 。如果你需要呼叫類的例項方法,那麼你需要呼叫 GetMethodID()CallObjectMethod()。諸如此類,JNIEnv 中還有許多其他有用的函式,你可以通過檢視 jni.h 標頭檔案來了解。
  2. Java 層和 Native 層的方法相互呼叫本身並不難,使用的邏輯也是非常清晰的。唯一比較複雜的地方在於,你需要花費額外的時間去處理兩個環境之間的資料型別轉換的問題。比如,按照我們上述的目標,我們需要實現一個將 Java 層傳入的字串轉換成 Native 層字串的函式。其定義如下,
char* Jstring2CStr(JNIEnv* env, jstring jstr) {
    char* rtn = NULL;
    jclass clsstring = (*env)->FindClass(env, "java/lang/String");
    jstring strencode = (*env)->NewStringUTF(env,"GB2312");
    jmethodID mid = (*env)->GetMethodID(env, clsstring, "getBytes", "(Ljava/lang/String;)[B");
    
    // String.getByte("GB2312");
    jbyteArray barr = (jbyteArray)(*env)->CallObjectMethod(env, jstr, mid, strencode);
    jsize alen = (*env)->GetArrayLength(env, barr);
    jbyte* ba = (*env)->GetByteArrayElements(env, barr, JNI_FALSE);
    
    if(alen > 0) {
        rtn = (char*)malloc(alen+1); //"\0"
        memcpy(rtn, ba, alen);
        rtn[alen]=0;
    }
    (*env)->ReleaseByteArrayElements(env,barr,ba,0); //
    return rtn;
}
複製程式碼

在上述函式中,我們通過呼叫 Java 層的 String.getBytes() 獲取到 Java 層的字元陣列,然後將其通過記憶體拷貝的方式複製到字元陣列中。(通過 malloc() 函式申請記憶體,並將字元指標的指向申請的記憶體的首地址。)最後,還要呼叫 JNIEnv 的方法來釋放字元陣列的記憶體。這裡也是一次 Native 調 Java 函式的過程,只是這裡的呼叫 String 類的例項方法。(從這裡也可以看出,Native 層寫程式碼要考慮的因素比 Java 層多得多,好在這是 C 語言,如果 C++ 的化可能處理起來會好一些。)

回到之前的討論中,我們需要繼續實現 Native 層的函式:

JNIEXPORT jstring JNICALL Java_me_shouheng_jni_interaction_JNIInteraction_outputStringFromJava (JNIEnv *env, jclass _cls) {
    jclass clsJNIInteraction = (*env)->FindClass(env, "me/shouheng/jni/interaction/JNIInteraction"); // 得到類
    jmethodID mid = (*env)->GetStaticMethodID(env, clsJNIInteraction, "getStringFromJava", "(Ljava/lang/String;)Ljava/lang/String;"); // 得到方法
    jstring params = (*env)->NewStringUTF(env, "Hello World!");
    jstring result = (jstring)(*env)->CallStaticObjectMethod(env, clsJNIInteraction, mid, params);
    return result;
}
複製程式碼

其實它的邏輯也是比較簡單的了。跟我們上面呼叫 String 的例項方法的步驟基本一致,只是這裡呼叫的是靜態方法。

這樣上述程式的效果是,當 Java 層呼叫 Native 層的 outputStringFromJava() 函式的時候:首先,Native 層通過呼叫 Java 層的 JNIInteraction 的靜態方法 getStringFromJava() 並傳入引數得到 String from Java Hello World! 之後將其作為 outputStringFromJava() 函式的結果返回。

4、在 Android Studio 中使用 JNI

上面在程式中使用 JNI 的方式可以說很笨拙了,還好在 Android Studio 中,許多過程被簡化了。這讓我們得以將跟多的精力放在實現 Native 層和 Java 層程式碼邏輯上,而無需過多關注編譯環節這個複雜的問題。

在 AS 中啟用 JNI 的方式很簡單:在使用 AS 建立一個新專案的時候注意勾選 include C++ support 即可。其他的步驟與建立一個普通的 Android 專案並無二致。然後你需要對開發的環境進行簡單的配置。你需要安裝下面幾個庫,即 CMake, LLDB 和 NDK:

AS 環境需求

AS 之所以能夠簡化我們的編譯流程,很大程度上是得益於編譯工具 CMake。CMake 是一個跨平臺的安裝(編譯)工具,可以用簡單的語句來描述所有平臺的安裝 (編譯過程)。我們只需要在它指定的 CMakeLists.txt 檔案中使用它特定的語法描述整個編譯流程,然後使用 CMake 的指令即可。你可以通過文件來了解如何在 AS 中使用 CMake:add-native-code. 或者通過下面這篇文章簡單入門下 CMake:CMake 入門實戰

支援 JNI 開發的 Android 專案與普通的專案沒有太大的區別,除了在 local.properties 中額外指定了 NDK 的目錄之外,專案結構和 Gradle 的配置主要有如下的區別:

專案區別

可以看出區別主要在於:

  1. main 目錄下面多了個 cpp 目錄用來編寫 C++ 程式碼;
  2. app 目錄下面多了各 CMakeLists.txt 就是我們上面提到的 CMake 的配置檔案;
  3. 另外 Gradle 中裡面一處指定了 CMakeLists.txt 檔案的位置,另一處配置了 CMake 的編譯;

在 AS 中進行 JNI 開發的優勢除了 CMake 之外,還有:

  1. 無需手動對方法進行動態註冊和靜態註冊,當你在 Java 層定義了一個 native 方法之後,可以通過右鍵直接生成 Native 層對應的方法;
  2. 此外,AS 中可以建立 Native 層和 Java 層方法之間的聯絡,你可以直接在兩個方法之間跳轉;
  3. 當使用 AS 進行程式設計的時候,呼叫 Native 層的類的時候也會給出提示選項,比如上面的 JNIEnv 就可以給出其內部各種方法的提示。

另外,從該初始化的專案以及 Android 的 Native 層的原始碼來看,Google 是支援我們使用 C++ 開發的。所以,吃了那麼久灰的 C++ 書籍又可以派上用場了……

總結

以上。


Android 從基礎到高階,關注作者及時獲取更多知識

本系列以及其他系列的文章均維護在 Github 上面:Github / Android-notes,歡迎 Star & Fork. 如果你喜歡這篇文章,願意支援作者的工作,請為這篇文章點個贊?!

相關文章