jni開發探索之旅

靚仔凌霄發表於2018-08-19

由於工作上的需求需要使用java和c++互調實現功能,所以要對jni進行深入研究,故此入坑。已經很久沒有寫介面佈局這方面的了,對於android開發來講,更加偏向於前端,不知道是好是壞...

JNI是什麼

JNI全程Java Native Interface,意為Java本地呼叫,它允許Java程式碼和其他語言寫的程式碼進行互動,簡單的說,一種在Java虛擬機器控制下執行程式碼的標準機制。 可以用它實現java和c語言互調。對於初學者來講,很容易吧jni和ndk的概念搞混淆(當然也可能只有博主一個人o(╯□╰)o),那jni和ndk的區別到底是什麼?

NDK是什麼

Android NDK(Native Development Kit )是一套工具集合,允許你用像C/C++語言那樣實現應用程式的一部分。 簡單的說,NDK其實多了一個把.so和.apk打包的工具,而JNI開發並沒有打包,只是把.so檔案放到檔案系統的特定位置。可以將NDK看做是Google提供的一個打包工具,方便開發者使用,有了這個工具,我們只需要關注程式碼的具體實現,而不需要關注如何編譯動態連結庫。

上手之前

先看看jni中的資料型別:

jni開發探索之旅

函式操作(只列出了一些常用的):

函式 Java資料型別 本地型別 函式說明
GetBooleanArrayElements boolean jboolean 需要呼叫ReleaseBooleanArrayElements 釋放
GetByteArrayElements byte jbyte 需要呼叫ReleaseByteArrayElements 釋放
GetCharArrayElements char jchar 需要呼叫ReleaseShortArrayElements 釋
GetObjectArrayElement 自定義物件 jobject
SetObjectArrayElement 自定義物件 jobject
NewArray 建立一個指定長度的原始資料型別的陣列
NewStringUTF jstring型別的方法轉換
DeleteLocalRef 刪除 localRef所指向的區域性引用
DeleteGlobalRef 刪除 globalRef 所指向的全域性引用
GetMethodID 返回類或介面例項(非靜態)方法的方法 ID。方法可在某個 clazz 的超類中定義,也可從 clazz 繼承。該方法由其名稱和簽名決定。 GetMethodID() 可使未初始化的類初始化。要獲得建構函式的方法 ID,應將 作為方法名,同時將void (V) 作為返回型別。
GetStaticMethodID 呼叫靜態方法
CallVoidMethod 呼叫例項方法
CallMethod

天才第一步,Hello World來一個

首先得有ndk的環境,環境配置很簡單,博主就不在這裡演示了。直接新建一個工程,勾選上c++支援:

jni開發探索之旅

然後看看Android Studio給我們生成了什麼:

jni開發探索之旅

#####初識cmake

  1. cmake是什麼:脫離 Android 開發來看,c/c++ 的編譯檔案在不同平臺是不一樣的。Unix 下會使用 makefile 檔案編譯,Windows 下會使用 project 檔案編譯。而 CMake 則是一個跨平臺的編譯工具,它並不會直接編譯出物件,而是根據自定義的語言規則(CMakeLists.txt)生成 對應 makefileproject 檔案,然後再呼叫底層的編譯。
  2. 和ndk的區別:在 Android Studio 2.2 之後你有2種選擇來編譯你寫的 c/c++ 程式碼。一個是 ndk-build + Android.mk + Application.mk 組合,另一個是 CMake + CMakeLists.txt 組合。這2個組合與Android程式碼和c/c++程式碼無關,只是不同的構建指令碼和構建命令。說白了,cmake就是ndk的替代者。

本文使用的是後者即cmake構建,這也是google官方主推的。

cmake工程和普通的工程相比就多了這三個地方,一個是CMakeLists.txt檔案,檔案內容如下:

cmake_minimum_required(VERSION 3.4.1)
add_library( # 生成的so庫名稱,此處生成的so檔名稱是libnative-lib.so
             native-lib
             # SHARED是動態庫,會被動態連結,在執行時被載入
             # STATIC:靜態庫,是目標檔案的歸檔檔案,在連結其它目標的時候使用
             # MODULE:模組庫,是不會被連結到其它目標中的外掛
             SHARED
             # 資源路徑是相對路徑,相對於本CMakeLists.txt所在目錄
             src/main/cpp/native-lib.cpp )
# 從系統查詢依賴庫
find_library( # android系統每個型別的庫會存放一個特定的位置,而log庫存放在log-lib中
              log-lib
              # android系統在c環境下打log到logcat的庫
              log )
