JNI 是 Java Native Interface。它定義了 Android 位元碼的方式,讓 Android 透過代管程式碼 (以 Java 或 Kotlin 程式設計語言編寫) 編譯位元碼,以便與原生程式碼 (以 C/C++ 編寫) 互動。JNI 可以不受供應商影響,還能支援從動態共用程式庫載入程式碼,但有時操作十分麻煩。
注意:由於 Android 會以類似 Java 程式設計語言的方式,將 Kotlin 編譯為適合 ART 的位元碼,因此您可以將本頁指南套用至 Kotlin 和 Java 程式設計語言,進而處理 JNI 架構和相關相關費用。詳情請參閱 Kotlin 和 Android 相關說明。
如果您不熟悉 JNI 的運作方式,請參閱 Java 原生介面規格,瞭解 JNI 的運作方式及可用功能。有些介面元素在一讀時並不會立即顯現,因此接下來的幾個部分可能會派上用場。
如要瀏覽全域 JNI 參照,並查看全域 JNI 參照的建立位置,請在 Android Studio 3.2 以上版本中使用記憶體分析器中的「JNI heap」檢視畫面。
常見提示
請盡量減少 JNI 層的足跡。這裡有幾個維度要考量。 您的 JNI 解決方案應嘗試按照下列規範 (按照重要性由高至低依序列出):
- 有效減少 JNI 層的資源管理。橫跨 JNI 層會產生非常小的費用。建議您設計能盡量減少管理所需資料量的介面,以及管理資料的頻率。
- 盡可能避免在以代管程式設計語言編寫的程式碼與以 C++ 編寫的程式碼之間進行非同步通訊。這可讓 JNI 介面更容易維護。一般而言,您只要使用與 UI 相同的語言保留非同步更新,即可簡化非同步 UI 更新。舉例來說,與其透過 JNI 從 Java 程式碼中的 UI 執行緒叫用 C++ 函式,最好不要在 Java 程式設計語言的兩個執行緒之間進行回呼,其中一個執行緒會發出阻斷的 C++ 呼叫,然後在封鎖呼叫完成時通知 UI 執行緒。
- 盡量減少需要由 JNI 輕觸或輕觸的執行緒數量。如果確實需要以 Java 和 C++ 語言使用執行緒集區,請嘗試在集區擁有者之間保持 JNI 通訊,而不是讓個別工作站執行緒之間的 JNI 通訊。
- 將介面程式碼存放在較容易識別的 C++ 和 Java 來源位置,方便日後重構。建議您視情況使用 JNI 自動產生程式庫。
JavaVM 和 JNIEnv
JNI 定義了兩個關鍵資料結構:「JavaVM」和「JNIEnv」。這兩者基本上都是指向函式資料表的指標。(在 C++ 版本中,類別是具有函式資料表指標的指標,也是透過資料表間接載入的每個 JNI 函式都有成員函式的類別)。JavaVM 提供「叫用介面」函式,能夠讓您建立及刪除 JavaVM。理論上,每個程序可以有多個 JavaVM,但 Android 僅允許一個 JavaVM。
JNIEnv 提供大部分的 JNI 函式。您的原生函式都會收到 JNIEnv 做為第一個引數 (@CriticalNative
方法除外),請參閱更快速的原生呼叫。
JNIEnv 是用於執行緒本機儲存空間。因此,您無法在執行緒之間共用 JNIEnv。如果某段程式碼無法透過其他方式取得 JNIEnv,您應共用 JavaVM,並使用 GetEnv
尋找執行緒的 JNIEnv。(假設有帳戶,請參閱下方的 AttachCurrentThread
)。
JNIEnv 和 JavaVM 的 C 宣告與 C++ 宣告不同。"jni.h"
包含檔案會根據是否在 C 或 C++ 中包含不同類型定義。因此,建議您在這兩種語言都納入的標頭檔案中加入 JNIEnv 引數。(換個方法:如果您的標頭檔案需要 #ifdef __cplusplus
,當該標頭中的任何項目參照 JNIEnv 時,您可能必須執行一些額外工作)。
執行緒
所有執行緒都是由核心排定的 Linux 執行緒。這些變數通常都是從受管理的程式碼啟動 (使用 Thread.start()
),不過您也可以在其他位置建立,然後附加至 JavaVM
。舉例來說,使用 AttachCurrentThread()
或 AttachCurrentThreadAsDaemon()
函式附加以 pthread_create()
或 std::thread
開頭的執行緒。附加執行緒之前,不會有 JNIEnv,而且無法撥打 JNI 呼叫。
一般來說,建議您使用 Thread.start()
建立需要呼叫 Java 程式碼的執行緒。這麼做可確保您有足夠的堆疊空間,位於正確的 ThreadGroup
,且您的 ClassLoader
與 Java 程式碼相同。在 Java 中設定用於偵錯的執行緒名稱,比使用原生程式碼更輕鬆 (如果您有 pthread_t
或 thread_t
,請參閱 pthread_setname_np()
;如果您有 std::thread
且想要 pthread_t
,請參閱 std::thread::native_handle()
)。
附加原生建立的執行緒會建構 java.lang.Thread
物件,並新增至「main」ThreadGroup
,讓偵錯工具看到該物件。如果在已附加的執行緒上呼叫 AttachCurrentThread()
,即無需人工管理。
Android 不會暫停執行原生程式碼的執行緒。如果垃圾收集正在進行中,或是偵錯工具發出暫停要求,Android 會在下次發出 JNI 呼叫時暫停執行緒。
透過 JNI 附加的執行緒必須在結束之前呼叫 DetachCurrentThread()
。如果直接編寫程式碼並不容易,在 Android 2.0 (Eclair) 以上版本中,您可以使用 pthread_key_create()
定義要在執行緒結束前呼叫的解構函式,並從該處呼叫 DetachCurrentThread()
。(將金鑰與 pthread_setspecific()
搭配使用,即可將 JNIEnv 儲存在執行緒本機儲存空間中,如此一來便會將 JNIEnv 當做引數傳遞至解構函式中)。
jclass、jmethodID 和 jfieldID
如果您想從原生程式碼存取物件的欄位,請執行下列步驟:
- 使用
FindClass
取得類別的物件參照 - 取得包含
GetFieldID
的欄位 ID - 取得適當值的欄位內容,例如
GetIntField
同樣地,如要呼叫方法,您必須先取得類別物件參照,然後取得方法 ID。ID 通常只是內部執行階段資料結構的指標。查詢這些查詢可能需要多個字串比較,但一旦您可以實際呼叫以取得欄位或叫用方法,就可以快速完成。
如果效能很重要,建議您一次查詢這些值,並在原生程式碼中快取結果。由於每項程序最多只能有一個 JavaVM,因此將這項資料儲存在靜態本機結構中是合理的。
在類別卸載之前,類別參照、欄位 ID 和方法 ID 保證有效。只有在所有與 ClassLoader 相關聯的類別都可供垃圾收集時,類別才會卸載 (這種情況很少發生,但無法在 Android 中執行)。不過請注意,jclass
是類別參照,且必須受呼叫與 NewGlobalRef
呼叫 (請參閱下一節)。
如要在載入類別時快取 ID,並在類別卸載和重新載入時自動重新快取 ID,初始化 ID 的正確方法就是將一小段程式碼新增至適當的類別:
Kotlin
companion object { /* * We use a static class initializer to allow the native code to cache some * field offsets. This native function looks up and caches interesting * class/field/method IDs. Throws on failure. */ private external fun nativeInit() init { nativeInit() } }
Java
/* * We use a class initializer to allow the native code to cache some * field offsets. This native function looks up and caches interesting * class/field/method IDs. Throws on failure. */ private static native void nativeInit(); static { nativeInit(); }
在 C/C++ 程式碼中建立 nativeClassInit
方法,執行 ID 查詢。類別初始化時,程式碼會執行一次。如果類別曾卸載並重新載入,系統會再次執行該類別。
本地和全域參照
傳送至原生方法的每個引數與幾乎每個 JNI 函式傳回的物件都是「本機參照」。也就是說,在目前執行緒中的目前原生方法的時間長度內都有效。即使在原生方法傳回後,物件本身會繼續運作,參照也會無效。
這適用於所有 jobject
的子類別,包括 jclass
、jstring
和 jarray
。(啟用擴充的 JNI 檢查時,執行階段會警告大部分參照錯誤)。
要取得非本機參照,唯一的方法是透過 NewGlobalRef
函式和 NewWeakGlobalRef
函式。
如要長期保留某個參照,您必須使用「全域」參照。NewGlobalRef
函式會將本機參照做為引數,並傳回全域參照。全域參照保證在您呼叫 DeleteGlobalRef
之前有效。
這個模式經常用於快取從 FindClass
傳回的 jclass,例如:
jclass localClass = env->FindClass("MyClass"); jclass globalClass = reinterpret_cast<jclass>(env->NewGlobalRef(localClass));
所有 JNI 方法都接受本機和全域參照做為引數。同一個物件的參照可能會有不同的值。舉例來說,在同一個物件上連續呼叫 NewGlobalRef
所傳回的值可能有所不同。如要查看兩個參照是否參照同一個物件,您必須使用 IsSameObject
函式。切勿比較原生程式碼中與 ==
的參照。
這種情況的其中一個結果,就是您不得假設原生程式碼中的物件參照是常數或不重複。代表物件的值可能與下一個方法叫用的值不同,而且兩個不同的物件在連續呼叫中可能會有相同的值。請勿使用 jobject
值做為索引鍵。
程式設計師必須「未過度配置」的當地參考檔案。實際上,這意味著如果要建立大量的本機參照 (可能在透過一系列物件執行時),應使用 DeleteLocalRef
手動釋放這些參照,而非讓 JNI 為您執行。實作項目只需要為 16 個本機參照預留運算單元,因此如果您需要超過 16 個本機參照,則應在執行時刪除更多運算單元,或使用 EnsureLocalCapacity
/PushLocalFrame
預留更多運算單元。
請注意,jfieldID
和 jmethodID
是不透明類型,而非物件參照,且不應傳遞至 NewGlobalRef
。GetStringUTFChars
和 GetByteArrayElements
等函式傳回的原始資料點也不是物件。(可在執行緒之間傳遞,且在相符的 Release 呼叫前有效)。
在特殊情況中,您必須單獨提及。如果您使用 AttachCurrentThread
附加原生執行緒,則執行中的程式碼永遠不會自動釋放本機參照,直到執行緒卸離為止。您必須手動刪除您建立的所有本機參照。一般來說,在迴圈中建立本機參照的任何原生程式碼都可能需要手動刪除。
請謹慎使用全域參照。全域參照可能不可避免,但這類參照難以偵錯,且可能會造成難以診斷的記憶體 (錯誤) 行為。在所有其他條件相同的情況下,全域參照較少的解決方案可能更好。
UTF-8 和 UTF-16 字串
Java 程式設計語言使用 UTF-16。為了方便起見,JNI 也提供了支援修改後的 UTF-8 方法。經過修改的編碼對 C 程式碼很實用,因為其編碼為 \u0000 為 0xc0 0x80,而非 0x00。值得一提的是,您可以計算具有 C 樣式的零結束字串,該字串適合與標準 libc 字串函式搭配使用。但缺點是您無法將任意 UTF-8 資料傳送至 JNI,並預期資料可正常運作。
如要取得 String
的 UTF-16 表示,請使用 GetStringChars
。請注意,UTF-16 字串並非以零結尾,且可以使用「\u0000」,因此您需要保留字串長度和 jchar 指標。
別忘了Release
您 Get
的字串。字串函式會傳回 jchar*
或 jbyte*
,這是指向原始資料的 C 樣式指標,而非本機參照。這些憑證保證在呼叫 Release
之前都有效,也就是說,原生方法傳回時不會釋出。
傳遞至 NewStringUTF 的資料必須採用修改後的 UTF-8 格式。常見的錯誤是從檔案或網路串流讀取字元資料,然後在不篩選的情況下將其交給 NewStringUTF
。除非您知道資料是有效的 MUTF-8 或 7 位元 ASCII (是相容的子集),否則您需要移除無效字元,或將字元轉換為正確修改的 UTF-8 格式。否則 UTF-16 轉換就很有可能會帶來非預期的結果。模擬器預設啟用的 CheckJNI 會掃描字串,並在 VM 收到無效的輸入時取消 VM。
在 Android 8 之前,由於 Android 不需要在 GetStringChars
中複製,而在 Android 8 之前,使用 UTF-16 字串可以加快操作速度,而 GetStringUTFChars
則需要配置並轉換成 UTF-8。Android 8 將 String
表示法變更為將 ASCII 字串每個字元 8 位元改為每個字元 (為了節省記憶體),並開始使用移動垃圾收集器。這些功能可大幅減少 ART 可提供 String
資料指標的次數,不必建立副本 (即使是 GetStringCritical
)。不過,如果程式碼處理的大多數字串都很短,在大多數情況下,您都可以使用堆疊分配的緩衝區和 GetStringRegion
或 GetStringUTFRegion
,避免配置和取消配置。例如:
constexpr size_t kStackBufferSize = 64u; jchar stack_buffer[kStackBufferSize]; std::unique_ptrheap_buffer; jchar* buffer = stack_buffer; jsize length = env->GetStringLength(str); if (length > kStackBufferSize) { heap_buffer.reset(new jchar[length]); buffer = heap_buffer.get(); } env->GetStringRegion(str, 0, length, buffer); process_data(buffer, length);
原始陣列
JNI 提供用於存取陣列物件內容的函式。雖然物件陣列一次只能存取一個項目,但基本陣列的讀取及寫入作業就像在 C 中宣告一樣。
為了在不限制 VM 實作的情況下盡可能提高介面效率,Get<PrimitiveType>ArrayElements
呼叫系列可讓執行階段傳回實際元素的指標,或分配一些記憶體並複製。無論哪種方式,傳回的原始指標保證在發出對應的 Release
呼叫之前都有效 (這表示如果資料未複製,系統會將陣列物件固定,且無法在壓縮堆積的過程中,移動至陣列物件)。您必須對自己 Get
的每個陣列執行 Release
。此外,如果 Get
呼叫失敗,您必須確保程式碼稍後不會嘗試對空值指標 Release
。
您可以傳入 isCopy
引數的非 NULL 指標,判斷資料是否已複製。這很少有用。
Release
呼叫接受的 mode
引數可有三個值的其中一個,執行階段執行的動作取決於其傳回指向實際資料的指標還是副本:
0
- 實際:陣列物件未固定。
- 複製:系統會複製資料。系統會釋出含有副本的緩衝區。
JNI_COMMIT
- 實際:不執行任何動作。
- 複製:系統會複製資料。含有副本的緩衝區不會釋出。
JNI_ABORT
- 實際:陣列物件未固定。先前的寫入作業「不會」取消。
- 複製:複製的緩衝區是釋出的,其中任何變更都會遺失。
檢查 isCopy
旗標的其中一個原因,是知道在變更陣列後,是否需要使用 JNI_COMMIT
呼叫 Release
。如果您在進行變更及執行使用陣列內容的程式碼之間,也許可以略過免人工管理修訂版本。檢查標記的另一個可能原因,是有效處理 JNI_ABORT
。例如,您可能會想要取得陣列、加以修改、將部分項目傳送至其他函式,然後捨棄變更。如果您知道 JNI 正在為您建立新的副本,就不需要再建立另一個「可編輯」副本。如果 JNI 將原始檔案傳送給您,您就必須建立自己的副本。
常見的錯誤 (範例程式碼重複) 假設 *isCopy
為 false 時可略過 Release
呼叫。但事實並非如此。如未分配任何複製緩衝區,則原始記憶體必須固定,且垃圾收集器無法移動原始記憶體。
另請注意,JNI_COMMIT
旗標「不會」釋出陣列,而您必須以不同的標記再次呼叫 Release
。
區域呼叫
您可以改用其他方法 (例如 Get<Type>ArrayElements
和 GetStringChars
) 進行呼叫,這樣當您只需要複製資料或匯出資料時,這些方法會相當實用。請考量下列幾項重點:
jbyte* data = env->GetByteArrayElements(array, NULL); if (data != NULL) { memcpy(buffer, data, len); env->ReleaseByteArrayElements(array, data, JNI_ABORT); }
這會擷取陣列、複製其中的前 len
位元組元素,然後釋放陣列。視實作方式而定,Get
呼叫會固定或複製陣列內容。程式碼會複製資料 (可能再次執行一次),然後呼叫 Release
;在本例中,JNI_ABORT
不會發生第三個副本。
想要做到這一點也很簡單:
env->GetByteArrayRegion(array, 0, len, buffer);
這種做法有幾個優點:
- 需要一個 JNI 呼叫而非 2,進而降低負擔。
- 不需要固定或額外建立資料副本。
- 可降低程式設計人員錯誤的風險,避免因作業失敗後忘記呼叫
Release
。
同樣地,您也可以使用 Set<Type>ArrayRegion
呼叫將資料複製到陣列,並使用 GetStringRegion
或 GetStringUTFRegion
從 String
複製字元。
例外狀況
例外狀況為待處理狀態時,您不得呼叫大多數 JNI 函式。
您的程式碼應注意到例外狀況 (透過函式的傳回值、ExceptionCheck
或 ExceptionOccurred
),並傳回,或是清除例外狀況並進行處理。
例外狀況如下:您唯一可以呼叫的 JNI 函式:
DeleteGlobalRef
DeleteLocalRef
DeleteWeakGlobalRef
ExceptionCheck
ExceptionClear
ExceptionDescribe
ExceptionOccurred
MonitorExit
PopLocalFrame
PushLocalFrame
Release<PrimitiveType>ArrayElements
ReleasePrimitiveArrayCritical
ReleaseStringChars
ReleaseStringCritical
ReleaseStringUTFChars
許多 JNI 呼叫可能會擲回例外狀況,但通常可以提供更簡單的失敗檢查方式。舉例來說,如果 NewString
傳回非 NULL 值,您就無須檢查例外狀況。但是,如果您呼叫方法 (使用類似 CallObjectMethod
的函式),您就必須檢查例外狀況,因為擲回例外狀況時回傳值並不會有效。
請注意,代管程式碼擲回的例外狀況不會解開原生堆疊框架。(一般不建議在 Android 上使用 C++ 例外狀況,也不得在 JNI 轉換邊界從 C++ 程式碼到代管程式碼之間遭到擲回)。
JNI Throw
和 ThrowNew
指令只會在目前的執行緒中設定例外狀況指標。從原生程式碼返回管理時,會發生適當情況並妥善處理。
原生程式碼可以呼叫 ExceptionCheck
或 ExceptionOccurred
來「抓取」例外狀況,並使用 ExceptionClear
將其清除。與往常一樣,如未處理例外狀況,而捨棄例外狀況,可能會產生問題。
沒有內建函式可以自行操控 Throwable
物件,因此,如要 (假設) 取得例外狀況字串,您必須找出 Throwable
類別、查詢 getMessage "()Ljava/lang/String;"
的方法 ID、叫用該類別,以及使用 GetStringUTFChars
取得可處理 printf(3)
或對等項目的結果。
延長檢查時間
JNI 也很少檢查錯誤。錯誤通常會導致當機。Android 也提供 CheckJNI 模式,其中 JavaVM 和 JNIEnv 函式資料表指標會切換為函式資料表,在呼叫標準實作之前執行一系列延伸檢查。
這些額外檢查包括:
- 陣列:嘗試分配負大小的陣列。
- 錯誤的指標:將錯誤的 jarray/jclass/jobject/jstring 傳遞至 JNI 呼叫,或將 NULL 指標傳遞至具有不可為空值引數的 JNI 呼叫。
- 類別名稱:將類別名稱的「java/lang/String」樣式以外的任何項目傳送至 JNI 呼叫。
- 重要呼叫:在「重大」取得及其對應的版本之間發出 JNI 呼叫。
- Direct ByteBuffers:將錯誤引數傳遞至
NewDirectByteBuffer
。 - 例外狀況:有待處理的例外狀況時發出 JNI 呼叫。
- JNIEnv*s:從錯誤的執行緒使用 JNIEnv*。
- jfieldID:使用 NULL jfieldID,或使用 jfieldID 將欄位設為錯誤類型的值 (例如嘗試將 StringBuilder 指派給 String 欄位),或對靜態欄位使用 jfieldID 來設定實例欄位,或是使用類別中的 jfieldID,其類別的執行個體使用另一個類別。
- jmethodID:發出
Call*Method
JNI 呼叫時,使用錯誤的 jmethodID 類型:傳回類型不正確、靜態/非靜態不相符、「this」類型有誤 (適用於非靜態呼叫) 或錯誤的類別 (適用於靜態呼叫)。 - 參照:將
DeleteGlobalRef
/DeleteLocalRef
用於錯誤的參考類型。 - 版本模式:將錯誤的發布模式傳遞至版本呼叫 (
0
、JNI_ABORT
或JNI_COMMIT
以外的項目)。 - 類型安全:從您的原生方法傳回不相容的類型 (從宣告的方法傳回 StringBuilder,以傳回 String)。
- UTF-8:將無效的修改後的 UTF-8 位元組序列傳遞至 JNI 呼叫。
(系統不會檢查方法和欄位的無障礙設定:存取權限制不適用於原生程式碼)。
啟用 CheckJNI 的方法有很多種。
如果您使用的是模擬器,會預設開啟 CheckJNI。
如果您有已解鎖裝置,可以在啟用 CheckJNI 的情況下,使用以下的一系列指令重新啟動執行階段:
adb shell stop adb shell setprop dalvik.vm.checkjni true adb shell start
在上述任一情況下,當執行階段啟動時,您會在 Logcat 輸出內容中看到類似下方的內容:
D AndroidRuntime: CheckJNI is ON
如果您使用的是一般裝置,可以使用以下指令:
adb shell setprop debug.checkjni 1
這不會影響已在執行的應用程式,但從此時間點啟動的任何應用程式,都會啟用 CheckJNI。(請將屬性變更為其他值或直接重新啟動,系統會再次停用 CheckJNI)。在此情況下,您下次應用程式啟動時,Logcat 輸出內容中會如下所示:
D Late-enabling CheckJNI
您也可以在應用程式資訊清單中設定 android:debuggable
屬性,只為應用程式啟用 CheckJNI。請注意,Android 建構工具會自動為特定建構類型執行此作業。
原生程式庫
您可以使用標準 System.loadLibrary
,從共用程式庫載入原生程式碼。
實際上,舊版 Android 的 PackageManager 中存在錯誤,導致安裝及更新原生資料庫並不穩定。ReLinker 專案針對此問題和其他原生程式庫載入問題提供瞭解決方法。
從靜態類別初始化器呼叫 System.loadLibrary
(或 ReLinker.loadLibrary
)。引數為「未修飾」的程式庫名稱,因此如要載入 libfubar.so
,您將傳入 "fubar"
。
如果只有一個使用原生方法的類別,對 System.loadLibrary
的呼叫必須位於該類別的靜態初始化器中。否則,建議您從 Application
發出呼叫,讓您瞭解程式庫一律會載入並一律提早載入。
執行階段可透過兩種方式找到您的原生方法。您可以使用 RegisterNatives
明確註冊,也可以讓執行階段使用 dlsym
動態查詢。RegisterNatives
的優點在於,您可以預先檢查符號是否存在,也可以不匯出任何項目 (JNI_OnLoad
) 以縮減共用程式庫,並加快其執行速度。讓執行階段發現函式的好處是,需要編寫的程式碼較少。
如何使用「RegisterNatives
」:
- 提供
JNIEXPORT jint JNI_OnLoad(JavaVM* vm, void* reserved)
函式。 - 在
JNI_OnLoad
中,使用RegisterNatives
註冊所有原生方法。 - 使用
-fvisibility=hidden
建構,讓系統只從程式庫匯出JNI_OnLoad
。這能使程式碼更快且更小,並避免與載入應用程式的其他程式庫發生衝突 (但如果應用程式在原生程式碼中異常終止,這樣做會產生較不實用的堆疊追蹤)。
靜態初始化器應如下所示:
Kotlin
companion object { init { System.loadLibrary("fubar") } }
Java
static { System.loadLibrary("fubar"); }
如果以 C++ 編寫,JNI_OnLoad
函式應如下所示:
JNIEXPORT jint JNI_OnLoad(JavaVM* vm, void* reserved) { JNIEnv* env; if (vm->GetEnv(reinterpret_cast<void**>(&env), JNI_VERSION_1_6) != JNI_OK) { return JNI_ERR; } // Find your class. JNI_OnLoad is called from the correct class loader context for this to work. jclass c = env->FindClass("com/example/app/package/MyClass"); if (c == nullptr) return JNI_ERR; // Register your class' native methods. static const JNINativeMethod methods[] = { {"nativeFoo", "()V", reinterpret_cast<void*>(nativeFoo)}, {"nativeBar", "(Ljava/lang/String;I)Z", reinterpret_cast<void*>(nativeBar)}, }; int rc = env->RegisterNatives(c, methods, sizeof(methods)/sizeof(JNINativeMethod)); if (rc != JNI_OK) return rc; return JNI_VERSION_1_6; }
如要改用原生方法的「探索」,您必須以特定方式命名 (詳情請參閱 JNI 規格)。這表示如果方法簽章有誤,您必須等到第一次叫用此方法後,才能知道該方法簽章。
從 JNI_OnLoad
發出的任何 FindClass
呼叫,都會在用於載入共用資料庫的類別載入器的結構定義中解析類別。從其他結構定義呼叫時,FindClass
會使用與 Java 堆疊頂端方法相關聯的類別載入器;如果沒有 (因為呼叫是來自剛剛附加的原生執行緒),就會使用「系統」類別載入器。系統類別載入器不知道應用程式的類別,因此您無法在該結構定義中使用 FindClass
查詢自己的類別。如此一來,JNI_OnLoad
就能輕鬆地查詢及快取類別;一旦具備有效的 jclass
全域參照,便可從任何附加的執行緒中使用。
使用 @FastNative
和 @CriticalNative
加快原生通話速度
原生方法可以使用 @FastNative
或 @CriticalNative
註解 (但不能同時加上兩者),以加快代管與原生程式碼之間的轉換。不過,這類註解設有特定行為變更,在使用前必須審慎考量。雖然我們簡短提及這些變更,但詳情請參閱說明文件。
@CriticalNative
註解只能套用至不使用代管物件 (在參數或傳回值,或隱含 this
) 的原生方法,而且這個註解會變更 JNI 轉換 ABI。原生實作必須從函式簽章中排除 JNIEnv
和 jclass
參數。
執行 @FastNative
或 @CriticalNative
方法時,垃圾收集無法針對重要工作暫停執行緒,且可能會遭到封鎖。請不要將這些註解用於長時間執行的方法,包括通常很快,但通常不受限的方法。特別是,程式碼不應該執行大量 I/O 作業,或取得可能長時間保留的原生鎖定。
這些註解自 Android 8 起已針對系統使用而實作,在 Android 14 中成為通過 CTS 測試的公用 API。這些最佳化功能可能也適用於 Android 8 到 13 裝置 (儘管沒有高強度 CTS 保證),但只有 Android 12 以上版本支援原生方法的動態查詢,而在 Android 8 到 11 版本上執行時,必須使用 JNI RegisterNatives
明確註冊。Android 7 會忽略這些註解,因為 @CriticalNative
的 ABI 不相符會導致引數管理錯誤,並可能導致當機。
若是需要這些註解的效能關鍵方法,強烈建議您使用 JNI RegisterNatives
明確註冊方法,不要依賴原生方法的以名稱為基礎的「探索」。為獲得最佳應用程式啟動效能,建議您在基準設定檔中加入 @FastNative
或 @CriticalNative
方法的呼叫端。自 Android 12 起,在 C/C++ 中呼叫 @CriticalNative
原生方法的費用幾乎與 C/C++ 中的非內嵌呼叫一樣低,只要所有引數都符合註冊條件 (例如 arm64 中最多 8 個積分及最多 8 個浮點引數)。
有時候,建議您將原生方法分割為兩個,一個非常快速的方法可能會失敗,另一個則能處理速度緩慢的情況。例如:
Kotlin
fun writeInt(nativeHandle: Long, value: Int) { // A fast buffered write with a `@CriticalNative` method should succeed most of the time. if (!nativeTryBufferedWriteInt(nativeHandle, value)) { // If the buffered write failed, we need to use the slow path that can perform // significant I/O and can even throw an `IOException`. nativeWriteInt(nativeHandle, value) } } @CriticalNative external fun nativeTryBufferedWriteInt(nativeHandle: Long, value: Int): Boolean external fun nativeWriteInt(nativeHandle: Long, value: Int)
Java
void writeInt(long nativeHandle, int value) { // A fast buffered write with a `@CriticalNative` method should succeed most of the time. if (!nativeTryBufferedWriteInt(nativeHandle, value)) { // If the buffered write failed, we need to use the slow path that can perform // significant I/O and can even throw an `IOException`. nativeWriteInt(nativeHandle, value); } } @CriticalNative static native boolean nativeTryBufferedWriteInt(long nativeHandle, int value); static native void nativeWriteInt(long nativeHandle, int value);
64 位元注意事項
如要支援使用 64 位元指標的架構,在將指標儲存至 Java 欄位中原生結構時,請使用 long
欄位 (而非 int
)。
不支援的功能/回溯相容性
支援所有 JNI 1.6 功能,但下列情況除外:
- 未實作
DefineClass
。Android 不會使用 Java 位元碼或類別檔案,因此無法傳入二進位類別資料。
為了提供與舊版 Android 的回溯相容性,您可能需要注意以下事項:
- 動態查詢原生函式
在 Android 2.0 (Eclair) 之前,搜尋方法名稱時,「$」字元無法正確轉換為「_00024」。如要解決這個問題,必須使用明確註冊,或將原生方法移出內部類別。
- 卸離執行緒
在 Android 2.0 (Eclair) 之前,您無法使用
pthread_key_create
解構函式來避開「執行緒必須在通過檢查前卸離」。(執行階段也會使用 pthread 金鑰解構函式,因此需要查看先呼叫哪個函式)。 - 全球參考資源弱
在 Android 2.2 (Froyo) 之前,系統並未實作微弱的全域參照。如果嘗試在舊版本中嘗試使用這些版本,系統會謹慎地拒絕這些嘗試。您可以使用 Android 平台版本常數來測試支援。
在 Android 4.0 (Ice Cream Sandwich) 之前,全域參照只能傳遞至
NewLocalRef
、NewGlobalRef
和DeleteWeakGlobalRef
。(這項規格強烈建議程式設計師先建立對安全漏洞全域弱點的硬式參照,再加以處理,因此不應受到任何限制。)從 Android 4.0 (Ice Cream Sandwich) 開啟後,像任何其他 JNI 參照一樣,全域參照都會使用微弱的全域參照。
- 本機參照
在 Android 4.0 (Ice Cream Sandwich) 之前,本機參照實際上是直接指標。Ice Cream Sandwich 新增了必要的指令,以便支援更完善的垃圾收集器,但這表示舊版中的許多 JNI 錯誤無法偵測。詳情請參閱 ICS 的 JNI 本機參照變更。
在 Android 8.0 之前的 Android 版本中,本機參照的數量上限為版本專屬限制。從 Android 8.0 版開始,Android 即可支援無限量的本機參照。
- 使用
GetObjectRefType
確定參照類型在 Android 4.0 (Ice Cream Sandwich) 之前,由於使用直接指標 (請見上文),可能無法正確實作
GetObjectRefType
。因此,我們使用經驗法則,依序查看弱的全域資料表、引數、本機資料表和全域資料表。首次找到你的直接指標時,系統會回報你的參照屬於正在檢查的類型。舉例來說,如果您在全域 jclass 上呼叫GetObjectRefType
,而該 jclass 與做為隱含引數傳遞至靜態原生方法的 jclass 相同,您會取得JNILocalRefType
,而非JNIGlobalRefType
。 @FastNative
和@CriticalNative
在 Android 7 以下版本中,系統會忽略這些最佳化註解。
@CriticalNative
的 ABI 不符將導致引數管理錯誤,並可能導致當機。@FastNative
和@CriticalNative
方法的原生函式動態查詢未在 Android 8 到 10 中實作,且包含 Android 11 中的已知錯誤。如果在未明確註冊 JNIRegisterNatives
的情況下使用這些最佳化功能,可能會導致 Android 8 到 11 版本發生當機問題。FindClass
會擲回ClassNotFoundException
為了提供回溯相容性,Android 在
FindClass
找不到類別時,會擲回ClassNotFoundException
,而非NoClassDefFoundError
。這個行為與 Java 反射 APIClass.forName(name)
一致。
常見問題:為什麼我會收到 UnsatisfiedLinkError
?
處理原生程式碼時,出現類似以下的失敗情形:
java.lang.UnsatisfiedLinkError: Library foo not found
在某些情況下,這代表找不到程式庫。在其他情況下,存在程式庫,但 dlopen(3)
無法開啟,您可以在例外狀況的詳細資料訊息中找到失敗的詳細資料。
遇到「找不到程式庫」例外狀況的常見原因:
- 程式庫不存在或應用程式無法存取。請使用
adb shell ls -l <path>
檢查程式庫是否存在以及權限。 - 這個程式庫不是使用 NDK 建構而成。這可能會導致裝置上不存在的函式或程式庫的依附元件。
另一種 UnsatisfiedLinkError
失敗類別如下所示:
java.lang.UnsatisfiedLinkError: myfunc at Foo.myfunc(Native Method) at Foo.main(Foo.java:10)
Logcat 中會顯示:
W/dalvikvm( 880): No implementation found for native LFoo;.myfunc ()V
這表示執行階段嘗試尋找相符方法,但失敗。常見原因如下:
- 尚未載入程式庫。請查看 Logcat 輸出內容,瞭解程式庫載入的相關訊息。
- 名稱或簽章不相符,因此找不到方法。這通常是因為:
- 針對延遲方法查詢,無法使用
extern "C"
和適當的瀏覽權限宣告 C++ 函式 (JNIEXPORT
)。請注意,在 Ice Cream Sandwich 之前,JNIEXPORT 巨集不正確,因此使用含有舊jni.h
的新 GCC 將無法運作。您可以使用arm-eabi-nm
查看程式庫中顯示的符號。如果符號看起來變形 (例如_Z15Java_Foo_myfuncP7_JNIEnvP7_jclass
而不是Java_Foo_myfunc
),或者符號類型為小寫的 T 而非大寫「T」,您就必須調整宣告內容。 - 明確註冊時,輸入方法簽章時會出現小錯誤。請確認您傳遞至註冊呼叫的內容與記錄檔中的簽章相符。請注意,「B」是
byte
,「Z」是boolean
。 簽章中的類別名稱元件以「L」開頭,以「;」結尾,請使用「/」來分隔套件/類別名稱,並使用「$」分隔內部類別名稱 (如Ljava/util/Map$Entry;
所示)。
- 針對延遲方法查詢,無法使用
使用 javah
自動產生 JNI 標頭可能有助於避免某些問題。
常見問題:為什麼 FindClass
找不到我的課程?
(對於失敗尋找含有 GetMethodID
或 GetStaticMethodID
方法,或含有 GetFieldID
或 GetStaticFieldID
的欄位,這項建議大部分都同樣適用)。
請確認類別名稱字串格式正確。JNI 類別名稱以套件名稱開頭,並以正斜線分隔,例如 java/lang/String
。如要查詢陣列類別,首先需使用適當的方括號數量,並且須使用「L」和「;」納入類別,因此 String
的一維陣列會是 [Ljava/lang/String;
。如要查詢內部類別,請使用「$」,而不是「.」。一般來說,在 .class 檔案中使用 javap
是找出類別內部名稱的好方法。
如果您啟用程式碼縮減功能,請務必設定要保留的程式碼。設定適當的保留規則非常重要,因為程式碼縮減器可能會移除僅從 JNI 使用的類別、方法或欄位。
如果類別名稱看起來沒問題,表示可能發生類別載入器問題。FindClass
想要在與程式碼相關聯的類別載入器中啟動類別搜尋。這會檢查呼叫堆疊,如下所示:
Foo.myfunc(Native Method) Foo.main(Foo.java:10)
最頂端的方法為 Foo.myfunc
。FindClass
會尋找與 Foo
類別相關聯的 ClassLoader
物件,並使用該物件。
通常可以執行所需作業。如果您自行建立執行緒,可能會遇到困難 (可能藉由呼叫 pthread_create
並附加 AttachCurrentThread
來附加),但您的應用程式現在沒有堆疊框架。如果您從這個執行緒呼叫 FindClass
,JavaVM 會啟動在「系統」類別載入器 (而非與應用程式相關聯的函式) 中,因此嘗試尋找應用程式特定類別會失敗。
以下提供幾種解決方法:
- 在
JNI_OnLoad
中執行FindClass
查詢一次,並快取類別參照供日後使用。在執行JNI_OnLoad
期間發出的任何FindClass
呼叫,都會使用與呼叫System.loadLibrary
函式相關聯的類別載入器 (為讓程式庫初始化作業更便利而提供的類別載入器)。如果應用程式的程式碼正在載入程式庫,FindClass
會使用正確的類別載入器。 - 藉由宣告原生方法取得類別引數,然後傳入
Foo.class
,將類別的例項傳遞至需要該類別的函式。 - 在方便的位置快取
ClassLoader
物件的參照,並直接發出loadClass
呼叫。這需要一段時間。
常見問題:如何使用原生程式碼分享原始資料?
您可能會發現自己需要從代管程式碼和原生程式碼存取大量原始資料。常見的例子包括操控點陣圖或聲音樣本。基本的做法有兩種
您可以將資料儲存在 byte[]
中。這樣就能以極快的速度透過受管理的程式碼存取。但是,在原生端,您無法保證不必複製資料,即可存取資料。在某些實作中,GetByteArrayElements
和 GetPrimitiveArrayCritical
會傳回代管堆積中原始資料的實際指標,但在其他實作中,則會在原生堆積上分配緩衝區,並複製資料。
另一種做法是將資料儲存在直接位元組緩衝區中。您可以使用 java.nio.ByteBuffer.allocateDirect
或 JNI NewDirectByteBuffer
函式建立這些內容。與一般位元組緩衝區不同,儲存空間不會在受管理的堆積上分配,而且可隨時透過原生程式碼存取 (使用 GetDirectBufferAddress
取得位址)。視直接位元組緩衝區存取的實作方式而定,從代管程式碼存取資料的速度可能非常緩慢。
選擇要使用的選項取決於兩個因素:
- 大多數的資料存取作業是否來自以 Java 或 C/C++ 編寫的程式碼?
- 如果資料最終會傳送至系統 API,必須使用哪種形式?(舉例來說,如果資料最終傳遞至接受位元組 [] 的函式,則直接
ByteBuffer
處理可能會不切實際)。
如果沒有明顯勝出者,請使用直接位元組緩衝區。JNI 直接內建相關支援功能,日後推出的版本應能改善效能。