安卓JNI精細化講解,讓你徹底了解JNI(二):用法解析


目錄

用法解析
├── 1、JNI函數
│ ├── 1.1、extern "C"
│ ├── 1.2、JNIEXPORT、JNICALL
│ ├── 1.3、函數名
│ ├── 1.4、JNIEnv
│ ├── 1.5、jobject
├── 2、Java、JNI、C/C++基本類型映射關系
├── 3、JNI描述符(簽名)
├── 4、函數靜態注冊、動態注冊
│ ├── 4.1、動態注冊原理
│ ├── 4.2、靜態注冊原理
│ ├── 4.3、Java調用native的流程

當通過AndroidStudio創建了Native C++工程后,首先面對的是*.cpp文件,對於不熟悉C/C++的開發人員而言,往往是望“類”興嘆,無從下手。為此,咱們系統的梳理一下JNI的用法,為后續Native開發做鋪墊。

1、JNI函數

#include <jni.h>
#include <string>

extern "C" JNIEXPORT jstring JNICALL
Java_com_qxc_testnativec_MainActivity_stringFromJNI(
        JNIEnv* env,
        jobject /* this */) {
    std::string hello = "Hello from C++";
    return env->NewStringUTF(hello.c_str());
}

通常,大家看到的JNI方法如上圖所示,方法結構與Java方法類似,同樣包含方法名、參數、返回類型,只不過多了一些修飾詞、特定參數類型而已。

1.1、extern "C"

作用:避免編繹器按照C++的方式去編繹C函數

該關鍵字可以刪掉嗎?
我們不妨動手測試一下:去掉extern “C” , 重新生成so,運行app,結果直接閃退了:

咱們反編譯so文件看一下,原來去掉extern “C” 后,函數名字竟然被修改了:

//保留extern "C"
000000000000ea98 T 
Java_com_qxc_testnativec_MainActivity_stringFromJNI

//去掉extern "C"
000000000000eab8 T 
_Z40Java_com_qxc_testnativec_MainActivity_stringFromJNIP7_JNIEnvP8_jobject

原因是什么呢?
其實這跟C和C++的函數重載差異有關系:

1、C不支持函數的重載,編譯之后函數名不變;
2、C++支持函數的重載(這點與Java一致),編譯之后函數名會改變;

原因:在C++中,存在函數的重載問題,函數的識別方式是通過:函數名,函數的返回類型,函數參數列表
三者組合來完成的。

所以,如果希望編譯后的函數名不變,應通知編譯器使用C的編譯方式編譯該函數(即:加上關鍵字:extern “C”)。

擴展:
如果即想去掉關鍵字 extern “C”,又希望方法能被正常調用,真的不能實現嗎?

非也,還是有解決辦法的:“函數的動態注冊”,這個后面再介紹吧!!!
1.2、JNIEXPORT、JNICALL
作用:

JNIEXPORT 用來表示該函數是否可導出(即:方法的可見性)
JNICALL 用來表示函數的調用規范(如:__stdcall)

我們通過JNIEXPORT、JNICALL關鍵字跳轉到jni.h中的定義,如下圖:

通過查看 jni.h 中的源碼,原來JNIEXPORT、JNICALL是兩個宏定義

對於安卓開發者來說,宏可這樣理解:

├── 宏 JNIEXPORT 代表的就是右側的表達式: __attribute__ ((visibility ("default")))
├── 或者也可以說: JNIEXPORT 是右側表達式的別名

宏可表達的內容很多,如:一個具體的數值、一個規則、一段邏輯代碼等;

attribute___((visibility ("default"))) 描述的是“可見性”屬性 visibility

1、default :表示外部可見,類似於public修飾符 (即:可以被外部調用)
2、hidden :表示隱藏,類似於private修飾符 (即:只能被內部調用)
3、其他 :略

如果,我們想使用hidden,隱藏我們寫的方法,可這么寫:

#include <jni.h>
#include <string>

extern "C" __attribute__ ((visibility ("hidden"))) jstring JNICALL
Java_com_qxc_testnativec_MainActivity_stringFromJNI(
        JNIEnv* env,
        jobject /* this */) {
    std::string hello = "Hello from C++";
    return env->NewStringUTF(hello.c_str());
}

重新編譯、運行,結果閃退了。
原因:函數Java_com_qxc_testnativec_MainActivity_stringFromJNI已被隱藏,而我們在java中調用該函數時,找不到該函數,所以拋出了異常,如下圖:

宏JNICALL 右邊是空的,說明只是個空定義。上面講了,宏JNICALL代表的是右邊定義的內容,那么,我們代碼也可直接使用右邊的內容(空)替換調JNICALL(即:去掉JNICALL關鍵字),編譯后運行,調用so仍然是正確的:

#include <jni.h>
#include <string>

extern "C" JNIEXPORT jstring
Java_com_qxc_testnativec_MainActivity_stringFromJNI(
        JNIEnv* env,
        jobject /* this */) {
    std::string hello = "Hello from C++";
    return env->NewStringUTF(hello.c_str());
}
JNICALL 知識擴展:

JNICALL的定義,並非所有平台都像Linux一樣是空的,如windows平台:
#ifndef _JAVASOFT_JNI_MD_H_  
#define _JAVASOFT_JNI_MD_H_  
#define JNIEXPORT __declspec(dllexport)  
#define JNIIMPORT __declspec(dllimport)  
#define JNICALL __stdcall  
typedef long jint;  
typedef __int64 jlong;  
typedef signed char jbyte;  
#endif
1.3、函數名

看到.cpp中的函數"Java_com_qxc_testnativec_MainActivity_stringFromJNI",大部分開發人員都會有疑問:我們定義的native函數名stringFromJNI,為什么對應到cpp中函數名會變成這么長呢?

public native String stringFromJNI();

這跟JNI native函數的注冊方式有關

JNI Native函數有兩種注冊方式(后面會詳細介紹):
1、靜態注冊:按照JNI接口規范的命名規則注冊;
2、動態注冊:在.cpp的JNI_OnLoad方法里注冊;

JNI接口規范的命名規則:

Java_<PackageName>_<ClassName>_<MethodName> 

當我們在Java中調用native方法時,JVM 也會根據這種命名規則來查找、調用native方法對應的 C 方法。

1.4、JNIEnv

JNIEnv 代表了Java環境,通過JNIEnv*就可以對Java端的代碼進行操作,如:
├──創建Java對象
├──調用Java對象的方法
├──獲取Java對象的屬性等

我們跳轉、查看JNIEnv的源碼實現,如下圖:

JNIEnv指向_JNIEnv,而_JNIEnv是定義的一個C++結構體,里面包含了很多通過JNI接口(JNINativeInterface)對象調用的方法。

那么,我們通過JNIEnv操作Java端的代碼,主要使用哪些方法呢?

函數名稱 作用
NewObject 創建Java類中的對象
NewString 創建Java類中的String對象
New Array 創建類型為Type的數組對象
Get Field 獲得類型為Type的字段
Set Field 設置類型為Type的字段
GetStatic Field 獲得類型為Type的static的字段
SetStatic Field 設置類型為Type的static的字段
Call Method 調用返回值類型為Type的static方法
CallStatic Method 調用返回值類型為Type的static方法

具體用法,后面案例再進行演示。

1.5、jobject

jobject 代表了定義native函數的Java類 或 Java類的實例:

├── 如果native函數是static,則代表類Class對象
├── 如果native函數非static,則代表類的實例對象

我們可以通過jobject訪問定義該native方法的成員方法、成員變量等。

2、Java、JNI、C/C++基本類型映射關系

上面,已經介紹了.cpp方法的基本結構、主要關鍵字。當我們定義了具體方法,寫C/C++方法實現時,會用到各種參數類型。那么,在JNI開發中,這些類型應該是怎么寫呢?
舉例:定義加、減、乘、除的方法

//加
jint addNumber(JNIEnv *env,jclass clazz,jint a,jint b){
     return a+b;
}
//減
jint subNumber(JNIEnv *env,jclass clazz,jint a,jint b){
     return a-b;
}
//乘
jint mulNumber(JNIEnv *env,jclass clazz,jint a,jint b){
     return a*b;
}
//除
jint divNumber(JNIEnv *env,jclass clazz,jint a,jint b){
     return a/b;
}

通過上面案例可以看到,幾個方法的后兩個參數、返回值,類型都是 jint

jint 是JNI中定義的類型別名,對應的是Java、C++中的int類型

我們先源碼跟蹤、看下jint的定義,jint 原來是 jni.h中 定義的 int32_t 的別名,如下圖:

根據 int32_t 查找,發現 int32_t 是 stdint.h中定義的 __int32_t的別名,如下圖:

再根據 __int32_t 查找,發現 __int32_t 是 stdint.h中定義的 int 的別名(這個也就是C/C++中的int類型了),如下圖:

Java 、C/C++都有一些常用的數據類型,分別是如何與JNI類型對應的呢?如下所示:

Java 、C/C++中的常用數據類型的映射關系表(通過源碼跟蹤查找列出來的)
JNI中定義的別名 Java類型 C/C++類型
jint / jsize int int
jshort short short
jlong long long / long long (__int64)
jbyte byte signed char
jboolean boolean unsigned char
jchar char unsigned short
jfloat float float
jdouble double double
jobject Object _jobject*

3、JNI描述符 (簽名)

JNI開發時,我們除了寫本地C/C++實現,還可以通過 JNIEnv *env 調用Java層代碼,如獲得某個字段、獲取某個函數、執行某個函數等:

//獲得某類中定義的字段id
jfieldID GetFieldID(jclass clazz, const char* name, const char* sig)
    { return functions->GetFieldID(this, clazz, name, sig); }

//獲得某類中定義的函數id
jmethodID GetMethodID(jclass clazz, const char* name, const char* sig)
    { return functions->GetMethodID(this, clazz, name, sig); }

上面的函數與Java的反射比較類似,參數:

clazz : 類的class對象
name : 字段名、函數名
sig : 字段描述符(簽名)、函數描述符(簽名)

寫過反射的開發人員對clazz、name這兩個參數應該比較熟悉,對sig稍微陌生一些。

sig 此處是指的:

1、如果是字段,表示字段類型的描述符
2、如果是函數,表示函數結構的描述符,即:每個參數類型描述符 + 返回值類型描述符

舉例( int 類型的描述符是 大寫的 I ):

Java代碼:

public class Hello{
     public int property;
     public int fun(int param, int[] arr){
          return 100;
     }
}
JNI C/C++代碼:

JNIEXPORT void Java_Hello_test(JNIEnv* env, jobject obj){
    jclass myClazz = env->GetObjectClass(obj);
    jfieldId fieldId_prop = env -> GetFieldId(myClazz, "property", "I");
    jmethodId methodId_fun = env -> GetMethodId(myClazz, "fun", "(I[I)I");
}

由上面的示例可以看到,Java類中的字段類型、函數定義分別對應的描述符:

int  類型 對應的是  I
fun  函數 對應的是  (I[I)I

其他類型的描述符(簽名)如下表:

Java類型 字段描述符(簽名) 備注
int I int的首字母、大寫
float F float的首字母、大寫
double D double的首字母、大寫
short S short的首字母、大寫
long L long的首字母、大寫
char C char的首字母、大寫
byte B byte的首字母、大寫
boolean Z 因B已被byte使用,所以JNI規定使用Z
object L + /分隔完整類名 String 如: Ljava/lang/String
array [ + 類型描述符 int[] 如:[I
Java函數 函數描述符(簽名) 備注
void V 無返回值類型
Method (參數字段描述符...)返回值字段描述符 int add(int a,int b) 如:(II)I

4、函數靜態注冊、動態注冊

JNI開發中,我們一般定義了Java native方法,又寫了對應的C方法實現。
那么,當我們在Java代碼中調用Java native方法時,虛擬機是怎么知道並調用SO庫的對應的C方法的呢?

Java native方法與C方法的對應關系,其實是通過注冊實現的,Java native方法的注冊形式有兩種,一種是靜態注冊,另一種是動態注冊:

靜態注冊:按照JNI規范書寫函數名:java_類路徑_方法名(路徑用下划線分隔)
動態注冊:JNI_OnLoad中指定Java Native函數與C函數的對應關系

兩種注冊方式的使用對比:

靜態注冊:
1、優缺點:
系統默認方式,使用簡單;
靈活性差(如果修改了java native函數所在類的包名或類名,需手動修改C函數名稱(頭文件、源文件));

2、實現方式:
1)函數名可以根據規則手寫
2)也可使用javah命令自動生成

3、示例:
extern "C" JNIEXPORT jstring
Java_com_qxc_testnativec_MainActivity_stringFromJNI(
        JNIEnv* env,
        jobject /* this */) {
    std::string hello = "Hello from C++";
    return env->NewStringUTF(hello.c_str());
}
動態注冊:
1、優缺點:
函數名看着舒服一些,但是需要在C代碼中維護Java Native函數與C函數的對應關系;
靈活性稍高(如果修改了java native函數所在類的包名或類名,僅調整Java native函數的簽名信息)

2、實現方式
env->RegisterNatives(clazz, gMethods, numMethods)

3、示例:
Java類定義Native函數:

package com.qxc.testpage;
public class JNITools {
    static {
        System.loadLibrary("jnidemo");
    }

    //加法
    public static native int  add(int a,int b);

    //減法
    public static native int sub(int a,int b);

    //乘法
    public static native int mul(int a,int b);

    //除法
    public static native int div(int a,int b);
}

.cpp中動態注冊:

JNIEXPORT jint JNI_OnLoad(JavaVM* vm, void* reserved){
    //打印日志
    __android_log_print(ANDROID_LOG_DEBUG,"JNITag","enter jni_onload");
    JNIEnv* env = NULL;
    jint result = -1;
    // 判斷是否正確
    if((*vm)->GetEnv(vm,(void**)&env,JNI_VERSION_1_6)!= JNI_OK){
        return result;
    }
    // 定義函數映射關系(參數1:java native函數,參數2:函數描述符,參數3:C函數)
    const JNINativeMethod method[]={
            {"add","(II)I",(void*)addNumber},
            {"sub","(II)I",(void*)subNumber},
            {"mul","(II)I",(void*)mulNumber},
            {"div","(II)I",(void*)divNumber}
    };
    //找到對應的JNITools類
    jclass jClassName=(*env)->FindClass(env,"com/qxc/testpage/JNITools");
    //開始注冊
    jint ret = (*env)->RegisterNatives(env,jClassName,method, 4);
     //如果注冊失敗,打印日志
    if (ret != JNI_OK) {
        __android_log_print(ANDROID_LOG_DEBUG, "JNITag", "jni_register Error");
        return -1;
    }
    return JNI_VERSION_1_6;
}

//加
jint addNumber(JNIEnv *env,jclass clazz,jint a,jint b){
     return a+b;
}
//減
jint subNumber(JNIEnv *env,jclass clazz,jint a,jint b){
     return a-b;
}
//乘
jint mulNumber(JNIEnv *env,jclass clazz,jint a,jint b){
     return a*b;
}
//除
jint divNumber(JNIEnv *env,jclass clazz,jint a,jint b){
     return a/b;
}

上面,帶着大家了解了兩種注冊方式的基本知識。接下來,咱們再深入了解一下動態注冊和靜態注冊的底層差異、以及實現原理。

4.1、動態注冊原理

動態注冊是Java代碼調用中System.loadLibray()時完成的

那么,我們先了解一下System.loadLibray加載動態庫時,底層究竟做了哪些操作:

System.loadLibray的流程圖(為了便於大家理解,此圖省略了部分流程)

底層源碼:/dalvik/vm/Native.cpp

dvmLoadNativeCode() -> JNI_OnLoad()
//省略的代碼......
//將pNewEntry保存到gDvm全局變量nativeLibs中,下次可以直接通過緩存獲取
SharedLib* pActualEntry = addSharedLibEntry(pNewEntry);
//省略的代碼......
//第一次加載so時,調用so中的JNI_OnLoad方法
vonLoad = dlsym(handle, "JNI_OnLoad");

通過System.loadLibray的流程圖,不難看出,Java中加載.so動態庫時,最終會調用so中的JNI_OnLoad方法,這也是為什么我們要在C的JNIEXPORT jint JNI_OnLoad(JavaVM vm, void* reserved)方法中注冊的原因。

接下來,咱們再深入了解一下動態注冊的具體流程:

動態注冊的具體流程圖(為了便於大家理解,此圖省略了部分流程)

如上圖所示:

流程1:是指執行 System.loadLibray函數;
流程2:是指底層默認調用so中的JNI_OnLoad函數;
流程3:是指開發人員在JNI_OnLoad中寫的注冊方法,例如: (*env)->RegisterNatives(env,.....)
流程4:需要重點講解一下:
├── 在Android中,不管是Java函數還是Java Native函數,它在虛擬機中對應的都是一個Method*對象
├── 如果是Java Native函數,那么Method*對象的nativeFunc會指向一個bridge函數dvmCallJNIMethod
├── 當調用Java Native函數時,就會執行該bridge函數,bridge函數的作用是調用該Java Native方法對應的
JNI方法,即: method.insns

流程4的主要作用,如圖所示,為Java Native函數對應的Method*對象,綁定屬性,建立對應關系:
├── nativeFunc 指向函數 dvmCallJNIMethod(通常情況下)
├── insns 指向native層的C函數指針 (我們寫的C函數)

我們再從源碼層面,重點分析一下動態注冊的流程3和流程4吧。

流程3:開發人員在JNI_OnLoad中寫的注冊方法,注冊對應的C函數

JNIEXPORT jint JNI_OnLoad(JavaVM* vm, void* reserved){
    //打印日志
    __android_log_print(ANDROID_LOG_DEBUG,"JNITag","enter jni_onload");
    JNIEnv* env = NULL;
    jint result = -1;
    // 判斷是否正確
    if((*vm)->GetEnv(vm,(void**)&env,JNI_VERSION_1_6)!= JNI_OK){
        return result;
    }
    // 定義函數映射關系(參數1:java native函數,參數2:函數描述符,參數3:C函數)
    const JNINativeMethod method[]={
            {"add","(II)I",(void*)addNumber},
            {"sub","(II)I",(void*)subNumber},
            {"mul","(II)I",(void*)mulNumber},
            {"div","(II)I",(void*)divNumber}
    };
    //找到對應的JNITools類
    jclass jClassName=(*env)->FindClass(env,"com/qxc/testpage/JNITools");
    //開始注冊
    jint ret = (*env)->RegisterNatives(env,jClassName,method, 4);
     //如果注冊失敗,打印日志
    if (ret != JNI_OK) {
        __android_log_print(ANDROID_LOG_DEBUG, "JNITag", "jni_register Error");
        return -1;
    }
    return JNI_VERSION_1_6;
}

//加
jint addNumber(JNIEnv *env,jclass clazz,jint a,jint b){
     return a+b;
}
//減
jint subNumber(JNIEnv *env,jclass clazz,jint a,jint b){
     return a-b;
}
//乘
jint mulNumber(JNIEnv *env,jclass clazz,jint a,jint b){
     return a*b;
}
//除
jint divNumber(JNIEnv *env,jclass clazz,jint a,jint b){
     return a/b;
}

C函數的定義比較簡單,共加減乘除4個函數。當動態注冊時,需調用函數 RegisterNatives(env,jClassName,method, 4)(該方法有不同參數的多個方法重載),我們主要關注的參數:jclass clazz、JNINativeMethod* methods、jint nMethods

clazz 表示:定義Java Native方法的Java類;
methods 表示:Java Native方法與C方法的對應關系;
nMethods 表示:methods注冊方法的數量,一般設置成methods數組的長度;

JNINativeMethod如何表示Java Native方法與C方法的對應關系的呢?查看其源碼定義:

jni.h

//結構體
typedef struct {
    const char* name;   //Java 方法名稱
    const char* signature;  //Java 方法描述符(簽名)
    void*       fnPtr;  //C/C++方法實現
} JNINativeMethod;

了解了JNINativeMethod結構,那么,JNINativeMethod對象是如何與虛擬機中的Method*對象對應的呢?這個有點復雜了,咱們通過流程圖簡單描述一下吧:

動態注冊的源碼流程圖(為了便於大家理解,此圖省略了部分流程)

dvmSetNativeFunc源碼分析
如果還希望更清晰的了解底層源碼的實現邏輯,可下載Android源碼,自行分析一下吧。

4.2、靜態注冊原理

靜態注冊是在首次調用Java Native函數時完成的

靜態注冊的具體流程圖(為了便於大家理解,此圖省略了部分流程)
如上圖所示:

流程1:Java代碼中調用Java Native函數;
流程2:獲得Method*對象,默認為該函數的Method*設置nativeFunc(dvmResolveNativeMethod);
流程3:dvmResolveNativeMethod函數中按照特定名稱查找對應的C方法;
流程4:如果找到了對應的C方法,重新為該方法設置Method*屬性;

注意:當Java代碼中第二次再調用Java Native函數時,Method*的nativeFunc已經有值了
(即:dvmCallJNIMethod,可參考動態注冊流程內容),會直接執行Method*的nativeFunc的函數,不會在
重新執行特定名稱查找了。

靜態注冊流程2 源碼分析

靜態注冊流程3、4 源碼分析

4.3、Java調用native的流程

Java代碼中調用Java native的流程圖(為了便於大家理解,此圖省略了部分流程)
經過對動態注冊、靜態注冊的實現原理的梳理之后,再看Java代碼中調用Java native方法的流程圖,就比較簡單了:

1、如果是動態注冊的Java native函數,System.loadLibray時就已經設置好了Java native函數與C函數的對應關系,當Java代碼中調用Java native方法時,直接執行dvmCallJNIMethod橋函數即可(該函數中執行C函數)。

2、如果是靜態注冊的Java native函數,當Java代碼中調用Java native方法時,默認為Method.nativeFunc賦值為dvmResolveNativeMethod,並按特定名稱查找C方法,重新賦值Method*,最終仍然是執行dvmCallJNIMethod橋函數(只不過Java代碼中第二次再調用靜態注冊的Java native函數時,不會再執行黃色部分的流程圖了)


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM