1. 程式人生 > >在 Android 中使用 JNI 的總結

在 Android 中使用 JNI 的總結

方式 都是 edi urn 限定 ext powers shared 註冊

最近在研究 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 方法。

技術分享圖片
引用類型的對應關系如下,
技術分享圖片
上面註冊 JNI 的方式屬於靜態註冊,可以理解為在 Java 層註冊 Native 的方法;此外,還有動態註冊,就是在 Native 層註冊 Java 層的方法


1.2 動態註冊
除了按照上面的方式靜態註冊 native 方法,我們還可以動態進行註冊。動態註冊的方式需要我們使用方法的簽名,下面是 Java 類型與方法簽名之間的映射關系:
技術分享圖片
註意這裏的全限定類名以 / 分隔,而不是用 . 或 _ 分隔。方法簽名的規則是:(參數1類型簽名參數2類型簽名……參數n類型簽名)返回類型簽名。比如,long fun(int n, String str, int[] arr) 對應的方法簽名為 (ILjava/lang/String;[I)J。
一般 JNI 方法動態註冊的流程是:

  • 利用結構體 JNINativeMethod 數組記錄 java 方法與 JNI 函數的對應關系;
  • 實現 JNI_OnLoad 方法,在加載動態庫後,執行動態註冊;
  • 調用 FindClass 方法,獲取 java 對象;
  • 調用 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 層的方法的步驟基本是固定的:

  • 通過 JNIEnv 的 FindClass() 函數獲取要調用的 Java 層的類;
  • 通過 JNIEnv 的 GetStaticMethodID() 函數和上述 Java 層的類、方法名稱和方法簽名,得到 Java 層的方法的 id;
  • 通過 JNIEnv 的 CallStaticObjectMethod() 函數、上述得到的類和上述方法的 id,調用 Java 層的方法

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

  • 這裏因為我們要調用 Java 層的靜態函數,所以我們使用的函數是 GetStaticMethodID() 和 CallStaticObjectMethod() 。如果你需要調用類的實例方法,那麽你需要調用 GetMethodID() 和 CallObjectMethod()。諸如此類,JNIEnv 中還有許多其他有用的函數,你可以通過查看 jni.h 頭文件來了解。
  • 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:

![](https://s1.51cto.com/images/blog/201905/14/bfdb83f90e3950221be7639d685c577c.png?x-oss-process=image/watermark,size_16,text_QDUxQ1RP5Y2a5a6i,color_FFFFFF,t_100,g_se,x_10,y_10,shadow_90,type_ZmFuZ3poZW5naGVpdGk=)
AS 之所以能夠簡化我們的編譯流程,很大程度上是得益於編譯工具 CMake。CMake 是一個跨平臺的安裝(編譯)工具,可以用簡單的語句來描述所有平臺的安裝 (編譯過程)。我們只需要在它指定的 CMakeLists.txt 文件中使用它特定的語法描述整個編譯流程,然後使用 CMake 的指令即可

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

![](https://s1.51cto.com/images/blog/201905/14/ecde59d82db055d5b1685e55fef73114.png?x-oss-process=image/watermark,size_16,text_QDUxQ1RP5Y2a5a6i,color_FFFFFF,t_100,g_se,x_10,y_10,shadow_90,type_ZmFuZ3poZW5naGVpdGk=)
可以看出區別主要在於:

- main 目錄下面多了個 cpp 目錄用來編寫 C++ 代碼;
- app 目錄下面多了各 CMakeLists.txt 就是我們上面提到的 CMake 的配置文件;
- 另外 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 中使用 JNI 的總結