# 配置庫的連結(依賴關係)
target_link_libraries( # 目標庫
                       native-lib
                       # 依賴於
                       ${log-lib} )
複製程式碼

註釋寫的很明確了,對於初學者,只需要注意的兩個地方是,第一處和第三處的名字必須是相同的,第二處只要你在cpp資料夾下新建了.cpp檔案,都需要在這裡申明一下,是不是有點像清單檔案的感覺。

jni開發探索之旅

關於cmake的具體使用,網上有很多教程,博主就不多說了。

cpp檔案分析

然後就是.cpp檔案裡的內容了:

#include <jni.h>
#include <string>
extern "C" JNIEXPORT jstring
JNICALL
Java_com_ndk_lingxiao_ndkproject_MainActivity_stringFromJNI(
        JNIEnv *env,
        jobject /* this */) {
    std::string hello = "Hello from C++";
    return env->NewStringUTF(hello.c_str());
}
複製程式碼

一個一個分析。

  1. 首先前兩句是標頭檔案,沒什麼好說的。
  2. extern "C"主要作用就是為了能夠正確實現C++程式碼呼叫其他C語言程式碼 ,也就是相容c語言。
  3. JNIEXPORT 在Jni程式設計中所有本地語言實現Jni介面的方法前面都有一個"JNIEXPORT",這個可以看做是Jni的一個標誌,表示此函式是被jni呼叫的
  4. jstring 返回值型別是string型別的
  5. JNICALL 這個可以理解為Jni 和Call兩個部分,和起來的意思就是 Jni呼叫XXX(後面的XXX就是JAVA的方法名)
  6. Java_com_ndk_lingxiao_ndkproject_MainActivity_stringFromJNI,別看這玩意兒這麼長,他就是嚇唬你的,我相信人有所長,你一定比他長,不要被嚇到[]~( ̄▽ ̄)~*。固定寫法Java_+類名全路徑+方法名,只是把類名的“.”替換為了下劃線""。很簡單的有木有。
  7. JNIEnv * env:這個env可以看做是Jni介面本身的一個物件,jni.h標頭檔案中存在著大量被封裝好的函式,這些函式也是Jni程式設計中經常被使用到的,要想呼叫這些函式就需要使用JNIEnv這個物件。例如:env->GetObjectClass()。
  8. jobject obj 有兩種情況,一種是可以看做Java類的一個例項化物件 ,如Hello hello = new Hello(),hello.method(),這時候的obj 就是hello。哎,一不小心又new了一個物件出來。一種是可以看做是java類的本身 ,如果method是靜態方法,它不是屬於一個物件的,而是屬於一個類的 ,這時候就代表Hello.class。
  9. std::string hello = "Hello from C++" 相當於stirng str = "Hello from C++",但是c++的字串和java的字串不一樣,所以需要轉換一下再返回,所以通過env物件呼叫方法轉換為java能識別的env->NewStringUTF(hello.c_str())

cpp檔案也講完了,現在看看MainActivity裡的程式碼:

public class MainActivity extends AppCompatActivity {
    static {
        System.loadLibrary("native-lib");
    }
    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);

        // Example of a call to a native method
        TextView tv = (TextView) findViewById(R.id.sample_text);
        tv.setText(stringFromJNI());
    }
    public static native String stringFromJNI();
}
複製程式碼

只需要將一下那個靜態程式碼塊,loadLibrary的時候,本來生成的.so檔案為libnative-lib.so但是這裡沒有加是android studio會自動給我們加上去,如果這裡再加上就會重複,所以只需要填寫和CMakeLists.txt裡的命名相同就行了。

c語言裡列印Log

首先在module級的build.gradle里加入:

defaultConfig {
		ndk{
             ldLibs "gomp"
           }
       }
複製程式碼

然後在cpp中加入如下的巨集定義:

#include <android/log.h>
#define LOG_TAG "NATIVE_LIB"

#define DEBUG
#define ANDROID_PLATFORM

#ifdef DEBUG
#ifdef ANDROID_PLATFORM
#define LOGD(...) ((void)__android_log_print(ANDROID_LOG_DEBUG, LOG_TAG, __VA_ARGS__))
#define LOGI(...) ((void)__android_log_print(ANDROID_LOG_INFO, LOG_TAG, __VA_ARGS__))
#define LOGW(...) ((void)__android_log_print(ANDROID_LOG_WARN, LOG_TAG, __VA_ARGS__))
#define LOGE(...) ((void)__android_log_print(ANDROID_LOG_ERROR, LOG_TAG, __VA_ARGS__))
#else
#define LOGD(fmt, ...) printf(fmt"\n", ##__VA_ARGS__)
#define LOGI(fmt, ...) printf(fmt"\n", ##__VA_ARGS__)
#define LOGW(fmt, ...) printf(fmt"\n", ##__VA_ARGS__)
#define LOGE(fmt, ...) printf(fmt"\n", ##__VA_ARGS__)
#endif
#else
#define LOGD(...)
#define LOGI(...)
#define LOGW(...)
#define LOGE(...)
#endif
複製程式碼

