JNI 是 Java 原生介面。這個程式庫定義了 Android 從代管程式碼 (以 Java 或 Kotlin 程式設計語言編寫) 編譯的位元碼,與原生程式碼互動 (以 C/C++ 編寫) 的方式。JNI 不受廠商限制,支援從動態共享程式庫載入程式碼,有時繁瑣的效率相當高。
注意:Android 會以類似 Java 程式設計語言的做法,將 Kotlin 編譯為適合 ART 的位元碼,因此您可以依據 JNI 架構及其相關費用,將本頁內容應用到 Kotlin 和 Java 程式設計語言。詳情請參閱 Kotlin 和 Android。
如果您還不熟悉,請參閱 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 僅允許一個。
JNIEnv 提供大部分的 JNI 函式。您的原生函式全都會收到 JNIEnv 做為第一個引數,但 @CriticalNative
方法除外,請參閱更快速的原生呼叫一節。
JNIEnv 用於執行緒本機儲存空間。因此,您無法在執行緒之間共用 JNIEnv。如果某項程式碼沒有其他取得其 JNIEnv 的方法,您應共用 JavaVM,並使用 GetEnv
來探索執行緒的 JNIEnv。(假設有一個帳戶,請參閱下方的 AttachCurrentThread
)。
JNIEnv 和 JavaVM 的 C 宣告與 C++ 宣告不同。"jni.h"
內含檔案會根據檔案 (包含在 C 或 C++ 中) 提供不同的類型定義。因此,不建議在兩種語言包含的標頭檔案中加入 JNIEnv 引數。(另做其他方式:如果您的標頭檔案需要 #ifdef __cplusplus
,如果該標頭中有任何項目參照 JNIEnv,您可能需要執行一些額外作業)。
Threads
所有執行緒都是 Linux 執行緒,由核心排程。通常是從代管程式碼 (使用 Thread.start()
) 開始,不過您也可以在其他位置建立這些程式碼,然後再附加至 JavaVM
。舉例來說,使用 AttachCurrentThread()
或 AttachCurrentThreadAsDaemon()
函式附加以 pthread_create()
或 std::thread
啟動的執行緒。附加執行緒之前,它不會有任何 JNIEnv,而且無法進行 JNI 呼叫。
建議選擇使用 Thread.start()
建立需要呼叫 Java 程式碼的任何執行緒。這樣做可確保您有足夠的堆疊空間、位於正確的 ThreadGroup
,且使用的是與 Java 程式碼相同的 ClassLoader
。相較於原生程式碼,在 Java 中設定執行緒名稱以便偵錯也更容易 (如果您使用 pthread_t
或 thread_t
,請參閱 pthread_setname_np()
;如果您有 std::thread
且想要 pthread_t
,請參閱 std::thread::native_handle()
)。
附加原生建立的執行緒會建構 java.lang.Thread
物件並新增至「主要」ThreadGroup
,讓偵錯工具知道。在已安裝的執行緒上呼叫 AttachCurrentThread()
是免人工管理的操作。
Android 不會暫停執行原生程式碼的執行緒。如果垃圾收集正在執行,或是偵錯工具已發布暫停要求,Android 會在下次發出 JNI 呼叫時暫停執行緒。
透過 JNI 連接的執行緒必須在離開前呼叫 DetachCurrentThread()
。如果直接編寫程式碼並不容易,在 Android 2.0 (Eclair) 以上版本中,您可以使用 pthread_key_create()
定義在執行緒退出前呼叫的解構函式,並從中呼叫 DetachCurrentThread()
。(將該金鑰與 pthread_setspecific()
搭配使用,以便將 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 函式傳回的幾乎每個物件都是「本機參考資料」。換句話說,該 ID 在目前執行緒的執行期間有效。即使在原生方法傳回後,物件本身仍持續存在,但參照無效。
這適用於 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 個本機參照保留運算單元,因此如果您需要超過這個數量,請在使用時刪除,或使用 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。
在 Android 8 之前的版本中,使用 UTF-16 字串執行作業通常較快,因為 Android 不需要在 GetStringChars
中建立複製,而 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
呼叫前,都一定是傳回的原始指標有效。也就是說,如果系統未複製資料,陣列物件會固定,且無法在壓縮堆積時重新放置。您必須Release
Get
的每個陣列。此外,如果 Get
呼叫失敗,您必須確保程式碼不會在之後嘗試 Release
NULL 指標。
您可以傳入 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 並叫用該字串;如果結果並非非 NULL,請使用 GetStringUTFChars
取得可處理 printf(3)
或同等結果的項目。
延長檢查時間
JNI 幾乎不需要檢查錯誤。錯誤通常會導致當機。Android 也提供名為 CheckJNI 的模式,其中 JavaVM 和 JNIEnv 函式的資料表指標會轉換為函式表格,而這些表格會在呼叫標準實作之前執行一系列擴充檢查。
額外檢查包括:
- 陣列:嘗試分配負大小的陣列。
- 錯誤指標:將錯誤的 jarray/jclass/jobject/jstring 傳送至 JNI 呼叫,或將 NULL 指標傳送至具有非空值引數的 JNI 呼叫。
- 類別名稱:將類別名稱以外的任何「java/lang/String」樣式傳遞至 JNI 呼叫。
- 重要呼叫:在「重大」get 及其相應版本之間發出 JNI 呼叫。
- Direct ByteBuffers:將錯誤的引數傳遞至
NewDirectByteBuffer
。 - 例外狀況:在有待處理的例外狀況時進行 JNI 呼叫。
- JNIEnv*s:從錯誤的執行緒中使用 JNIEnv*。
- jfieldID:使用 NULL jfieldID 或 jfieldID,將欄位設為錯誤類型的值 (嘗試將 StringBuilder 指派給 String 欄位,像是說),或使用 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 建構工具會針對特定建構類型自動啟用 CheckJNI。
原生程式庫
您可以透過標準 System.loadLibrary
從共用程式庫載入原生程式碼。
實際上,舊版 Android 的 PackageManager 中有錯誤,導致安裝和更新原生程式庫不穩定。ReLinker 專案提供解決方法,以及其他原生程式庫載入問題。
從靜態類別初始化器呼叫 System.loadLibrary
(或 ReLinker.loadLibrary
)。引數是「未修飾」的程式庫名稱,因此要載入您將傳入 "fubar"
的 libfubar.so
。
如果您只有一個包含原生方法的類別,呼叫 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 會在 Android 7 上忽略這些註解,若 @CriticalNative
的 ABI 不相符,則會導致引數管理錯誤,並可能發生當機問題。
對於需要這些註解的效能關鍵方法,強烈建議您使用 JNI RegisterNatives
明確註冊方法,而非依賴原生方法的名稱式「探索」。如要獲得最佳應用程式啟動效能,建議您在基準設定檔中加入 @FastNative
或 @CriticalNative
方法的呼叫端。自 Android 12 起,對已編譯的代管方法呼叫 @CriticalNative
原生方法的成本幾乎和在 C/C++ 中使用非內嵌呼叫一樣低廉,前提是所有引數皆適用於暫存器 (例如 8 個整數,以及 arm64 上最多 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 相同,就會取得JNILocalRefType
,而非JNIGlobalRefType
。 @FastNative
和@CriticalNative
在 Android 7 中,系統會忽略這些最佳化註解。
@CriticalNative
不相符的 ABI 會導致引數管理錯誤,且可能當機。針對
@FastNative
和@CriticalNative
方法的原生函式動態查詢功能未在 Android 8 至 10 版中實作,且包含 Android 11 中的已知錯誤。如果在未明確註冊 JNIRegisterNatives
的情況下使用這些最佳化功能,可能會導致 Android 8 至 11 異常終止。
常見問題:為何會收到 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"
和適當的瀏覽權限 (JNIEXPORT
) 宣告 C++ 函式。請注意,在 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,而且日後版本的效能應有所提升。