Android - JNI 開發你所需要知道的基礎

這篇文章主要講解了 JNI 的基礎語法和交叉編譯的基本使用,通過這篇文章的學習就完全可以入門 Android 下 JNI 項目的開發了。

JNI 概念


從 JVM 角度,存在兩種類型的代碼:“Java”和“native”, native 一般指的是 c/c++,爲了使 java 和 native 端能夠進行交互,java 設計了 JNI(java native interface)。 JNI 允許java虛擬機(VM)內運行的java代碼與C++、C++和彙編等其他編程語言編寫的應用程序和庫進行互操作。

雖然大部分情況下我們的軟件完全可以由 java 來實現,但是某些場景下使用 native 代碼更加適合,比如:

  • 代碼效率:使用 native 代碼的性能更高
  • 跨平臺特性:標準Java類庫不支持應用程序所需的依賴於平臺的特性,或者希望用較低級別的語言(如彙編語言)實現一小部分時間關鍵型代碼。

native 層使用 JNI 主要可以做到:

  • 創建、檢查和更新Java對象(包括數組和字符串)。
  • 調用Java方法。
  • 加載類並獲取類信息。

創建 android ndk 項目


使用 as 創建一個 native c++ 項目

文件結構如下:

可以看到生成了一個 cpp 文件夾,裏面有 CMakeLists.txt, native-lib.cpp,CMakeLists後面再講,這裏先來看一下 native-lib.cpp 和 java 代碼。

public class MainActivity extends AppCompatActivity {
    static {
        System.loadLibrary("native-lib");
    }
    ...
    public native String stringFromJNI();
}
#include <jni.h>
#include <string>

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

可以看到在 MainActivity 中先定義了一個 native 方法,然後編譯器在 cpp 文件中創建一個一個對應的方法Java_com_wangzhen_jnitutorial_MainActivity_stringFromJNI。 它的命名規則就是 Java_packageName_methodName。

接下來我們詳細的解讀一下 cpp 中的代碼。

native 代碼解讀


extern "C"

在 c++ 中使用 c 代碼

JNIEXPORT

宏定義:#define JNIEXPORT __attribute__ ((visibility ("default"))) 在 Linux/Unix/Mac os/Android 這種類 Unix 系統中,定義爲__attribute__ ((visibility ("default")))

GCC 有個visibility屬性, 該屬性是說, 啓用這個屬性:

  • 當-fvisibility=hidden時,動態庫中的函數默認是被隱藏的即 hidden。
  • 當-fvisibility=default時,動態庫中的函數默認是可見的。

JNICALL

宏定義,在 Linux/Unix/Mac os/Android 這種類 Unix 系統中,它是個空的宏定義: #define JNICALL,所以在 android 上刪除它也可以。 快捷生成 .h 代碼

JNIEnv

  • JNIEnv類型實際上代表了Java環境,通過這個 JNIEnv* 指針,就可以對 Java 端的代碼進行操作:
    1. 調用 Java 函數
    2. 操作 Java 對象
  • JNIEnv 的本質是一個與線程相關的結構體,裏面存放了大量的 JNI 函數指針:
struct _JNIEnv {
    /**
    * 定義了很多的函數指針
    **/
    const struct JNINativeInterface* functions;

#if defined(__cplusplus)
    /// 通過類的名稱(類的全名,這時候包名不是用.號,而是用/來區分的)來獲取jclass    
    jclass FindClass(const char* name)
    { return functions->FindClass(this, name); }
    ...
}    

JNIEnv 的結構圖如下:

JavaVM

  • JavaVM : JavaVM 是 Java虛擬機在 JNI 層的代表, JNI 全局只有一個

  • JNIEnv : JavaVM 在線程中的代表, 每個線程都有一個, JNI 中可能有很多個 JNIEnv,同時 JNIEnv 具有線程相關性,也就是 B 線程無法使用 A 線程的 JNIEnv

JVM 的結構圖如下:

jobject thiz

這個 object 指向該 native 方法的 this 實例,比如我們在 MainActivity 調用的下面的 native 函數中打印一下 thiz 的 className:

#define  LOGE(...) __android_log_print(ANDROID_LOG_ERROR,"JNI",__VA_ARGS__);

extern "C" JNIEXPORT jstring JNICALL
Java_com_wangzhen_jnitutorial_MainActivity_stringFromJNI(JNIEnv *env, jobject thiz) {
    std::string hello = "Hello from C++";
    // 1. 獲取 thiz 的 class,也就是 java 中的 Class 信息
    jclass thisclazz = env->GetObjectClass(thiz);
    // 2. 根據 Class 獲取 getClass 方法的 methodID,第三個參數是簽名(params)return
    jmethodID mid_getClass = env->GetMethodID(thisclazz, "getClass", "()Ljava/lang/Class;");
    // 3. 執行 getClass 方法,獲得 Class 對象
    jobject clazz_instance = env->CallObjectMethod(thiz, mid_getClass);
    // 4. 獲取 Class 實例
    jclass clazz = env->GetObjectClass(clazz_instance);
    // 5. 根據 class  的 methodID
    jmethodID mid_getName = env->GetMethodID(clazz, "getName", "()Ljava/lang/String;");
    // 6. 調用 getName 方法
    jstring name = static_cast<jstring>(env->CallObjectMethod(clazz_instance, mid_getName));
    LOGE("class name:%s", env->GetStringUTFChars(name, 0));

    return env->NewStringUTF(hello.c_str());
}

打印結果如下:

JNI 基礎

數據類型

基礎數據類型

Java Type Native Type Description
boolean jboolean unsigned 8 bits
byte jbyte signed 8 bits
char jchar unsigned 16 bits
short jshort signed 16 bits
int jint signed 32 bits
long jlong signed 64 bits
float jfloat 32 bits
double jdouble 64 bits
void void N/A

引用類型

這裏貼一張 oracle 文檔中的圖,雖然很醜但挺好:

Field and Method IDs

JNIEvn 操作 java 對象時利用 java 中的反射,操作某個屬性都需要 field 和 method 的 id,這些 id 都是指針類型:

struct _jfieldID;              /* opaque structure */ 
typedef struct _jfieldID *jfieldID;   /* field IDs */ 
 
struct _jmethodID;              /* opaque structure */ 
typedef struct _jmethodID *jmethodID; /* method IDs */ 

JNI 操作 java 對象

操作 jarray

將一個 Java int[] 對象傳入 C++ 中,如何操作這個數組呢?

JNIEXPORT void JNICALL
Java_com_wangzhen_jnitutorial_MainActivity_setArray(JNIEnv *env, jobject thiz, jintArray array) {

    // 1.獲取數組長度
    jint len = env->GetArrayLength(array);
    LOGE("array.length:%d", len);

    jboolean isCopy;
    // 2.獲取數組地址 
    // 第二個參數代表 javaArray -> c/c++ Array 轉換的方式:
    // 0: 把指向Java數組的指針直接傳回到本地代碼中
    // 1: 新申請了內存,拷貝了數組
    // 返回值: 數組的地址(首元素地址)
    jint *firstElement = env->GetIntArrayElements(array, &isCopy);
    LOGE("is copy array:%d", isCopy);
    // 3.遍歷數組(移動地址)
    for (int i = 0; i < len; ++i) {
        LOGE("array[%i] = %i", i, *(firstElement + i));
    }
    // 4.使用後釋放數組
    // 第一個參數是 jarray,第二個參數是 GetIntArrayElements 返回值 
    // 第三個參數代表 mode
    env->ReleaseIntArrayElements(array,firstElement,0);

    // 5. 創建一個 java 數組
    jintArray newArray = env->NewIntArray(3);
}
  • mode = 0 刷新java數組 並 釋放c/c++數組
  • mode = JNI_COMMIT (1) 只刷新java數組
  • mode = JNI_ABORT (2) 只釋放c/c++數組

操作 jstring

extern "C"
JNIEXPORT void JNICALL
Java_com_wangzhen_jnitutorial_MainActivity_setString(JNIEnv *env, jobject thiz, jstring str) {
    // 1.jstring -> char*
    // java  中的字符創是 unicode 編碼, c/C++ 是UTF編碼,所以需要轉換一下。第二個參數作用同上面
    const char *c_str = env -> GetStringUTFChars(str,NULL);

    // 2.異常處理
    if(c_str == NULL){
        return;
    }

    // 3.當做一個 char 數組打印
    jint len = env->GetStringLength(str);
    for (int i = 0; i < len; ++i) {
        LOGE("c_str: %c",*(c_str+i));
    }

    // 4.釋放
    env->ReleaseStringUTFChars(str,c_str);
}

調用完 GetStringUTFChars 之後不要忘記安全檢查,因爲 JVM 需要爲新誕生的字符串分配內存空間,當內存空間不夠分配的時候,會導致調用失敗,失敗後 GetStringUTFChars 會返回 NULL,並拋出一個OutOfMemoryError 異常。JNI 的異常和 Java 中的異常處理流程是不一樣的,Java 遇到異常如果沒有捕獲,程序會立即停止運行。而 JNI 遇到未決的異常不會改變程序的運行流程,也就是程序會繼續往下走,這樣後面針對這個字符串的所有操作都是非常危險的,因此,我們需要用 return 語句跳過後面的代碼,並立即結束當前方法。

操作 jobject

  • c/c++ 操作 java 中的對象使用的是 java 中反射,步驟分爲:
  • 獲取 class 類
  • 根據成員變量名獲取 methodID / fieldID
  • 調用 get/set 方法操作 field,或者 CallObjectMethod 調用 method

操作 Field

  • 非靜態成員變量使用: GetXXXField,比如 GetIntField,對於引用類型,比如 String,使用 GetObjectField
  • 對於靜態成員變量使用: GetStaticXXXField,比如 GetStaticIntField

在 java 代碼中,MainActivity 有兩個成員變量:

public class MainActivity extends AppCompatActivity {

    String testField = "test1";

    static int staticField = 1;
}
    // 1. 獲取類 class
    jclass clazz = env->GetObjectClass(thiz);

    // 2. 獲取成員變量 id
    jfieldID strFieldId = env->GetFieldID(clazz,"testField","Ljava/lang/String;");
    // 3. 根據 id 獲取值
    jstring jstr = static_cast<jstring>(env->GetObjectField(thiz, strFieldId));
    const char* cStr = env->GetStringUTFChars(jstr,NULL);
    LOGE("獲取 MainActivity 的 String field :%s",cStr);

    // 4. 修改 String
    jstring newValue = env->NewStringUTF("新的字符創");
    env-> SetObjectField(thiz,strFieldId,newValue);

    // 5. 釋放資源
    env->ReleaseStringUTFChars(jstr,cStr);
    env->DeleteLocalRef(newValue);
    env->DeleteLocalRef(clazz);
    
    // 獲取靜態變量
    jfieldID staticIntFieldId = env->GetStaticFieldID(clazz,"staticField","I");
    jint staticJavaInt = env->GetStaticIntField(clazz,staticIntFieldId);

GetFieldID 和 GetStaticFieldID 需要三個參數:

  • jclass
  • filed name
  • 類型簽名: JNI 使用 jvm 的類型簽名

類型簽名一覽表

