Android開發進階:Android NDK介紹
導讀
為了在Android OS系統上開發應用程序,Google提供了兩種開發包:SDK和NDK。你可以從Google官方查閱到有許多關于SDK 的優秀的書籍、文章作為參考,但Google沒有提供足夠的NDK資料。在現有的書籍中,我認為Cinar O.寫于2012年 的”Pro Android C++ with the NDK”值得一讀。
本文旨在幫助那些缺乏Android NDK經驗但又想擴充這方面知識的人們。我所關注的是JNI(本地編程接口,簡稱JNI)。本文分上下兩篇,在上篇中,會從JNI為接口開始講起;下篇會進行回顧,并給出帶兩個文件讀寫功能的實例。
什么是 Android NDK?
Android NDK(Native Development Kit )是一套工具集合,允許你用像C/C++語言那樣實現應用程序的一部分。
何時使用NDK?
Google僅在極少數情況下建議使用NDK,有如下使用場景:
- 必須提高性能(例如,對大量數據進行排序)。
- 使用第三方庫。舉例說明:許多第三方庫由C/C++語言編寫,而Android應用程序需要使用現有的第三方庫,如Ffmpeg、OpenCV這樣的庫。
- 底層程序設計(例如,應用程序不依賴Dalvik Java虛擬機)。
什么是JNI?
JNI是一種在Java虛擬機控制下執行代碼的標準機制。代碼被編寫成匯編程序或者C/C++程序,并組裝為動態庫。也就允許了非靜態綁定用法。這提供了一個在Java平臺上調用C/C++的一種途徑,反之亦然。
JNI的優勢
與其他類似接口(Netscape Java運行接口、Microsoft的原始本地接口、COM/Java接口)相比,JNI主要的競爭優勢在于:它在設計之初就確保了二進制的兼容 性,JNI編寫的應用程序兼容性以及在某些具體平臺上的Java虛擬機兼容性(當談及JNI,這里并不特別針對Dalvik;JNI由Oracle開發, 適用于所有Java虛擬機)。這就是為什么C/C++編譯后的代碼無論在任何平臺上都能執行。不過,一些早期版本并不支持二進制兼容。
二進制兼容性是一種程序兼容性類型,允許一個程序在不改變其可執行文件的條件下在不同的編譯環境中工作。
JNI組織結構
這張JNI函數表的組成就像C++的虛函數表。虛擬機可以運行多張函數表,舉例來說,一張調試函數表,另一張是調用函數表。JNI接口指針僅在當前線程中起作用。這意味著指針不能從一個線程進入另一個線程。然而,可以在不同的線程中調用本地方法。
示例代碼:
- jdouble Java_pkg_Cls_f__ILjava_lang_String_2 (JNIEnv *env, jobject obj, jint i, jstring s)
- {
- const char *str = (*env)->GetStringUTFChars(env, s, 0);
- (*env)->ReleaseStringUTFChars(env, s, str);
- return 10;
- }
- *env — 一個接口指針。
- obj — 在本地方法中聲明的對象引用。
- i和s — 用于傳遞的參數。
原始類型(Primitive Type)在虛擬機和本機代碼進行拷貝,對象之間使用引用進行傳遞。VM(虛擬機)要追蹤所有傳遞給本地代碼的對象引用。GC無法釋放所有傳遞給本地代碼的對象引用。與此同時,本機代碼應該通知VM不需要的對象引用。
局部引用和全局引用
JNI定義了三種引用類型:局部引用、全局引用和全局弱引用。局部引用在方法完成之前是有效的。所有通過JNI函數返回的Java對象都是本地引 用。程序員希望VM會清空所有的局部引用,然而局部引用僅在其創建的線程里可用。如果有必要,局部引用可以通過接口中的DeleteLocalRef JNI方法立即釋放:
- jclass clazz;
- clazz = (*env)->FindClass(env, "java/lang/String");
- ...
- (*env)->DeleteLocalRef(env, clazz)
全局引用在完全釋放之前都是有效的。要創建一個全局引用,需要調用NewGlobalRef方法。如果全局引用并不是必須的,可以通過DeleteGlobalRef方法刪除:
- jclass localClazz;
- jclass globalClazz;
- ...
- localClazz = (*env)->FindClass(env, "java/lang/String");
- globalClazz = (*env)->NewGlobalRef(env, localClazz);
- ...
- (*env)->DeleteLocalRef(env, localClazz);
錯誤
JNI不會檢查NullPointerException、IllegalArgumentException這樣的錯誤,原因是:
- 導致性能下降。
- 在絕大多數C的庫函數中,很難避免錯誤發生。
JNI允許用戶使用Java異常處理。大部分JNI方法會返回錯誤代碼但本身并不會報出異常。因此,很有必要在代碼本身進行處理,將異常拋給Java。在JNI內部,首先會檢查調用函數返回的錯誤代碼,之后會調用ExpectOccurred()返回一個錯誤對象。
- jthrowable ExceptionOccurred(JNIEnv *env);
例如:一些操作數組的JNI函數不會報錯,因此可以調用ArrayIndexOutofBoundsException或ArrayStoreExpection方法報告異常。
JNI原始類型
JNI有自己的原始數據類型和數據引用類型。
Java類型 |
本地類型(JNI) |
描述 |
boolean(布爾型) | jboolean | 無符號8個比特 |
byte(字節型) | jbyte | 有符號8個比特 |
char(字符型) | jchar | 無符號16個比特 |
short(短整型) | jshort | 有符號16個比特 |
int(整型) | jint | 有符號32個比特 |
long(長整型) | jlong | 有符號64個比特 |
float(浮點型) | jfloat | 32個比特 |
double(雙精度浮點型) | jdouble | 64個比特 |
void(空型) | void | N/A |
JNI引用類型
改進的UTF-8編碼
JNI使用改進的UTF-8字符串來表示不同的字符類型。Java使用UTF-16編碼。UTF-8編碼主要使用于C語言,因為它的編碼用\u000表示為0xc0,而不是通常的0×00。非空ASCII字符改進后的字符串編碼中可以用一個字節表示。
#p#
JNI函數:
JNI接口不僅有自己的數據集(dataset)也有自己的函數?;仡欉@些數據集和函數需要花費我們很多時間。可以從官方文檔中找到更多信息:
http://docs.oracle.com/javase/6/docs/technotes/guides/jni/spec/functions.html
JNI函數使用示例
下面會通過一個簡短的示例確保你對這些資料所講的內容有了正確的理解:
- #include <jni.h>
- ...
- JavaVM *jvm;
- JNIEnv *env;
- JavaVMInitArgs vm_args;
- JavaVMOption* options = new JavaVMOption[1];
- options[0].optionString = "-Djava.class.path=/usr/lib/java";
- vm_args.version = JNI_VERSION_1_6;
- vm_args.nOptions = 1;
- vm_args.options = options;
- vm_args.ignoreUnrecognized = false;
- JNI_CreateJavaVM(&jvm, &env, &vm_args);
- delete options;
- jclass cls = env->FindClass("Main");
- jmethodID mid = env->GetStaticMethodID(cls, "test", "(I)V");
- env->CallStaticVoidMethod(cls, mid, 100);
- jvm->DestroyJavaVM();
讓我們來逐個分析字符串:
- JavaVM — 提供了一個接口,可以調用函數創建、刪除Java虛擬機。
- JNIEnv — 確保了大多數的JNI函數。
- JavaVMlnitArgs — Java虛擬機參數。
- JavaVMOption — Java虛擬機選項。
JNI的_CreateJavaVM()方法初始化Java虛擬機并向JNI接口返回一個指針。
JNI_DestroyJavaVM()方法可以載入創建好的Java虛擬機。
線程
內核負責管理所有在Linux上運行的線程;線程通過AttachCurrentThread和AttachCurrentThreadAsDaemon函數附加到Java虛擬機。如果線程沒有被添加成功,則不能訪問JNIEnv。 Android系統不能停止JNI創建的線程,即使GC(Garbage Collection)在運行釋放內存時也不行。直到調用DetachCurrentThread方法,該線程才會從Java虛擬機脫離。
***步
你的項目結構應該如圖3所示:
在圖3中,所有本地代碼都存儲到一個jni的文件夾。在新建一個工程后,Libs文件夾會被分為四個子文件夾。這意味著一個子目錄對應一種處理器架構,庫的數量取決于處理器架構的數量。
要創建一個本地項目和一個Android項目可以參照以下面的步驟:
- 創建一個jni文件夾 — 包含本地代碼的項目源代碼根目錄。
- 創建一個Android.mk文件用來構建項目。
- 創建一個Application.mk文件用來存儲編譯參數。雖然這不是必須的配置,但是推薦你這么做。這樣會使得編譯設置更加靈活。
- 創建一個ndk-build文件以此來顯示編譯過程(同樣這一步也不是必須的)。
Android.mk
就像前面提到的,Android.mk是編譯本地項目的makefile。Android.mk把代碼按照模塊進行了劃分,把靜態庫(static library)拷貝到項目的libs文件夾,生成共享庫(shared library)和獨立的可執行文件。
最精簡的配置示例:
- LOCAL_PATH := $(call my-dir)
- include $(CLEAR_VARS)
- LOCAL_MODULE := NDKBegining
- LOCAL_SRC_FILES := ndkBegining.c
- include $(BUILD_SHARED_LIBRARY)
讓我們來仔細看看:
- LOCAL_PATH:-$(call my-dir) — 調用函數宏my-dir返回當前文件所在路徑。
- include $(CLEAR_VARS) — 清除所有LOCAL_PATH以外的變量。這是必須的步驟,考慮到所有編譯控制文件都位于同一個GNU MAKE執行環境中,所有變量都是全局的。
- LOCAL_MODULE — 輸出模塊名稱。在上述例子中,輸出模塊叫做NDKBegining。但是在生成以后,會在libs文件夾中創建libNDKbegining庫。同 時,Android系統會為其添加一個前綴名lib,例如一個被命名為”foo”的共享庫模塊,將會生成”libfoo.so”文件。 但是在Java代 碼中使用庫時應該忽略前綴名(也就是說,名稱應該和makefile一樣)。
- LOCAL_SRC_FILE — 列出編譯所需要的源文件。
- include $(BUILD_SHARED_LIBARY) — 輸出模塊的類型。
你可以在Android.mk文件中設置自定義變量;但是必須遵守語法命名規則:LOCAL_、PRIVATE_、NDK_、APP_、my-dir。Google建議自定義示例前綴使用MY_,例如:
MY_SOURCE := NDKBegining.c
這樣就調用了一個變量$(MY_SOURCE)。變量同樣也可以被連接起來,例如:
LOCAL_SRC_FILES += $(MY_SOURCE)
Application.mk
這個makefile中定義了好幾種變量讓編譯更加靈活:
- APP_OPTM — 這個變量是可選的,用于指定程序是“release”還是“debug”。在構建應用程序模塊時,該變量用來優化構建過程。你可以在調試中指定“release”,不過“debug”支持的配置選項更多。
- APP_BUILD_SCRI為Android.mk定義了另一條路徑。
- APP_ABI — 最重要的變量之一。它指定了編譯模塊時使用的目標處理器架構。默認情況下,APP_ABI會設置為“armeabi”,對應于ARMv5TE架構。例如, 如果要支持 ARMv7,就需要設置為“armeabi-v7a”。對于IA-32-x86和MIPS-mips這樣支持多體系架構的系統,應該把 APP_ABI設置為“armeabi armeabi-v7a x86 mips”。在NDK修訂版本7或更高的版本中,可以簡單的設置APP_ABI := “all rather enumerating all the architectures”。
- APP_PLATFORM — 為目標平臺名稱;
- APP_STL — Android提供了一個最精簡的libstdc c++運行庫,因此開發人員使用的c++功能是非常有限的。然而使用APP_STL變量就可以使這些庫支持擴展功能。
- NDK_TOOLCHAIN_VERSION-GCC — 選擇的GCC編譯器版本(默認情況下設置為4.6)。
NDK-BUILDS
NDK-build是一個GNU Make的包裝容器。在NDK 4以后,ndk-build支持以下參數:
- clean — 清除所有已生成的二進制文件。
- NDK_DEBUG=1 — 生成可調式的代碼。
- NDK_LOG=1 — 顯示日志信息(用于調試)。
- NDK_HOST_32BIT=1 — 使Android系統支持64位版本(例如,NDK_PATH\toolchains\mipsel-linux-android-4.8\prebuilt\windows-x86_64,等等)。
- NDK_APPLICATION_MK=<file> — 指定Application.mk路徑。
在 NDK v5中,引入了NDK_DEBUG。當NDK_DEBUG設置為“1”時,便會生成可調試版本。如果沒有設置NDK_DEBUG,ndk-build會默 認驗證是否有在AndroidMainfest.xml文件中設置 android:debuggable=“true” 屬性。如果你使用的是NDK v8以后的版本,Google不建議你在AndoirdMainfest.xml文件中使用 android:debuggable 屬性(當你使用“ant debug”或ADT插件生成調試版本時,會自動添加“NDK_DEBUG=1”)。
默認情況下,設置了支持64位版本。你也可以通過設置“NDK_HOST_32BIT=1”強制使用一個32位的工具鏈來使用32位應用程序。不過,谷歌仍建議使用64位的應用程序來提升大型程序的性能。
如何建立一個項目?
這 是個令人頭疼的步驟。你要安裝CDT插件并下載cygwin或mingw編譯器和Android NDK,在Eclipse設置里配置這些東西,但***還 是不能運行。我***次開始使用Android NDK時,配置這些東西花了我3天時間。***發現問題出在Cygwin編譯器身上:應該為項目文件夾設置讀、寫、可執行的所有權限。
現在可就簡單多咯!只需要照著這個鏈接到網址:http://developer.android.com/sdk/index.html 下載ADT包,這里面有開始編譯環節需要用到的所有東西。
從Java代碼中調用本地方法
要從Java中調用本地代碼,首先你要在Java類中定義本地方法。例如:
- native String nativeGetStringFromFile(String path) throws IOException;
- native void nativeWriteByteArrayToFile(String path, byte[] b) throws IOException
你得在方法前使用“native”關鍵字。,這樣編譯器就知道這是JNI的入口點。這些方法會在C/C++文件中實現。Google建議用 “native+x”這樣的命名方式,“x”代表著方法的實際名稱。還有,在實現這些方法前你還得手動生成一個頭文件。你可以手動執行此操作或者使用 JDK的 javah工具生成頭文件。然后讓我們將進一步探討如何不用控制臺,直接使用標準的Eclipse開發環境:
- 打開Eclipse,選擇Run -> External-tool-External -> External tools configurations。
- 新建配置。
- 指定javah.exe在jdk里的絕對路徑(例如,C:\Program Files (x86)\Java\jdk1.6.0_35\bin\javah.exe)。
- 在工作目錄中指定bin/class目錄的路徑(例如,«${workspace_loc:/NDKBegin/bin/classes}»)。
- 填入如下參數:“-jni ${java_type_name}” (注意,輸入時不需要帶引號)。
現在你可以運行了。你的頭文件應該放在bin/classes目錄下。下一步,復制這些文件到本地工程的jni目錄。打開工程的配置菜單并選擇 Andorid Tools這一項 — 添加本地庫(Add Native Library)。這樣我們就可以使用jni.h頭文件中包含的函數了。在此之后,你還要創建一個.cpp的文件(有時候 Eclipse會默認生成),并且方法實現已經在頭文件中定義。
考慮到文章長度和可讀性,我并沒有加入簡單的代碼示例,所以你在這里找不到。如果需要,請訪問這個鏈接https://github.com/viacheslavtitov/NDKBegining。