*** 說明:本文不代表博主觀點,均是由以下資料整理的讀書筆記。 ***
【參考資料】
1、向您的Android Studio項目添加C/C++代碼
2、Google開發者文檔 -- 添加C++代碼到現有Android Studio項目中
3、JNI Tips 英文原版
4、JNI Tips 中文
5、極客學院 JNI/NDK 開發指南
6、極客學院 深入理解 JNI
7、使用CMake構建JNI環境
8、使用C和C++的區別
9、Google官方 NDK 文檔
10、極客學院 NDK開發課程
11、ndk-build 構建 JNI 環境
12、開發自己的NDK程序
13、JNI/NDK開發教程
14、JNI層修改參數值
15、JNI引用和垃圾回收
16、《Android高級進階》-- 顧浩鑫
17、《Android C++ 高級編程 -- 使用 NDK》 -- Onur Cinar
一、概述
1.1 JNI
Java Native Interface(Java 本地接口),它是為了方便Java調用C、C++等本地代碼所封裝的一層接口。
1.2 NDK
Native Development Kit(本地開發工具包),通過NDK可以在Android中更加方便的通過JNI來訪問本地代碼;并提供眾多平臺庫,讓您可以管理原生 Activity 和訪問物理設備組件,例如傳感器和觸摸輸入,比如百度開放平臺提供的定位服務、搜索服務、LBS 服務、推送服務的Android SDK,除了Java接口的jar包之外,還有一個.so文件,這個.so就是實現了Java層定義的native接口的動態庫。NDK 能自動將 so 和 Java 應用一起打包成 apk,它集成了交叉編譯器,并提供了相應的 mk 文件隔離 CPU、平臺、ABI 等差異,開發人員只需要簡單修改 mk 文件(指出“哪些文件需要編譯”、“編譯特性要求”等),就可以創建出 so。
1.3 JNI 開發的平臺差異
開發 JNI 程序會受到系統環境的限制,因為用 C/C++ 語言寫出來的代碼或模塊,編譯過程當中要依賴當前操作系統環境所提供的一些庫函數,并和本地庫鏈接在一起。而且編譯后生成的二進制代碼只能在本地操作系統環境下運行,因為不同的操作系統環境,有自己的本地庫和 CPU 指令集,而且各個平臺對標準 C/C++ 的規范和標準庫函數實現方式也有所區別。這就造成使用了 JNI 接口的 JAVA 程序,不再像以前那樣自由的跨平臺。如果要實現跨平臺,就必須將本地代碼在不同的操作系統平臺下編譯出相應的動態庫。
二、Android中C/C++項目的兩種構建方式
2.1 CMake
這是Android Studio的默認方式。通過CMakeLists.txt和gradle來構建原生代碼。
2.2 ndk-build
可以將現有的ndk-build庫導入到Android Studio項目中。
PS:如果是創建新的C++庫,建議使用CMake。
2.3 調試
Android Studio 可以使用 LLDB 工具來調試C++代碼。
2.4 使用Android Studio創建支持C/C++的CMake項目
** 使用2.2或以上版本的Android Studio可以創建CMake項目,與創建普通的AS項目類似,需要以下幾個額外步驟: **
1、在向導的 Configure your new project 部分,選中 Include C++ Support 復選框。
2、在向導的 Customize C++ Support 部分,您可以使用下列選項自定義項目:
(1)C++ Standard:使用下拉列表選擇您希望使用哪種 C++ 標準。選擇 Toolchain Default 會使用默認的 CMake 設置。
(2)Exceptions Support:如果您希望啟用對 C++ 異常處理的支持,請選中此復選框。如果啟用此復選框,Android Studio 會將 -fexceptions 標志添加到模塊級 build.gradle 文件的 cppFlags 中,Gradle 會將其傳遞到 CMake。
(3)Runtime Type Information Support:如果您希望支持 RTTI,請選中此復選框。如果啟用此復選框,Android Studio 會將 -frtti 標志添加到模塊級 build.gradle 文件的 cppFlags 中,Gradle 會將其傳遞到 CMake。
** 創建好的新項目,會比普通的AS項目多了 cpp 組和 External Build Files 組: **
1、在 cpp 組中,可以找到屬于項目的所有原生源文件、標頭和預構建庫。對于新項目,Android Studio 會創建一個示例 C++ 源文件 native-lib.cpp。默認模板提供了一個 C++ 函數 stringFromJNI(),以返回字符串“Hello from C++”。
2、在 External Build Files 組中,可以找到 CMake 構建腳本。與 build.gradle 文件指示 Gradle 如何構建應用一樣,CMake 需要一個構建腳本來了解如何構建原生庫。對于新項目,Android Studio 會創建一個 CMake 構建腳本 CMakeLists.txt,并將其置于模塊的根目錄中。
** 構建和運行的過程 **
1、Gradle 調用外部構建腳本 CMakeLists.txt。
2、CMake 按照構建腳本中的命令將 C++ 源文件 native-lib.cpp 編譯到共享的對象庫中,并命名為 libnative-lib.so,Gradle 隨后會將其打包到 APK 中。
3、運行時,應用的 MainActivity 會使用 System.loadLibrary() 加載原生庫,加載成功之后,應用就可以使用庫的原生函數 stringFromJNI() 了。
4、MainActivity.onCreate() 調用 stringFromJNI(),這將返回“Hello from C++”。
2.5 向現有項目添加 C/C++ 代碼
1、創建新的 C/C++ 源文件或提供現有的原生庫,并將其添加到的 Android Studio 項目中。
2、創建 CMake 構建腳本或 ndk-build 構建腳本,將原生源代碼構建到庫中。
- ** CMake **
CMake 構建腳本是一個純文本文件,必須將其命名為 CMakeLists.txt,在 gradle 的 externalNativeBuild 中指向其路徑。下面是幾個常見的CMake命令:(詳見:https://developer.android.google.cn/studio/projects/add-native-code.html#existing-project)
(1)cmake_minimum_required:指定最低版本號。
(2)add_library:包含三個參數,分別是共享庫的名稱,設置為共享庫或靜態庫,入口文件的路徑;在 Java 代碼中使用 System.loadLibrary(“native-lib”) 來加載該共享庫。可以使用多個 add_library 命令關聯多個共享庫。
(3)find_library、target_link_libraries:這兩個命令可以將已有的的 NDK 庫關聯到 CMake中,默認實例中添加了原生的 log 庫。
(4)例子:NDK 還以源代碼的形式包含一些庫,可以使用 add_library() 命令,將源代碼編譯到原生庫中。要提供本地 NDK 庫的路徑,使用 ANDROID_NDK 路徑變量,Android Studio 會自動為你定義此變量。以下命令可以指示 CMake 構建 android_native_app_glue.c,后者會將 NativeActivity 生命周期事件和觸摸輸入置于靜態庫中并將靜態庫關聯到 native-lib。
add_library( app-glue
STATIC
${ANDROID_NDK}/sources/android/native_app_glue/android_native_app_glue.c )
# You need to link static libraries against your shared native library.
target_link_libraries( native-lib app-glue ${log-lib} )
- ** ndk-build **
提供一個指向您的 Android.mk 文件的路徑,將 Gradle 關聯到原生庫。使用ndk-build構建請參考這兩篇:
NDK筆記(二)-在Android Studio中使用ndk-build
JNI/NDK開發指南 - 開發自己的 NDK 程序
下面是Android.mk和pplication.mk文件的語法說明:
如果要使用stl,則需要建立一個Application.mk,里面寫上:
APP_STL := stlport_shared
APP_STL := stlport_static
3、提供一個指向 CMake 或 ndk-build 腳本文件的路徑,將 Gradle 關聯到原生庫。Gradle 使用構建腳本將源代碼導入 Android Studio 項目并將原生庫(SO 文件)打包到 APK 中。
三、JNI頭文件解釋
看這個最簡單的例子:
extern "C"
JNIEXPORT jstring JNICALL
Java_com_scu_miomin_learncmake_MainActivity_stringFromJNI(
JNIEnv *env,
jobject /* this */) {
std::string hello = "Hello from C++ " + getStringValue();
return env->NewStringUTF(hello.c_str());
}
3.1 JNIEXPORT 和 JNICALL
JNIEXPORT 和 JNICALL 是定義在跨平臺頭文件目錄(Mac os x系統下的目錄名為 darwin,在 Windows 下目錄名為 win32,linux 下目錄名為 linux),用于標識函數用途的兩個宏。從 Linux 下的jni_md.h頭文件可以看出來,JNIEXPORT 和 JNICALL 是一個空定義,所以在 Linux 下 JNI 函數聲明可以省略這兩個宏。
3.2 JNIEnv*
是定義任意 native 函數的第一個參數,指向 JVM 函數表的指針,函數表中的每一個入口指向一個 JNI 函數,每個函數用于訪問 JVM 中特定的數據結構。
3.3 jobject
調用 Java 中 native 方法的實例或 Class 對象,如果這個 native 方法是實例方法,則該參數是 jobject,如果是靜態方法,則是 jclass
3.4 JavaVM 及 JNIEnv
JNI定義了兩種關鍵數據結構,“JavaVM”和“JNIEnv”。它們本質上都是指向函數表指針的指針(在C++版本中,它們被定義為類,該類包含一個指向函數表的指針,以及一系列可以通過這個函數表間接地訪問對應的JNI函數的成員函數)。JavaVM提供“調用接口(invocation interface)”函數, 允許你創建和銷毀一個JavaVM。理論上你可以在一個進程中擁有多個JavaVM對象,但安卓只允許一個。
JNIEnv提供了大部分JNI功能。你定義的所有本地函數都會接收JNIEnv作為第一個參數。
JNIEnv是用作線程局部存儲。因此,你不能在線程間共享一個JNIEnv變量。如果在一段代碼中沒有其它辦法獲得它的JNIEnv,你可以共享JavaVM對象,使用GetEnv來取得該線程下的JNIEnv(如果該線程有一個JavaVM的話;見下面的AttachCurrentThread)。
3.5 jclass, jmethodID, jfieldID
如果你想在本地代碼中訪問一個對象的字段(field),你可以像下面這樣做:
- 對于類,使用FindClass獲得類對象的引用
- 對于字段,使用GetFieldId獲得字段ID
- 使用對應的方法(例如GetIntField)獲取字段下面的值
如果性能是你看重的,那么一旦查找出這些值之后在你的本地代碼中緩存這些結果是非常有用的。因為每個進程當中的JavaVM是存在限制的,存儲這些數據到本地靜態數據結構中是非常合理的。類引用(class reference),字段ID(field ID)以及方法ID(method ID)在類被卸載前都是有效的。如果與一個類加載器(ClassLoader)相關的所有類都能夠被垃圾回收,但是這種情況在安卓上是罕見甚至不可能出現,只有這時類才被卸載。注意雖然jclass是一個類引用,但是必須要調用NewGlobalRef保護起來(見后文)。
當一個類被加載時如果你想緩存些ID,而后當這個類被卸載后再次載入時能夠自動地更新這些緩存ID,正確做法是在對應的類中添加一段像下面的代碼來初始化這些ID,當這個類被初始化時這段代碼將會執行一次。當這個類被卸載后而后再次載入時,這段代碼將會再次執行:
/*
* 我們在一個類初始化時調用本地方法來緩存一些字段的偏移信息
* 這個本地方法查找并緩存你感興趣的class/field/method ID
* 失敗時拋出異常
*/
private static native void nativeInit();
static {
nativeInit();
}
四、JNI 與 Java 的數據類型映射
在調用 Java native 方法將實參傳遞給 C/C++ 函數的時候,會自動將 java 形參的數據類型自動轉換成 C/C++ 相應的數據類型,所以我們在寫 JNI 程序的時候,必須要明白它們之間數據類型的對應關系。
4.1 基礎類型
boolean -- jboolean
byte -- jbyte
char -- bchar
short -- jshort
int -- jint
long -- jlong
float -- jfloat
double -- jdouble
4.2 引用類型
JNI 如果使用 C++ 語言編寫的話,所有引用類型派生自 jobject,使用 C++ 的繼承結構特性,使用相應的類型。如下所示:
class _jobject {};
class _jclass : public _jobject {};
class _jstring : public _jobject {};
class _jarray : public _jobject {};
class _jbooleanArray : public _jarray {};
class _jbyteArray : public _jarray {};
JNI 把 Java 中的所有對象當作一個C指針傳遞到本地方法中,這個指針指向 JVM 中的內部數據結構,而內部的數據結構在內存中的存儲方式是不可見的。只能從 JNIEnv 指針指向的函數表中選擇合適的 JNI 函數來操作 JVM 中的數據結構。
注意:JNI 層拿到的參數數據是以拷貝的形式存在,所以修改修改 JNI 中形參的值,不會引起 Java 層實參值的變化。如果一定要修改實參的值,必須以對象的方式傳遞參數,JNI 層對 jobject 參數進行修改,具體實現參考:http://www.cnblogs.com/CCBB/p/3980856.html
五、JNI 字符串處理
一個簡單的例子:
extern "C"
JNIEXPORT jstring JNICALL
Java_com_scu_miomin_learncmake_NativeLib_stringFromJNI(
JNIEnv *env,
jclass /* this */,
jstring j_str_first,
jstring j_str_test) {
jstring j_second = env->NewStringUTF(getStringValue().c_str());
char *c_second = Jstring2CStr(env, j_second);
char *c_str = Jstring2CStr(env, j_str_first);
const char *c_str_test = env->GetStringUTFChars(j_str_test, NULL);
if (c_second == NULL || c_str == NULL || c_str_test == NULL) {
return NULL;
}
strcat(c_str, c_second);
LOGV("j_str_test = %s", c_str_test);
env->ReleaseStringUTFChars(j_str_test, c_str_test);
return env->NewStringUTF(c_str);
}
這段代碼從 Java 傳遞 String 參數止 JNI 層,JNI 再從 C++ 庫獲得另一個 string,在JNI層將這兩個字符串拼接后返回給 Java。
5.1 訪問字符串
在這個例子中,訪問 java.lang.String 對應的 JNI 類型 jstring 時,沒有像訪問基本數據類型一樣直接使用,因為它在 Java 是一個引用類型,所以在本地代碼中只能通過 GetStringUTFChars 這樣的 JNI 函數來訪問字符串的內容。
GetStringUTFChars() 和 Jstring2CStr() 都是用于將 jstring 轉換成 char* 類型的函數,前者是系統提供,后者是自己實現,區別在于返回值是否為 const。
5.2 異常檢查
調用完 GetStringUTFChars 之后不要忘記安全檢查,因為 JVM 需要為新誕生的字符串分配內存空間,當內存空間不夠分配的時候,會導致調用失敗,失敗后 GetStringUTFChars 會返回 NULL,并拋出一個OutOfMemoryError 異常。JNI 的異常和 Java 中的異常處理流程是不一樣的,Java 遇到異常如果沒有捕獲,程序會立即停止運行。而 JNI 遇到未決的異常不會改變程序的運行流程,也就是程序會繼續往下走,這樣后面針對這個字符串的所有操作都是非常危險的,因此,我們需要用 return 語句跳過后面的代碼,并立即結束當前方法。
5.3 釋放字符串
在調用 GetStringUTFChars 函數從 JVM 內部獲取一個字符串之后,JVM 內部會分配一塊新的內存,用于存儲源字符串的拷貝,以便本地代碼訪問和修改。即然有內存分配,用完之后馬上釋放是一個編程的好習慣。通過調用ReleaseStringUTFChars 函數通知 JVM 這塊內存已經不使用了,可以清除了。注意:這兩個函數是配對使用的,用了 GetXXX 就必須調用 ReleaseXXX,而且這兩個函數的命名也有規律,除了前面的 Get 和 Release 之外,后面的都一樣。
5.4 創建字符串
通過調用 NewStringUTF 函數,會構建一個新的 java.lang.String 字符串對象。這個新創建的字符串會自動轉換成 Java 支持的 Unicode 編碼。如果 JVM 不能為構造 java.lang.String 分配足夠的內存,NewStringUTF 會拋出一個 OutOfMemoryError 異常,并返回 NULL。一般來說不必檢查它的返回值,如果NewStringUTF 創建 java.lang.String 失敗,OutOfMemoryError 會在 Java 調用層中被拋出。如果 NewStringUTF 創建 java.lang.String 成功,則返回一個 JNI 引用,這個引用指向新創建的java.lang.String 對象。
5.5 其它字符串處理函數
(1)GetStringChars和ReleaseStringChars
這對函數和 Get/ReleaseStringUTFChars 函數功能差不多,用于獲取和釋放以 Unicode 格式編碼的字符串。而后者是用于獲取和釋放 UTF-8 編碼的字符串。
(2)GetStringLength
由于 UTF-8 編碼的字符串以'\0'結尾,而 Unicode 字符串不是。如果想獲取一個指向 Unicode 編碼的 jstring 字符串長度,在 JNI 中可通過這個函數獲取。
(3)GetStringUTFLength
獲取 UTF-8 編碼字符串的長度,也可以通過標準 C 函數 strlen 獲取。
(4)GetStringCritical和ReleaseStringCritical
Get/ReleaseStringChars 和 Get/ReleaseStringUTFChars 這對函數返回的源字符串會后分配內存,如果有一個字符串內容相當大,有 1M 左右,而且只需要讀取里面的內容打印出來,用這兩對函數就有些不太合適了。此時用 Get/ReleaseStringCritical 可直接返回源字符串的指針應該是一個比較合適的方式。不過這對函數有一個很大的限制,在這兩個函數之間的本地代碼不能調用任何會讓線程阻塞或等待 JVM 中其它線程的本地函數或 JNI 函數。因為通過 GetStringCritical 得到的是一個指向 JVM 內部字符串的直接指針,獲取這個直接指針后會導致暫停 GC 線程,當 GC 被暫停后,如果其它線程觸發 GC 繼續運行的話,都會導致阻塞調用者。所以在 Get/ReleaseStringCritical 這對函數中間的任何本地代碼都不可以執行導致阻塞的調用或為新對象在 JVM 中分配內存,否則,JVM 有可能死鎖。另外一定要記住檢查是否因為內存溢出而導致它的返回值為 NULL,因為 JVM 在執行 GetStringCritical 這個函數時,仍有發生數據復制的可能性,尤其是當 JVM 內部存儲的數組不連續時,為了返回一個指向連續內存空間的指針,JVM 必須復制所有數據。下面代碼演示這對函數的正確用法:
JNIEXPORT jstring JNICALL Java_com_study_jnilearn_Sample_sayHello
(JNIEnv *env, jclass cls, jstring j_str) {
const jchar* c_str= NULL;
char buff[128] = "hello ";
char* pBuff = buff + 6;
/*
* 在GetStringCritical/RealeaseStringCritical之間是一個關鍵區。
* 在這關鍵區之中,絕對不能呼叫JNI的其他函數和會造成當前線程中斷或是會讓當前線程等待的任何本地代碼,
* 否則將造成關鍵區代碼執行區間垃圾回收器停止運作,任何觸發垃圾回收器的線程也會暫停。
* 其他觸發垃圾回收器的線程不能前進直到當前線程結束而激活垃圾回收器。
*/
// 返回源字符串指針的可能性
c_str = (*env)->GetStringCritical(env,j_str,NULL);
// 驗證是否因為字符串拷貝內存溢出而返回NULL
if (c_str == NULL) {
return NULL;
}
while(*c_str) {
*pBuff++ = *c_str++;
}
(*env)->ReleaseStringCritical(env,j_str,c_str);
return (*env)->NewStringUTF(env,buff);
}
JNI 中沒有 Get/ReleaseStringUTFCritical 這樣的函數,因為在進行編碼轉換時很可能會促使 JVM 對數據進行復制,因為 JVM 內部表示的字符串是使用 Unicode 編碼的。
(5)GetStringRegion和GetStringUTFRegion
分別表示獲取 Unicode 和 UTF-8 編碼字符串指定范圍內的內容。這對函數會把源字符串復制到一個預先分配的緩沖區內。下面代碼用 GetStringUTFRegion 重新實現 sayHello 函數:
JNIEXPORT jstring JNICALL Java_com_study_jnilearn_Sample_sayHello
(JNIEnv *env, jclass cls, jstring j_str) {
jsize len = (*env)->GetStringLength(env,j_str); // 獲取unicode字符串的長度
printf("str_len:%d\n",len);
char buff[128] = "hello ";
char* pBuff = buff + 6;
// 將JVM中的字符串以utf-8編碼拷入C緩沖區,該函數內部不會分配內存空間
(*env)->GetStringUTFRegion(env,j_str,0,len,pBuff);
return (*env)->NewStringUTF(env,buff);
}
GetStringUTFRegion 這個函數會做越界檢查,如果檢查發現越界了,會拋出StringIndexOutOfBoundsException 異常,這個方法與 GetStringUTFChars 比較相似,不同的是,GetStringUTFRegion 內部不分配內存,不會拋出內存溢出異常。
注意:GetStringUTFRegion 和 GetStringRegion 這兩個函數由于內部沒有分配內存,所以 JNI 沒有提供ReleaseStringUTFRegion 和 ReleaseStringRegion 這樣的函數。
5.6 字符串操作建議
- 對于小字符串來說,GetStringRegion 和 GetStringUTFRegion 這兩對函數是最佳選擇,因為緩沖區可以被編譯器提前分配,而且永遠不會產生內存溢出的異常。當你需要處理一個字符串的一部分時,使用這對函數也是不錯。因為它們提供了一個開始索引和子字符串的長度值。另外,復制少量字符串的消耗 也是非常小的。
- 使用 GetStringCritical 和 ReleaseStringCritical 這對函數時,必須非常小心。一定要確保在持有一個由 GetStringCritical 獲取到的指針時,本地代碼不會在 JVM 內部分配新對象,或者做任何其它可能導致系統死鎖的阻塞性調用。
- 獲取 Unicode 字符串和長度,使用 GetStringChars 和 GetStringLength 函數。
- 獲取 UTF-8 字符串的長度,使用 GetStringUTFLength 函數。
- 創建 Unicode 字符串,使用 NewStringUTF 函數。
- 從 Java 字符串轉換成 C/C++ 字符串,使用 GetStringUTFChars 函數。
- 通過 GetStringUTFChars、GetStringChars、GetStringCritical 獲取字符串,這些函數內部會分配內存,必須調用相對應的 ReleaseXXXX 函數釋放內存。