Type Signature Java Type
Z boolean
B byte
C char
S short
I int
J long
F float
D double
V void
L fully-qualified-class; fully-qualified-class
[type type[]
(arg-types) ret-type method type
  • 基本數據類型的比較好理解,不如要獲取一個 int ,GetFieldID 需要傳入簽名就是 I;

  • 如果是一個類,比如 String,簽名就是 L+全類名; :Ljava.lang.String;

  • 如果是一個 int array,就要寫作 [I

  • 如果要獲取一個方法,那麼方法的簽名是:(參數簽名)返回值簽名,參數如果是多個,中間不需要加間隔符,比如: | java 方法|JNI 簽名| |--|--| |void f (int n); |(I)V| |void f (String s,int n); |(Ljava/lang/String;I)V| |long f (int n, String s, int[] arr); |(ILjava/lang/String;[I)J|

操作 method

操作 method 和 filed 非常相似,先獲取 MethodID,然後對應的 CallXXXMethod 方法

Java層返回值 方法族 本地返回類型NativeType
void CallVoidMethod() (無)
引用類型 CallObjectMethod( ) jobect
boolean CallBooleanMethod ( ) jboolean
byte CallByteMethod( ) jbyte
char CallCharMethod( ) jchar
short CallShortMethod( ) jshort
int CallIntMethod( ) jint
long CallLongMethod() jlong
float CallFloatMethod() jfloat
double CallDoubleMethod() jdouble

在 java 中我們要想獲取 MainActivity 的 className 會這樣寫:

 this.getClass().getName()

可以看到需要先調用 getClass 方法獲取 Class 對象,然後調用 Class 對象的 getName 方法,我們來看一下如何在 native 方法中調用:

extern "C" JNIEXPORT jstring JNICALL
Java_com_wangzhen_jnitutorial_MainActivity_stringFromJNI(JNIEnv *env, jobject thiz) {
    std::string hello = "Hello from C++";
    // 1. 獲取 thiz 的 class,也就是 java 中的 Class 信息
    jclass thisclazz = env->GetObjectClass(thiz);
    // 2. 根據 Class 獲取 getClass 方法的 methodID,第三個參數是簽名(params)return
    jmethodID mid_getClass = env->GetMethodID(thisclazz, "getClass", "()Ljava/lang/Class;");
    // 3. 執行 getClass 方法,獲得 Class 對象
    jobject clazz_instance = env->CallObjectMethod(thiz, mid_getClass);
    // 4. 獲取 Class 實例
    jclass clazz = env->GetObjectClass(clazz_instance);
    // 5. 根據 class  的 methodID
    jmethodID mid_getName = env->GetMethodID(clazz, "getName", "()Ljava/lang/String;");
    // 6. 調用 getName 方法
    jstring name = static_cast<jstring>(env->CallObjectMethod(clazz_instance, mid_getName));
    LOGE("class name:%s", env->GetStringUTFChars(name, 0));
    
    // 7. 釋放資源
    env->DeleteLocalRef(thisclazz);
    env->DeleteLocalRef(clazz);
    env->DeleteLocalRef(clazz_instance);
    env->DeleteLocalRef(name);
    
    return env->NewStringUTF(hello.c_str());
}

創建對象

首先定義一個 java 類:

public class Person {
    private int age;
    private String name;

    public Person(int age, String name){
        this.age = age;
        this.name = name;
    }

    public void print(){
        Log.e("Person",name + age + "歲了");
    }
}

然後我們再 JNI 中創建一個 Person 並調用它的 print 方法:

// 1. 獲取 Class
    jclass pClazz = env->FindClass("com/wangzhen/jnitutorial/Person");
    // 2. 獲取構造方法,方法名固定爲<init>
    jmethodID constructID = env->GetMethodID(pClazz,"<init>","(ILjava/lang/String;)V");
    if(constructID == NULL){
        return;
    }
    // 3. 創建一個 Person 對象
    jstring name = env->NewStringUTF("alex");
    jobject person = env->NewObject(pClazz,constructID,1,name);

    jmethodID printId = env->GetMethodID(pClazz,"print","()V");
    if(printId == NULL){
        return;
    }
    env->CallVoidMethod(person,printId);

    // 4. 釋放資源
    env->DeleteLocalRef(name);
    env->DeleteLocalRef(pClazz);
    env->DeleteLocalRef(person);

JNI 引用


JNI 分爲三種引用:

  • 局部引用(Local Reference),類似 java 中的局部變量
  • 全局引用(Global Reference),類似 java 中的全局變量
  • 弱全局引用(Weak Global Reference),類似 java 中的弱引用

上面的代碼片段中最後都會有釋放資源的代碼,這是 c/c++ 編程的良好習慣,對於不同 JNI 引用有不同的釋放方式。

 

局部引用


創建

JNI 函數返回的所有 Java 對象都是局部引用,比如上面調用的 NewObject/FindClass/NewStringUTF 等等都是局部引用。

 

釋放

  • 自動釋放 局部引用在方法調用期間有效,並在方法返回後被 JVM 自動釋放。
  • 手動釋放

手動釋放的場景

有了自動釋放之後爲什麼還需要手動釋放呢?主要考慮一下場景:

  • 本機方法訪問大型Java對象,從而創建對Java對象的局部引用。然後,本機方法在返回到調用方之前執行附加計算。對大型Java對象的本地引用將防止對該對象進行垃圾收集,即使該對象不再用於計算的其餘部分。
  • 本機方法創建大量本地引用,但並非所有本地引用都同時使用。因爲 JVM 需要一定的空間來跟蹤本地引用,所以創建了太多的本地引用,這可能導致系統內存不足。例如,本機方法循環遍歷一個大型對象數組,檢索作爲本地引用的元素,並在每次迭代時對一個元素進行操作。每次迭代之後,程序員不再需要對數組元素的本地引用。

所以我們應該養成手動釋放本地引用的好習慣。

手動釋放的方式

  • GetXXX 就必須調用 ReleaseXXX。

在調用 GetStringUTFChars 函數從 JVM 內部獲取一個字符串之後,JVM 內部會分配一塊新的內存,用於存儲源字符串的拷貝,以便本地代碼訪問和修改。即然有內存分配,用完之後馬上釋放是一個編程的好習慣。通過調用ReleaseStringUTFChars 函數通知 JVM 這塊內存已經不使用了。

  • 對於手動創建的 jclass,jobject 等對象使用 DeleteLocalRef 方法進行釋放

全局引用

創建

JNI 允許程序員從局部引用創建全局引用:

 static jstring globalStr;
 if(globalStr == NULL){
   jstring str = env->NewStringUTF("C++");
   // 從局部變量 str 創建一個全局變量
   globalStr = static_cast<jstring>(env->NewGlobalRef(str));
   
   //局部可以釋放,因爲有了一個全局引用使用str,局部str也不會使用了
    env->DeleteLocalRef(str);
    }

釋放

全局引用在顯式釋放之前保持有效,可以通過 DeleteGlobalRef 來手動刪除全局引用調用。

 

弱全局引用


與全局引用類似,弱引用可以跨方法、線程使用。與全局引用不同的是,弱引用不會阻止GC回收它所指向的VM內部的對象

所以在使用弱引用時,必須先檢查緩存過的弱引用是指向活動的對象,還是指向一個已經被GC的對象

創建

    static jclass globalClazz = NULL;
    //對於弱引用 如果引用的對象被回收返回 true,否則爲false
    //對於局部和全局引用則判斷是否引用java的null對象
    jboolean isEqual = env->IsSameObject(globalClazz, NULL);
    if (globalClazz == NULL || isEqual) {
        jclass clazz = env->GetObjectClass(instance);
        globalClazz = static_cast<jclass>(env->NewWeakGlobalRef(clazz));
        env->DeleteLocalRef(clazz);
    }

釋放

刪除使用 DeleteWeakGlobalRef

線程相關

局部變量只能在當前線程使用,而全局引用可以跨方法、跨線程使用,直到它被手動釋放纔會失效。

 

加載動態庫


在 android 中有兩種方式加載動態庫:

  • System.load(String filename) // 絕對路徑
  • system library path // 從 system lib 路徑下加載

比如下面代碼會報錯,在 java.library.path 下找不到 hello

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

可以使用下面代碼打印出 java.library.path ,並且吧 hello 拷貝到改路徑下:

public static void main(String[] args){
    System.out.println(System.getProperty("java.library.path"));
}

JNI_OnLoad

調用System.loadLibrary()函數時, 內部就會去查找so中的 JNI_OnLoad 函數,如果存在此函數則調用。 JNI_OnLoad 必須返回 JNI 的版本,比如 JNI_VERSION_1_6、JNI_VERSION_1_8。

動態註冊

JNI 匹配對應的 java 方法有兩種方式:

  • 靜態註冊: 之前我們使用的 Java_com_wangzhen_jnitutorial_MainActivity_stringFromJNI 來進行與java方法的匹配就是靜態註冊
  • 動態註冊:就是將 java 中的方法在代碼中動態的與 JNI 方法對應起來

靜態註冊的名字需要包名,太長了,可以使用動態註冊來縮短方法名。

比如我們再 Java 中有兩個 native 方法:

public class MainActivity extends AppCompatActivity {
    public native void dynamicJavaFunc1();

    public native int dynamicJavaFunc2(int i);
}

在 native 代碼中,我們不使用靜態註冊,而使用動態註冊

void dynamicNativeFunc1(){
    LOGE("調用了 dynamicJavaFunc1");
}
// 如果方法帶有參數,前面要加上 JNIEnv *env, jobject thisz
jint dynamicNativeFunc2(JNIEnv *env, jobject thisz,jint i){
    LOGE("調用了 dynamicTest2,參數是:%d",i);
    return 66;
}

// 需要動態註冊的方法數組
static const JNINativeMethod methods[] = {
        {"dynamicJavaFunc1","()V",(void*)dynamicNativeFunc1},
        {"dynamicJavaFunc2","(I)I",(int*)dynamicNativeFunc2},
};
// 需要動態註冊native方法的類名
static const char *mClassName = "com/wangzhen/jnitutorial/MainActivity";


jint JNI_OnLoad(JavaVM* vm, void* reserved){
    JNIEnv* env = NULL;
    // 1. 獲取 JNIEnv,這個地方要注意第一個參數是個二級指針
    int result = vm->GetEnv(reinterpret_cast<void **>(&env), JNI_VERSION_1_6);
    // 2. 是否獲取成功
    if(result != JNI_OK){
        LOGE("獲取 env 失敗");
        return JNI_VERSION_1_6;
    }
    // 3. 註冊方法
    jclass classMainActivity = env->FindClass(mClassName);
    // sizeof(methods)/ sizeof(JNINativeMethod)
    result = env->RegisterNatives(classMainActivity,methods, 2);

    if(result != JNI_OK){
        LOGE("註冊方法失敗")
        return JNI_VERSION_1_2;
    }

    return JNI_VERSION_1_6;
}

這樣我們再 MainActivity 中調用 dynamicJavaFunc1 方法就會調用 native 中的 dynamicNativeFunc1 方法。

native 線程中調用 JNIEnv*

前面介紹過 JNIEnv* 是和線程相關的,那麼如果在 c++ 中新建一個線程A,在線程A 中可以直接使用 JNIEnv* 嗎? 答案是否定的,如果想在 native 線程中使用 JNIEnv* 需要使用 JVM 的 AttachCurrentThread 方法進行綁定:

JavaVM *_vm;

jint JNI_OnLoad(JavaVM* vm, void* reserved){
    _vm = vm;
    return JNI_VERSION_1_6;
 }

void* threadTask(void* args){
    JNIEnv *env;
    jint result = _vm->AttachCurrentThread(&env,0);
    if (result != JNI_OK){
        return 0;
    }

    // ...

    // 線程 task 執行完後不要忘記分離
    _vm->DetachCurrentThread();
}

extern "C"
JNIEXPORT void JNICALL
Java_com_wangzhen_jnitutorial_MainActivity_nativeThreadTest(JNIEnv *env, jobject thiz) {
    pthread_t pid;
    pthread_create(&pid,0,threadTask,0);
}

交叉編譯


在一個平臺上編譯出另一個平臺上可以執行的二級制文件的過程叫做交叉編譯。比如在 MacOS 上編譯出 android 上可用的庫文件。 如果想要編譯出可以在 android 平臺上運行的庫文件就需要使用 ndk。

兩種庫文件

linux 平臺上的庫文件分爲兩種:

  • 靜態庫: 編譯鏈接時,把庫文件的代碼全部加入到可執行文件中,因此生成的文件比較大,但在運行時也就不再需要庫文件了,linux中後綴名爲”.a”。
  • 動態庫: 在編譯鏈接時並沒有把庫文件的代碼加入到可執行文件中,而是在程序執行時由運行時鏈接文件加載庫。linux 中後綴名爲”.so”,gcc在編譯時默認使用動態庫。

Android 原生開發套件 (NDK):這套工具使您能在 Android 應用中使用 C 和 C++ 代碼。 CMake:一款外部編譯工具,可與 Gradle 搭配使用來編譯原生庫。如果您只計劃使用 ndk-build,則不需要此組件。 LLDB:Android Studio 用於調試原生代碼的調試程序。

NDK

原生開發套件 (NDK) 是一套工具,使您能夠在 Android 應用中使用 C 和 C++ 代碼,並提供衆多平臺庫。 我們可以在 sdk/ndk-bundle 中查看 ndk 的目錄結構,下面列舉出三個重要的成員:

  • ndk-build: 該 Shell 腳本是 Android NDK 構建系統的起始點,一般在項目中僅僅執行這一個命令就可以編譯出對應的動態鏈接庫了。
  • platforms: 該目錄包含支持不同 Android 目標版本的頭文件和庫文件, NDK 構建系統會根據具體的配置來引用指定平臺下的頭文件和庫文件。
  • toolchains: 該目錄包含目前 NDK 所支持的不同平臺下的交叉編譯器 - ARM 、X86、MIPS ,目前比較常用的是 ARM。 // todo ndk-depends.cmd

ndk 爲什麼要提供多平臺呢? 不同的 Android 設備使用不同的 CPU,而不同的 CPU 支持不同的指令集。更具體的內容參考官方文檔

使用 ndk 手動編譯動態庫

在 ndk 目錄下的 toolchains 下有多個平臺的編譯工具,比如在 /arm-linux-androideabi-4.9/prebuilt/darwin-x86_64/bin 下可以找到 arm-linux-androideabi-gcc 執行文件,利用 ndk 的這個 gcc 可以編譯出在 android(arm 架構) 上運行的動態庫:

arm-linux-androideabi-gcc -fPIC -shared test.c -o libtest.so

參數含義 -fPIC: 產生與位置無關代碼 -shared:編譯動態庫,如果去掉代表靜態庫 test.c:需要編譯的 c 文件 -o:輸出 libtest.so:庫文件名

獨立工具鏈 版本比較新的 ndk 下已經找不到 gcc 了,如果想用的話需要參考獨立工具鏈。 比如執行 $NDK/build/tools/make_standalone_toolchain.py --arch arm --api 21 --install-dir/$yourDir 可以產生 arm 的獨立工具鏈

$NDK 代表 ndk 的絕對路徑, $yourDir 代表輸出文件路徑

當源文件很多的時候,手動編譯既麻煩又容易出錯,此時出現了 makefile 編譯。

 

makefile

makefile 就是“自動化編譯”:一個工程中的源文件不計數,其按類型、功能、模塊分別放在若干個目錄中,makefile定義了一系列的規則來指定,哪些文件需要先編譯,哪些文件需要後編譯,如何進行鏈接等等操作。 Android 使用 Android.mk 文件來配置 makefile,下面是一個最簡單的 Android.mk:

# 源文件在的位置。宏函數 my-dir 返回當前目錄(包含 Android.mk 文件本身的目錄)的路徑。
LOCAL_PATH := $(call my-dir)

# 引入其他makefile文件。CLEAR_VARS 變量指向特殊 GNU Makefile,可爲您清除許多 LOCAL_XXX 變量
# 不會清理 LOCAL_PATH 變量
include $(CLEAR_VARS)

# 指定庫名稱,如果模塊名稱的開頭已是 lib,則構建系統不會附加額外的前綴 lib;而是按原樣採用模塊名稱,並添加 .so 擴展名。
LOCAL_MODULE := hello
# 包含要構建到模塊中的 C 和/或 C++ 源文件列表 以空格分開
LOCAL_SRC_FILES := hello.c
# 構建動態庫
include $(BUILD_SHARED_LIBRARY)

我們配置好了 Android.mk 文件後如何告訴編譯器這是我們的配置文件呢? 這時候需要在 app/build.gradle 文件中進行相關的配置:

apply plugin: 'com.android.application'

android {
    compileSdkVersion 29

    defaultConfig {
      ...
        // 應該將源文件編譯成幾個 CPU so
        externalNativeBuild{
            ndkBuild{
                abiFilters 'x86','armeabi-v7a'
            }
        }
        // 需要打包進 apk 幾種 so
        ndk {
            abiFilters 'x86','armeabi-v7a'
        }
    }
    // 配置 native 構建腳本位置
    externalNativeBuild{
        ndkBuild{
            path "src/main/jni/Android.mk"
        }
    }
    // 指定 ndk 版本
    ndkVersion "20.0.5594570"

    ...
}

dependencies {
    implementation fileTree(dir: "libs", include: ["*.jar"])
    ...
}

Google 推薦開發者使用 cmake 來代替 makefile 進行交叉編譯了,makefile 在引入第三方預編譯好的 so 的時候會在 android 6.0 版本前後有些差異,比如在 6.0 之前需要手動 System.loadLibrary 第三方 so,在之後則不需要。 關於 makefile 還有很多配置參數,這裏不在講解,更多參考官方文檔

在 6.0 以下,System.loadLibrary 不會自動加載 so 內部依賴的 so 在 6.0 以下,System.loadLibrary 會自動加載 so 內部依賴的 so 所以使用 mk 的話需要做版本兼容

cmake

CMake是一個跨平臺的構建工具,可以用簡單的語句來描述所有平臺的安裝(編譯過程)。能夠輸出各種各樣的makefile或者project文件。Cmake 並不直接建構出最終的軟件,而是產生其他工具的腳本(如Makefile ),然後再依這個工具的構建方式使用。 Android Studio利用CMake生成的是ninja,ninja是一個小型的關注速度的構建系統。我們不需要關心ninja的腳本,知道怎麼配置cmake就可以了。

CMakeLists.txt

Make的腳本名默認是CMakeLists.txt,當我們用 android studio new project 勾選 include c/c++ 的時候,會默認生成以下文件:

|- app |-- src |--- main |---- cpp |----- CMakeLists.txt |----- native-lib.cpp

先來看一下 CMakeLists.txt:

# 設置 cmake 最小支持版本
cmake_minimum_required(VERSION 3.4.1)

# 創建一個庫
add_library( # 庫名稱,比如現在會生成 native-lib.so
             native-lib

             # 設置是動態庫(SHARED)還是靜態庫(STATIC)
             SHARED

             # 設置源文件的相對路徑
             native-lib.cpp )
             
 # 搜索並指定預構建庫並將路徑存儲爲變量。
 # NDK中已經有一部分預構建庫(比如 log),並且ndk庫已經是被配置爲cmake搜索路徑的一部分
 # 可以不寫 直接在 target_link_libraries 寫上log
 find_library( # 設置路徑變量的名稱
              log-lib

              # 指定要CMake定位的NDK庫的名稱
              log )
              
 # 指定CMake應鏈接到目標庫的庫。你可以鏈接多個庫,例如構建腳本、預構建的第三方庫或系統庫。
 target_link_libraries( # Specifies the target library.
                       native-lib
                       ${log-lib} )

我們再來看下 gradle 中的配置:

android {
    compileSdkVersion 29
    buildToolsVersion "29.0.1"
    defaultConfig {
        ...
        testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
        // 設置編譯版本
        externalNativeBuild {
            cmake {
                abiFilters "armeabi-v7a","x86"
            }
        }
    }
    ...
    // 設置配置文件路徑
    externalNativeBuild {
        cmake {
            path "src/main/cpp/CMakeLists.txt"
            version "3.10.2"
        }
    }
}

這樣在編譯產物中就可以看到兩個版本的 so:

添加多個源文件

比如我們添加一個 extra.h:

#ifndef JNITUTORIAL_EXTRA_H
#define JNITUTORIAL_EXTRA_H
const char * getString(){
    return "string from extra";
}
#endif //JNITUTORIAL_EXTRA_H

然後在 native-lib.cpp 中使用:

#include <jni.h>
#include <string>
#include <android/log.h>
#include "extra.h"
//  __VA_ARGS__ 代表... 可變參數
#define  LOGE(...) __android_log_print(ANDROID_LOG_ERROR,"JNI",__VA_ARGS__);

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

源文件已經寫好了,這時候要修改一下 CMakeLists.txt:

add_library( 
             native-lib
             SHARED
             native-lib.cpp
             // 添加 extra.h 
             extra.h )
             
#==================================
# 當然如果源文件非常多,並且可能在不同的文件夾下,像上面明確的引入各個文件就會非常繁瑣,此時可以批量引入

# 如果文件太多,可以批量加載,下面時將 cpp 文件夾下所有的源文件定義成了 SOURCE(後面的源文件使用相對路徑)
file(GLOB SOURCE *.cpp *.h)

add_library(
        native-lib
        SHARED
        # 引入 SOURCE 下的所有源文件
        ${SOURCE}
        )

添加第三方動態庫

那麼如何添加第三方的動態庫呢?

第三方庫的存放位置

動態庫必須放到 src/main/jniLibs/xxabi 目錄下才能被打包到 apk 中,這裏用的是虛擬機,所以用的是 x86 平臺,所以我們放置一個第三方庫 libexternal.so 到 src/main/jniLibs/x86 下面。 libexternal.so 中只有一個 hello.c ,裏面只有一個方法:

const char * getExternalString(){
    return "string from external";
}

CMakeLists.txt 的位置

這裏將 CMakeLists.txt 重新放到了 app 目錄下,和 src 同級,這樣方便找到 jniLibs 下面的庫。 所以別忘了修改 gradle

externalNativeBuild {
        cmake {
            path "CMakeLists.txt"
            version "3.10.2"
        }
    }

配置 CMakeLists.txt


cmake_minimum_required(VERSION 3.4.1)

# 如果文件太多,可以批量加載,下面時將 cpp 文件夾下所有的源文件定義成了 SOURCE(後面的源文件使用相對路徑)
file(GLOB SOURCE src/main/cpp/*.cpp src/main/cpp/*.h)

add_library(
        native-lib
        SHARED
        # 引入 SOURCE 下的所有源文件
        ${SOURCE}
        )
set_target_properties(native-lib PROPERTIES LINKER_LANGUAGE CXX)

#add_library( # Sets the name of the library.
#             native-lib
#
#             # Sets the library as a shared library.
#             SHARED
#
#             # Provides a relative path to your source file(s).
#             native-lib.cpp
#             extra.h )

find_library(
              log-lib
              log )

# ==================引入外部 so===================
message("ANDROID_ABI : ${ANDROID_ABI}")
message("CMAKE_SOURCE_DIR : ${CMAKE_SOURCE_DIR}")
message("PROJECT_SOURCE_DIR : ${PROJECT_SOURCE_DIR}")

# external 代表第三方 so - libexternal.so
# SHARED 代表動態庫,靜態庫是 STATIC;
# IMPORTED: 表示是以導入的形式添加進來(預編譯庫)
add_library(external SHARED IMPORTED)

#設置 external 的 導入路徑(IMPORTED_LOCATION) 屬性,不可以使用相對路徑
# CMAKE_SOURCE_DIR: 當前cmakelists.txt的路徑 (cmake工具內置的)
# android cmake 內置的 ANDROID_ABI :  當前需要編譯的cpu架構
set_target_properties(external PROPERTIES IMPORTED_LOCATION ${CMAKE_SOURCE_DIR}/src/main/jniLibs/x86/libexternal.so)
#set_target_properties(external PROPERTIES LINKER_LANGUAGE CXX)

# ==================引入外部 so end===================

target_link_libraries( # Specifies the target library.
                       native-lib

                       # Links the target library to the log library
                       # included in the NDK.
                       ${log-lib}
                       # 鏈接第三方 so
                       external
        )

使用第三方庫

#include <jni.h>
#include <string>
#include <android/log.h>
#include "extra.h"
//  __VA_ARGS__ 代表... 可變參數
#define  LOGE(...) __android_log_print(ANDROID_LOG_ERROR,"JNI",__VA_ARGS__);

extern "C"{
 const char * getExternalString();
}

extern "C" JNIEXPORT jstring JNICALL
Java_com_wangzhen_jnitutorial_MainActivity_stringFromJNI(JNIEnv *env, jobject thiz) {
//    std::string hello = "Hello from new C++";
//    std::string hello = getString();
    // 這裏調用了第三方庫的方法
    std::string hello = getExternalString();
    return env->NewStringUTF(hello.c_str());
}

增加 CMake 查找路徑

除了上面的方式還可以給 CMake 增加一個查找 so 的 path,當我們 target_link_libraries external 的時候就會在該路徑下找到。

#=====================引入外部 so 的第二種方式===============================

# 直接給 cmake 在添加一個查找路徑,在這個路徑下可以找到 external

# CMAKE_C_FLAGS 代表使用 c 編譯, CMAKE_CXX_FLAGS 代表 c++
# set 方法 定義一個變量 CMAKE_C_FLAGS = "${CMAKE_C_FLAGS} XXXX"
# -L: 庫的查找路徑 libexternal.so
#set(CMAKE_C_FLAGS "${CMAKE_C_FLAGS} -L${CMAKE_SOURCE_DIR}/src/main/jniLibs/${ANDROID_ABI} ")
set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -L${CMAKE_SOURCE_DIR}/src/main/jniLibs/x86")
#=====================引入外部 so 的第二種方式 end===============================

粉絲裙:

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章