搞定,這個是固定寫法,沒什麼好說的。

java呼叫C++方法

這個比較簡單,這裡就隨便提一下,首先我新建了一個Hello類,寫了兩個方法,android studio會提示是否生成方法:

jni開發探索之旅

生成方法之後我只加了兩句列印:

extern "C"
JNIEXPORT void JNICALL
Java_com_ndk_lingxiao_ndkproject_Hello_callStaticMethod(JNIEnv *env, jclass type, jint i) {
    LOGD("im from static moethod C++ , value is : %d",i);
}

extern "C"
JNIEXPORT void JNICALL
Java_com_ndk_lingxiao_ndkproject_Hello_callInstanceMethod
        (JNIEnv *, jobject, jint i){
    LOGD("im from instance moethod C++ , value is : %d",i);
}
複製程式碼

然後在相應的地方呼叫一下,我是在MainActivity中呼叫的:

jni開發探索之旅

然後看一下後面的重點,c++中呼叫java層的方法和修改java層的屬性。

方法簽名

在學習c++呼叫java方法時需要了解的是方法簽名,關於方法簽名,我覺得只要關注這兩個地方就行了:

  1. 什麼是方法簽名:方法簽名由方法名稱和一個引數列表(方法的引數的順序和型別)組成。
  2. 為什麼要用方法簽名:c語言中沒有方法過載這個概念,如果java中有兩個方法:long test(int n, String str, int[] arr) ,long test(String str) 。那麼沒有方法簽名來標註一下,編譯器不就懵逼了嘛(ノ`Д)ノ。

下面有請方法簽名規則表開始表演:

Java型別 簽名型別
boolean Z
byte B
char C
long J
float F
double D
short S
int I
L全限定類名
陣列 [全限定類名

上述中類的簽名規則是:”L+全限定類名+;”三部分組成,其中全限定類名以”/”分隔,而不是用”.”或”_”分隔。

比如剛剛說的那兩個方法:

  1. long test(String str) :方法簽名為(Ljava/lang/String;)J ,括號裡的內容代表string括號後面是返回值型別簽名,J代表long型。
  2. long test(int n, String str, int[] arr) :其方法簽名為(ILjava/lang/String;[I)J括號裡的內容分成三部分,之間沒有空格,即”I”,”Ljava/lang/String;”和”[I”,分別代表int,String,int[]

有迷妹私信我了:這麼複雜的嗎?有沒有簡單快捷的方法,每次都這麼麻煩,太浪費時間了吧!我的時間很寶貴的嚶嚶嚶,要是沒有我砍死你

30米的大刀

很大方的(迫不得已)交出偷懶方法:

javap -s 類的.class路徑
複製程式碼

可以說是很直觀了(逃),博主用的as3.1,所以這個目錄在工程,目錄\module目錄\build\intermediates\classes\debug下面。得到方法簽名之後,就可以開始下面的操作了

C++呼叫Java靜態方法

在java中寫了一個這樣的方法:

public static void staticMethod(String data){
        logMessage(data);
    }
public static void logMessage(String data){
        Log.d("hello", data);
    }
複製程式碼

我希望在cpp中呼叫staticMethod方法,該怎麼做呢?先貼程式碼:

extern "C"
JNIEXPORT void JNICALL
Java_com_ndk_lingxiao_ndkproject_Hello_callJavaStaticMethod(JNIEnv *env, jclass type) {
    jclass clazz = NULL;
    jmethodID method_id = NULL;
    jstring str_log = NULL;

    clazz = env->FindClass("com/ndk/lingxiao/ndkproject/Hello");
    if (clazz == NULL){
        LOGD("沒有發現該類");
        return;
    }
    method_id = env->GetStaticMethodID(clazz,"staticMethod","(Ljava/lang/String;)V");
    if (method_id == NULL){
        LOGD("沒有發現該方法名");
        return;
    }
    str_log = env->NewStringUTF("c++ 呼叫java的靜態方法");
    env->CallStaticVoidMethod(clazz,method_id,str_log);

    env->DeleteLocalRef(clazz);
    env->DeleteLocalRef(str_log);
    return ;
}
複製程式碼

這裡如果對jvm虛擬機器比較瞭解的同學可能會更容易理解,博主正在瞭解中,所以假裝解釋一波,只是按照我自己的理解,來解釋,可能後面會改動(~ ̄▽ ̄)~ 。

首先定義了三個變數,然後使用env呼叫封裝好的方法FindClass,傳入類名全路徑,在jvm中如果有載入這個類,那麼就會返回我們的這個類。

接著是獲取方法的id,使用env呼叫GetStaticMethodID,第一個引數是方法所在的類,第二個是方法名,第三個是方法簽名。

然後使用env呼叫CallStaticVoidMethod,傳入類和方法和引數,完成對java層方法的呼叫。

最後不要忘記刪除引用,不然會發生記憶體洩漏。

C++呼叫Java例項方法

和靜態方法的區別就兩個地方,一個是GetStaticMethodID,一個是CallStaticVoidMethod:

extern "C"
JNIEXPORT void JNICALL
Java_com_ndk_lingxiao_ndkproject_Hello_callJavaInstanceMethod(JNIEnv *env, jobject instance) {
    jclass clazz = NULL;
    jmethodID method_id = NULL;
    jstring str_log = NULL;

    clazz = env->FindClass("com/ndk/lingxiao/ndkproject/Hello");
    if (clazz == NULL){
        LOGD("沒有發現該類");
        return;
    }
    method_id = env->GetMethodID(clazz,"instanceMethod","(Ljava/lang/String;)V");
    if (method_id == NULL){
        LOGD("沒有發現該方法名");
        return;
    }
    str_log = env->NewStringUTF("c++ 呼叫java的例項方法");
    env->CallVoidMethod(instance,method_id,str_log); //clazz 改為instance

    env->DeleteLocalRef(clazz);
    env->DeleteLocalRef(str_log);
    return ;
}
複製程式碼
C++呼叫Java變數

首先在java類中定義一個變數:

public String name = "im is java";
複製程式碼

然後貼上jni程式碼,主要方法是GetFieldID,第一個引數傳入變數所在類,第二個引數是變數名,第三個引數是簽名型別:

extern "C"
JNIEXPORT void JNICALL
Java_com_ndk_lingxiao_ndkproject_Hello_changeField(JNIEnv *env, jobject instance) {
    jclass clazz = env->GetObjectClass(instance);
    if (clazz == NULL){
        return;
    }
    jfieldID jfieldID = env->GetFieldID(clazz,"name","Ljava/lang/String;");
    if (jfieldID == NULL){
        return;
    }
    jstring obj_str = (jstring) env->GetObjectField(instance,jfieldID);
    if (obj_str == NULL){
        return;
    }
    char* c_str = (char*) env->GetStringUTFChars(obj_str,JNI_FALSE);

    const char new_char[40] = "changed from c";
    //複製new_char的內容到c_str
    strcpy(c_str,new_char);

    jstring new_str = env->NewStringUTF(c_str);
    LOGD("%s",new_char);
    env->SetObjectField(instance,jfieldID,new_str);

    env->DeleteLocalRef(clazz);
    env->DeleteLocalRef(obj_str);
    env->DeleteLocalRef(new_str);
    return;
}
複製程式碼
C++呼叫Java靜態變數

同理,靜態變數也沒啥好講的了,這裡就貼一下程式碼:

extern "C"
JNIEXPORT void JNICALL
Java_com_ndk_lingxiao_ndkproject_Hello_changeStaticField(JNIEnv *env, jclass type) {
    jclass clazz = env->FindClass("com/ndk/lingxiao/ndkproject/Hello");
    if (clazz == NULL){
        return;
    }
    jfieldID jfieldID = env->GetStaticFieldID(clazz,"age","I");
    if (jfieldID == NULL){
        return;
    }
    int age = env->GetStaticIntField(clazz,jfieldID);
    LOGD("%d",age);
    jint change_int = 12;
    env->SetStaticIntField(clazz,jfieldID,change_int);

    env->DeleteLocalRef(clazz);
}
複製程式碼

學習JNI,個人建議是在平常的工作中能用到的才去深入學習,因為這個東西只有實踐才有意義。關於如何在native中排查錯誤,可以使用ndk-stack工具,使用方法賊簡單,一個命令列的事兒,這裡就不說了。

本文demo的github地址:NdkDemo

參考連結:

JNI實戰全面解析

Android NDK開發掃盲及最新CMake的編譯使用

相關文章