一篇文章教你完全掌握jni技術

語言: CN / TW / HK

開啟掘金成長之旅!這是我參與「掘金日新計劃 · 2 月更文挑戰」的第 2 天,點擊查看活動詳情

jni介紹

jni全稱java native interface,我把它分為三部分,java代表java語言,native代表當前程序運行的本地環境,一般指windows/linux,而這些操作系統都是通過C/C++實現的,所以native通常也指C/C++語言,interface代表java跟native兩者之間的通信接口,jni可以實現java和C/C++通信。它是java生態的特徵,所以定義在jdk標準當中。

使用場景和優勢
  • java雖然跨平台,但仍然運行在具體平台(windows,linux)之上,對於需要操作硬件的功能,必須通過系統的C/C++方法對硬件進行直接操作,比如打開文件,java層必須調用系統的open方法(linux是open,windows是openFile)才能打開文件,這個時候就涉及到java代碼如何調用C/C++代碼的問題

  • 在一些擁有複雜算法的場景(音視頻編解碼,圖像繪製等),java的執行效率遠低於C/C++的執行效率,使用jni技術,在java層調用C/C++代碼,可以提高程序的執行效率,最大化利用機器的硬件資源。

  • native層的代碼往往更加安全,反編譯so文件比反編譯jar文件要難得多,所以,我們往往把涉及到密碼密鑰相關的功能用C/C++實現,然後java層通過jni調用
通信原理

java運行在jvm,jvm本身就是使用C/C++編寫的,因此jni只需要在java代碼、jvm、C/C++代碼之間做切換即可

jni調用流程

使用步驟

基於windows,為了方便,我使用了idea+clion,讀者需要能掌握這兩個工具的基本使用,跟Android Studio差不多的。整個過程我分為了十步:

1.使用idea創建一個java工程,並創建JNIDemo.java文件

image-20230203115518044

2.在JNIDemo.java文件中聲明native方法helloJni()

java public class JNIDemo { public static native helloJni(); }

3.使用javac命令編譯JNIDemo.java,生成JNIDemo.class文件

image-20230203115654509

4.使用javah命令生成JNIDemo.h文件

image-20230203132204113

5.使用clion創建C++ library項目,並複製剛剛生成的com_jason_jni_JNIDemo.h頭文件到項目根目錄

image-20230203132600578

庫類型選擇shared,表示編譯生成動態庫,static為靜態庫,動態庫和靜態庫的最大區別就在於靜態庫會將目標代碼以及所有需要依賴的庫文件進行整體打包,執行時不再依賴外部環境。動態庫則只會將目標代碼打包,運行時需要依賴外部環境,所以一般來説,靜態庫往往比動態庫要大。windows上的動態庫為.dll文件,靜態庫為.lib文件。linux上的動態庫為.so文件,靜態庫為.a文件。

image-20230203132700673

6.創建JNIDemo.cpp文件,實現helloJni()方法

image-20230203132758307

這裏我直接返回了I am from c++字符串,同時要將JNIDemo.cpp文件添加到CMakeList.txt

image-20230203133020937

這個時候我們看到com_jason_jni_JNIDemo.h文件中有報錯

image-20230203141554307

這是因為無法從系統中找到jni.h頭文件,這裏我們可以手動導入jni.h到項目中,開頭説了,jni是java的特徵,所以jni.h文件在jdk當中,去本地jdk安裝目中找<jdk安裝目錄>/include/jni.h<jdk安裝目錄>/include/win32/jni_md.h,將這兩個文件拷貝到項目根目錄中,然後將#include <jni.h>改為#include "jni.h",尖括號表示從系統中查找,雙引號表示從當前項目中查找。

image-20230203142220664

7.編譯本地代碼,生成libjnitest.dll文件,因為我是在windows上運行的,所以生成的是.dll

image-20230203133353506

7.在剛剛的java項目的根目錄中創建libs文件夾,並將其設置為資源文件夾,然後將生成的libjnitest.dll文件拷貝到該目錄中

