在 Android 中使用 JNI 的總結

最近在研究 Android 相機相關的東西,由於想要對相機作一個封裝,因而想到要提供支持濾鏡和圖像動態識別相關的接口。在我找到一些資料中,它們的實現:一個是基於 OpenGL 的,一個是基於 OpenCV 的。二者均可以直接使用 Java 進行開發,受制於 Java 語言的限制,因此當對程序的性能要求很高的時候,Java 就有些心有餘力不足了。因此,有些實現 OpenCV 的方式是在 Native 層進行處理的。這就須要涉及 JNI 的一些知識。html

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

一、聲明 native 方法

1.1 靜態註冊

首先,聲明 Java 類,android

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,這裏的路徑是個人庫文件所在的位置。c++

使用 JNI 第一步是生成頭文件,咱們可使用以下的指令,git

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

或者簡單一些,先把 java 文件編譯成 class,而後使用 class 生成 h 頭文件,github

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

上面的兩個命令是可行的,只是要注意下文件的路徑的問題。(也許咱們可使用 Java 或者其餘的語言寫些程序調用這些可執行文件來簡化它的使用!)shell

生成的頭文件代碼以下,編程

/* 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 表示了。windows

這裏的 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;
}
複製代碼

二、執行 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! 便可。

三、進一步接觸 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() 函數的結果返回。

四、在 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. 若是你喜歡這篇文章,願意支持做者的工做,請爲這篇文章點個贊👍!

相關文章
相關標籤/搜索