image-20230203135048557

注意libs目錄的圖標一定要是資源文件夾的樣式,不是普通文件夾的樣式,然後將libjnitest.dll文件拷貝到該目錄下

image-20230203135359032

8.在java代碼中通過System.loadLibrary()加載dll文件

```java public class JNIDemo { static { System.loadLibrary("libjnitest"); } public static native String helloJni();

public static void main(String[] args) {
    System.out.println(helloJni());
}

} ```

9.將該libjnitest.dll庫添加到虛擬機運行環境

image-20230203140101328

image-20230203140143403

值設置為-Djava.library.path=E:\Idea_projects\JNITestDemo\libs,等號後面為libjnitest.dll文件所在的路徑

10.在main()函數處右鍵,運行該程序

image-20230203143422710

成功輸出I am from c++

上面通過一個簡單的案例講解了jni的使用流程,從中不難看出,大部分步驟都是固定的,唯一不固定的是JNIDemo.cpp的內容,這個取決於實際的需求。而在新版的Android Studio當中已經把這些固定流程封裝成了模板操作,我們可以一鍵生成頭文件和源文件,開發者只需要關注源文件的功能實現即可。

image-20230203145849108

只需要在新建項目時選擇Native C++即可,這裏我就不做具體演示了,有興趣的讀者可以自行嘗試。

API詳解

剛剛我只是簡單的返回了一個字符串,實際上我們還可以做很多事情,jni.h都給我們定義好了標準,我們按照它的標準來即可。

開頭提到,java和C/C++通信是通過jni來完成的,那麼在jni方法中就涉及到對java變量的訪問(變量類型包括基本數據類型和引用數據類型),對java方法的調用,java對象的創建等,而java語法跟jni語法不一定是一 一對應的,比如,java中叫boolean,jni中叫jboolean,那怎麼解決這個問題呢,jni給我們提供了若干個映射表,將java中的類型與jni中的類型進行了一 一映射,其中包括基本數據類型映射,引用數據類型映射,方法簽名(包含參數和返回值)映射,以下是這三個映射表:

表1-基本數據類型映射表

映射表-基本數據類型

表2-引用數據類型映射表

映射表-引用數據類型

表3-方法簽名

映射表-方法簽名

以上面Demo來分析

```c++ //Java方法 public static native String helloJni(); public static native float helloJni2(int age, boolean isChild);

//jni方法 extern "C" JNIEXPORT jstring JNICALL Java_com_jason_jni_JNIDemo_helloJni (JNIEnv *env, jclass clazz){ return env->NewStringUTF("I am from c++"); }

extern "C" JNIEXPORT jfloat JNICALL Java_com_jason_jni_JNIDemo_helloJni2 (JNIEnv *env, jclass clazz, jint age, jboolean isChild){

} ```

java方法helloJni()的返回值為String,映射到jni方法中的返回值即為jstring,我們新增一個方法helloJni2(int age, boolean isChild),增加了兩個參數intboolean,對應的映射為jintjboolean,同時返回值float映射為jfloat

解決了數據類型不一致的問題之後,接下來就可以在jni方法中訪問java成員了,同樣的,jni給我們提供了一系列訪問java成員的API,具體如下:

jni訪問調用對象

| 方法名 | 作用 | | -------------- | ------------------------------------ | | GetObjectClass | 獲取調用對象的類,我們稱其為target | | FindClass | 根據類名獲取某個類,我們稱其為target | | IsInstanceOf | 判斷一個類是否為某個類型 | | IsSamObject | 是否指向同一個對象 |

jni訪問java成員變量的值

| 方法名 | 作用 | | ----------- | ---------------------------------------------------------- | | GetFieldId | 根據變量名獲取target中成員變量的ID | | GetIntField | 根據變量ID獲取int變量的值,對應的還有byte,boolean,long等 | | SetIntField | 修改int變量的值,對應的還有byte,boolean,long等 |

jni訪問java靜態變量的值

| 方法名 | 作用 | | ----------------- | ------------------------------------------------------------ | | GetStaticFieldId | 根據變量名獲取target中靜態變量的ID | | GetStaticIntField | 根據變量ID獲取int靜態變量的值,對應的還有byte,boolean,long等 | | SetStaticIntField | 修改int靜態變量的值,對應的還有byte,boolean,long等 |

jni訪問java成員方法

| 方法名 | 作用 | | -------------- | ------------------------------------------------------ | | GetMethodID | 根據方法名獲取target中成員方法的ID | | CallVoidMethod | 執行無返回值成員方法 | | CallIntMethod | 執行int返回值成員方法,對應的還有byte,boolean,long等 |

jni訪問java靜態方法

| 方法名 | 作用 | | -------------------- | ------------------------------------------------------ | | GetStaticMethodID | 根據方法名獲取target中靜態方法的ID | | CallStaticVoidMethod | 執行無返回值靜態方法 | | CallStaticIntMethod | 執行int返回值靜態方法,對應的還有byte,boolean,long等 |

jni訪問java構造方法

| 方法名 | 作用 | | ----------- | ---------------------------------------------------------- | | GetMethodID | 根據方法名獲取target中構造方法的ID,注意,方法名傳<init> | | NewObject | 創建對象 |

jni創建引用

| 方法名 | 作用 | | ---------------- | ------------------------------------------ | | NewGlobalRef | 創建全局引用 | | NewWeakGlobalRef | 創建弱全局引用 | | NewLocalRef | 創建局部引用 | | DeleteGlobalRef | 釋放全局對象,引用不主動釋放會導致內存泄漏 | | DeleteLocalRef | 釋放局部對象,引用不主動釋放會導致內存泄漏 |

除此之外,jni還提供了異常處理機制,處理方式跟java一樣有兩種,要麼往上(java層)拋,要麼自己捕獲處理

| 方法名 | 作用 | | ----------------- | -------------------------- | | ExceptionOccurred | 判斷是否有異常發生 | | ExceptionClear | 清除異常 | | Throw | 往上(java層)拋出異常 | | ThrowNew | 往上(java層)拋出自定義異常 |

API有很多,上述只是列出了一些常用的,其他的可以自行到jni.h文件裏去查看。

案例實戰

以一個完整的demo來進行綜合實戰,在實戰中感受jni的使用姿勢,為了方便,我直接在Android Studio裏面創建了一個Native工程。

需求:統計按鈕的點擊次數

代碼如下:

```java public class MainActivity extends AppCompatActivity { private static final String TAG = "jasonwan"; private TextView tv;

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

private int num = 1;

@Override
protected void onCreate(Bundle savedInstanceState) {
    super.onCreate(savedInstanceState);
    setContentView(R.layout.activity_main);

    tv = findViewById(R.id.sample_text);
    tv.setOnClickListener(v -> {
        jnitTest()
    });
}

//jni測試代碼主要在這個方法裏面
public native void jniTest();

} ```

```c++

include

include

include

define TAG "jasonwan" // 這個是自定義的LOG的標識

define LOGD(...) android_log_print(ANDROID_LOG_DEBUG,TAG,__VA_ARGS) // 定義LOGD類型

/ * 儘管java中的stringFromJNI()方法沒有參數,但cpp中仍然有兩個參數, * 參數一:JNIEnv env表示指向可用JNI函數表的接口指針,所有跟jni相關的操作都需要通過env來完成 * 參數二:jobject是調用該方法的java對象,這裏是MainActivity調用的,所以thiz代表MainActivity * 方法名:Java_包名_類名_方法名 / extern "C" JNIEXPORT void JNICALL Java_com_jason_jni_MainActivity_jniTest(JNIEnv *env, jobject thiz) { //獲取MainActivity的class對象 jclass clazz = env->GetObjectClass(thiz); //獲取MainActivity中num變量id / 參數1:MainActivity的class對象 參數2:變量名稱 參數3:變量類型,具體見上《表3-方法簽名》 / jfieldID numFieldId = env->GetFieldID(clazz, "num", "I"); //根據變量id獲取num的值 jint oldValue = env->GetIntField(thiz, numFieldId); //將num變量的值+1 env->SetIntField(thiz, numFieldId, oldValue + 1); //重新獲取num的值 jint num = env->GetIntField(thiz, numFieldId); //先獲取tv變量id jfieldID tvFieldId = env->GetFieldID(clazz, "tv", "Landroid/widget/TextView;"); //根據變量id獲取textview對象 jobject tvObject = env->GetObjectField(thiz, tvFieldId); //獲取textview的class對象 jclass tvClass = env->GetObjectClass(tvObject); //獲取setText方法ID / 參數1:textview的class對象 參數2:方法名稱 參數3:方法參數類型和返回值類型,具體見上《表3-方法簽名》 / jmethodID methodId = env->GetMethodID(tvClass, "setText", "([CII)V"); //獲取setText所需的參數 //先將num轉化為jstring char buf[64]; sprintf(buf, "%d", num); jstring pJstring = env->NewStringUTF(buf); const char value = env->GetStringUTFChars(pJstring, JNI_FALSE); //創建char數組,長度為字符串num的長度 jcharArray charArray = env->NewCharArray(strlen(value)); //開闢jchar內存空間 jchar pArray = (jchar ) calloc(strlen(value), sizeof(jchar)); //將num字符串緩衝到內存空間中 for (int i = 0; i < strlen(value); ++i) { (pArray + i) = *(value + i); } //將緩衝的值寫入到上面創建的char數組中 env->SetCharArrayRegion(charArray, 0, strlen(value), pArray); //調用setText方法 env->CallVoidMethod(tvObject, methodId, charArray, 0, env->GetArrayLength(charArray)); //釋放資源 env->ReleaseCharArrayElements(charArray, env->GetCharArrayElements(charArray, JNI_FALSE), 0); free(pArray); pArray = NULL; } ```

最後的效果是這樣的

最終效果

通過這樣一個簡單的案例,將大部分jni相關的API都練習了一遍,不難看出,java層能實現的功能,在native層一樣可以實現,但這裏僅僅是為了練習jni,實際項目中不會把一些無關緊要的功能寫在native層,比如UI操作,因為同樣的功能,java代碼要簡潔得太多。

上面我們在實現jniTest()時,可以看到c++裏面的方法名很長Java_com_jason_jni_MainActivity_jniTest,這是jni靜態註冊的方式,按照jni規範的命名規則進行查找,格式為Java_類路徑_方法名,這種方式在應用層開發用的比較廣泛,因為Android Studio默認就是用這種方式,而在framework當中幾乎都是採用動態註冊的方式來實現java和c/c++的通信。比如之前研究過的《Android MediaPlayer源碼分析》,裏面就是採用的動態註冊的方式。

在Android中,當程序在Java層運行System.loadLibrary("jnitest");這行代碼後,程序會去載入libjnitset.so文件。於此同時,產生一個Load事件,這個事件觸發後,程序默認會在載入的.so文件的函數列表中查找JNI_OnLoad函數並執行,與Load事件相對,在載入的.so文件被卸載時,Unload事件被觸發。此時,程序默認會去載入的.so文件的函數列表中查找JNI_OnLoad函數並執行,然後卸載.so文件。因此開發者經常會在JNI_OnLoad中做一些初始化操作,動態註冊就是在這裏進行的,使用env->RegisterNatives(clazz, gMethods, numMethods)

  • 參數1:Java對應的類
  • 參數2:JNINativeMethod數組
  • 參數3:JNINativeMethod數組的長度,也就是要註冊的方法的個數

JNINativeMethod是jni中定義的一個結構體

c++ typedef struct { const char* name; //java中要註冊的native方法名 const char* signature;//方法簽名 void* fnPtr;//對應映射到C/C++中的函數指針 } JNINativeMethod;

相比靜態註冊,動態註冊的靈活性更高,如果修改了native函數所在類的包名或類名,僅調整native函數的簽名信息即可。上述案例改為動態註冊,java代碼不需要更改,只需要更改native代碼

```c++

include

include

include

define TAG "jasonwan" // 這個是自定義的LOG的標識

define LOGD(...) android_log_print(ANDROID_LOG_DEBUG,TAG,__VA_ARGS) // 定義LOGD類型

void native_jniTest(JNIEnv env, jobject thiz) { //獲取java類的實例對象 jclass clazz = env->GetObjectClass(thiz); //獲取MainActivity中num變量 jfieldID numFieldId = env->GetFieldID(clazz, "num", "I"); jint oldValue = env->GetIntField(thiz, numFieldId); //將num變量的值+1 env->SetIntField(thiz, numFieldId, oldValue + 1); //重新獲取num jint num = env->GetIntField(thiz, numFieldId); //獲取tv控件對象 jfieldID tvFieldId = env->GetFieldID(clazz, "tv", "Landroid/widget/TextView;"); jobject tvObject = env->GetObjectField(thiz, tvFieldId); jclass tvClass = env->GetObjectClass(tvObject); //獲取setText方法ID jmethodID methodId = env->GetMethodID(tvClass, "setText", "([CII)V"); //獲取setText所需的參數 //先將num轉化為jstring char buf[64]; sprintf(buf, "%d", num); jstring pJstring = env->NewStringUTF(buf); const char value = env->GetStringUTFChars(pJstring, JNI_FALSE); //創建char數組,長度為字符串num的長度 jcharArray charArray = env->NewCharArray(strlen(value)); //開闢jchar內存空間 jchar pArray = (jchar ) calloc(strlen(value), sizeof(jchar)); //將num的值緩衝到內存空間中 for (int i = 0; i < strlen(value); ++i) { (pArray + i) = (value + i); } //將緩衝的值寫入到char數組中 env->SetCharArrayRegion(charArray, 0, strlen(value), pArray); //調用setText方法 env->CallVoidMethod(tvObject, methodId, charArray, 0, env->GetArrayLength(charArray)); //釋放資源 env->ReleaseCharArrayElements(charArray, env->GetCharArrayElements(charArray, JNI_FALSE), 0); free(pArray); pArray = NULL; }

static const JNINativeMethod nativeMethod[] = { / 參數1:java中要註冊的native方法名 參數2:方法簽名 參數3:對應映射到C/C++中的函數指針 / {"jniTest", "()V;", (void *) native_jniTest}, };

//System.loadLibrary()執行時會調用此方法 extern "C" JNIEXPORT jint JNICALL JNI_OnLoad(JavaVM vm, void reversed) { JNIEnv env = NULL; // 初始化JNIEnv if (vm->GetEnv((void *) &env, JNI_VERSION_1_4) != JNI_OK) { return JNI_FALSE; } // 找到需要動態註冊的java類 jclass jniClass = env->FindClass("com/jason/jni/MainActivity"); if (nullptr == jniClass) { return JNI_FALSE; } // 動態註冊 if (env->RegisterNatives(jniClass, nativeMethod, sizeof(nativeMethod) / sizeof(nativeMethod[0])) != JNI_OK) { return JNI_FALSE; } // 返回JNI使用的版本 return JNI_VERSION_1_4; } ```

注意,在Android工程中要排除對native方法以及所在類的混淆(java工程不需要),否則要註冊的java類和java函數會找不到。proguard-rules.pro中添加

```properties

設置所有 native 方法不被混淆

-keepclasseswithmembernames class * { native ; }

不混淆類

-keep class com.jason.jni.* { ; } ```

到這裏,你應該瞭解jni的基本使用姿勢了,剩下的就是不斷的實踐來鞏固技能。附上Demo源碼:https://gitee.com/jasonwan/JNIDemo

參考文章

JNI方法註冊源碼分析

NDK 系列(5):JNI 從入門到實踐,爆肝萬字詳解!

基礎JNI語法和常見使用