JNI 提示

JNI 是 Java Native Interface,它定義了 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 通訊,而不是在個別工作站執行緒之間進行通訊。
  • 將介面程式碼存放在容易辨識的 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" include 檔案會依據檔案是否包含 C 或 C++ 提供不同的類型定義。因此,在這兩種語言包含的標頭檔案中加入 JNIEnv 引數並不是明智的做法。(換個方式:如果標頭檔案需要 #ifdef __cplusplus,如果標頭中的任何內容參照 JNIEnv,就可能需要執行一些額外操作)。

Threads

所有執行緒都是由核心安排的 Linux 執行緒。這些物件通常是從代管程式碼開始執行 (使用 Thread.start()),但也可以在其他位置建立,然後附加至 JavaVM。舉例來說,您可以使用 AttachCurrentThread()AttachCurrentThreadAsDaemon() 函式附加以 pthread_create()std::thread 開頭的執行緒。在附加執行緒之前,沒有 JNIEnv 和「無法進行 JNI 呼叫」

一般而言,建議使用 Thread.start() 建立需要呼叫 Java 程式碼的任何執行緒。這樣做可確保您有足夠的堆疊空間、位於正確的 ThreadGroup,而且使用的是相同的 ClassLoader 做為 Java 程式碼。相較於原生程式碼,在 Java 中設定執行緒名稱進行偵錯也較為簡單 (如果您有 pthread_tthread_t,如果有 std::thread 且想要 pthread_t,請參閱 pthread_setname_np()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 儲存在 thread-local-storage 中,這樣它就會以引數形式傳遞至解構函式)。

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 的所有子類別,包括 jclassjstringjarray。(啟用擴充 JNI 檢查時,執行階段會警告大部分參照錯誤使用)。

取得非本機參照的唯一方法是透過 NewGlobalRefNewWeakGlobalRef 函式。

如果您要保留某個參照的時間較長,就必須使用「全域」參照。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 保留更多。

請注意,jfieldIDjmethodID 是不透明類型,而不是物件參照,因此不應傳遞至 NewGlobalRefGetStringUTFCharsGetByteArrayElements 等函式傳回的原始資料點也不是物件。(這些物件可以在執行緒之間傳遞,且在相符的 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 指標。

別忘了 ReleaseGet 的字串。字串函式會傳回 jchar*jbyte*,這些是原始資料的 C 樣式指標,而非本機參照。在呼叫 Release 之前,這些 API 保證有效。也就是說,當原生方法傳回時,不會釋放這些物件。

傳送至 NewStringUTF 的資料必須使用修改後的 UTF-8 格式。常見的錯誤之一是從檔案或網路串流讀取字元資料,並在未篩選的情況下將其交給 NewStringUTF。除非您知道資料是有效的 MUTF-8 (或 7 位元 ASCII,為相容子集),否則就必須移除無效字元或將其轉換為適當的 UTF-8 格式。 否則 UTF-16 轉換可能會產生意外的結果。 CheckJNI (模擬器預設啟用) 會掃描字串,並在 VM 收到無效的輸入內容時取消 VM。

在 Android 8 之前,由於 Android 不需要在 GetStringChars 中複製資料,因此使用 UTF-16 字串來加快運作速度,而 GetStringUTFChars 需要配置和轉換為 UTF-8 格式。Android 8 將 String 表示法改為每個字元 8 位元,用於 ASCII 字串 (以節省記憶體),並開始使用移動垃圾收集器。這類功能可大幅減少 ART 無須複製即可提供 String 資料指標的情況,即使是 GetStringCritical 也不例外。不過,如果程式碼處理的大部分字串都很短,在大多數情況下,您可以使用堆疊分配的緩衝區和 GetStringRegionGetStringUTFRegion 來避免配置和取消配置。例如:

    constexpr size_t kStackBufferSize = 64u;
    jchar stack_buffer[kStackBufferSize];
    std::unique_ptr heap_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 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>ArrayElementsGetStringChars) 的替代方法,如果只是要複製或輸出資料,可能就非常實用。請把握以下幾項重點:

    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 呼叫將資料複製到陣列,並使用 GetStringRegionGetStringUTFRegionString 複製字元。

例外狀況

在例外狀況處理期間,您不得呼叫大多數 JNI 函式。您的程式碼應該會出現例外狀況 (透過函式的傳回值 ExceptionCheckExceptionOccurred),並傳回,或是清除例外狀況並處理。

例外狀況處理時,唯一可呼叫的 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 ThrowThrowNew 指令只需在目前的執行緒中設定例外狀況指標即可。從原生程式碼返回代管後,系統會記錄並妥善處理例外狀況。

原生程式碼可以呼叫 ExceptionCheckExceptionOccurred 來「擷取」例外狀況,然後使用 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 呼叫。
  • 重要呼叫:在「關鍵」取得與相應版本之間發出 JNI 呼叫。
  • 直接 ByteBuffers:將錯誤引數傳遞至 NewDirectByteBuffer
  • 例外狀況:在遇到待處理的例外狀況時進行 JNI 呼叫。
  • JNIEnv*s:從錯誤的執行緒使用 JNIEnv*。
  • jfieldID:使用 NULL jfieldID,或使用 jfieldID 將欄位設為錯誤類型的值 (例如嘗試將 StringBuilder 指派給 String 欄位,或者使用 jfieldID 為靜態欄位設定執行個體欄位,或是以 jfieldID 設定實例欄位,或是使用類別中的 jfieldID,以設定另一個類別的執行個體。
  • jmethodIDs:發出 Call*Method JNI 呼叫時使用錯誤的 jmethodID:傳回類型不正確、靜態/非靜態不相符、「this」類型錯誤 (針對非靜態呼叫) 或錯誤的類別 (針對靜態呼叫)。
  • 參照:對錯誤的參考種類使用 DeleteGlobalRef/DeleteLocalRef
  • 發布模式:將錯誤的發布模式傳遞至發布呼叫 (0JNI_ABORTJNI_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)。引數是「未裝飾」的程式庫名稱,因此如要載入您會傳入 "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;
}

如要改用原生方法的「discovery」,您必須以特定方式命名 (詳情請參閱 JNI 規格)。這表示如果方法簽章錯誤,您必須等到第一種方法實際叫用時,才能知道這個簽章。

透過 JNI_OnLoad 發出的任何 FindClass 呼叫,都會在用於載入共用程式庫的類別載入器結構定義中解析類別。在其他結構定義中呼叫時,FindClass 會使用與 Java 堆疊頂端與方法相關聯的類別載入器;如果沒有 (因為呼叫來自剛剛附加的原生執行緒),則會使用「系統」類別載入器。系統類別載入器不知道應用程式的類別,因此您無法在該情況下使用 FindClass 查詢自己的類別。這可方便您查詢及快取類別:取得有效的 jclass 全域參考資料後,就可以在任何附加的執行緒中使用 JNI_OnLoad

使用 @FastNative@CriticalNative 加快原生呼叫速度

原生方法可使用 @FastNative@CriticalNative (但兩者無法) 註解,以加快受管理程式碼和原生程式碼之間的轉換速度。不過,這些註解具有某些行為變更,在使用前必須審慎考慮。以下簡要說明這些異動內容,詳情請參閱相關說明文件。

@CriticalNative 註解只能套用至不使用代管物件的原生方法 (在參數、回傳值,或以隱含 this 的形式),而且這個註解會變更 JNI 轉換 ABI。原生實作必須從函式簽章中排除 JNIEnvjclass 參數。

執行 @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 起,透過已編譯的代管方法呼叫 @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) 之前,尚未實作弱的全域參照。舊版會嚴重拒絕使用這些 API 的嘗試。您可以使用 Android 平台版本常數來測試是否支援。

    在 Android 4.0 (Ice Cream Sandwich) 之前,弱的全域參照只能傳遞至 NewLocalRefNewGlobalRefDeleteWeakGlobalRef。(這項規格極力建議程式設計師先建立硬的全球弱點參照,再與他們一起行動,因此不應有任何限制)。

    從 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 中的已知錯誤。在沒有明確註冊 JNI RegisterNatives 的情況下使用這些最佳化功能,可能會在 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為什麼找不到我的課程?

(大部分的建議做法同樣適用於找出含有 GetMethodIDGetStaticMethodID 的方法,或是含有 GetFieldIDGetStaticFieldID 的欄位。)

請確認類別名稱字串的格式正確無誤。JNI 類別名稱是以套件名稱開頭,並以斜線分隔,例如 java/lang/String。如要查詢陣列類別,開頭必須是適當的方括號數量,且也必須以「L」和「;」納入類別,因此 String 的一維陣列會是 [Ljava/lang/String;。如要查詢內部類別,請使用「$」而非「.」。一般而言,在 .class 檔案上使用 javap 即可找出類別的內部名稱。

啟用程式碼縮減功能時,請務必設定要保留的程式碼。設定適當的保留規則非常重要,因為程式碼縮減器可能會另外移除僅從 JNI 使用的類別、方法或欄位。

如果類別名稱正確無誤,您可能遇到類別載入器問題。FindClass 想在與程式碼相關聯的類別載入器中啟動類別搜尋。這項工具會檢查呼叫堆疊,如下所示:

    Foo.myfunc(Native Method)
    Foo.main(Foo.java:10)

最頂層的方法為 Foo.myfuncFindClass 會尋找與 Foo 類別相關聯的 ClassLoader 物件,並使用該物件。

這通常是您想要的任務。假如您自行建立執行緒 (也許是呼叫 pthread_create,然後加上 AttachCurrentThread 來附加),可能會發生問題。現在應用程式中沒有任何堆疊框架。如果您從這個執行緒呼叫 FindClass,JavaVM 會在「系統」類別載入器中啟動,而不是與您的應用程式相關聯的類別,因此嘗試尋找應用程式特定類別將會失敗。

解決方法如下:

  • 請在 JNI_OnLoad 中執行 FindClass 查詢一次,並快取類別參照供日後使用。在執行 JNI_OnLoad 過程中發出的任何 FindClass 呼叫,都會使用與呼叫 System.loadLibrary 函式相關聯的類別載入器 (這是為了讓程式庫初始化更方便)。如果應用程式程式碼正在載入程式庫,FindClass 會使用正確的類別載入器。
  • 透過宣告原生方法接受類別引數,然後傳入 Foo.class,將類別的執行個體傳遞至需要該類別的函式。
  • 視需要快取 ClassLoader 物件的參照,並直接發出 loadClass 呼叫。這需要花費一些心力。

常見問題:如何使用原生程式碼分享原始資料?

您可能會發現自己必須從代管和原生程式碼存取大量的原始資料。常見的例子包括操控點陣圖或音效樣本。基本做法有兩種

您可以將資料儲存在 byte[] 中。這可讓您透過代管程式碼快速存取內容。但在原生方面,您並不保證可以先複製資料再存取。在部分實作中,GetByteArrayElementsGetPrimitiveArrayCritical 會將實際指標傳回至代管堆積中的原始資料,但在其他情況下,則會在原生堆積上分配緩衝區,然後複製資料。

另一種方法是將資料儲存在直接位元組緩衝區。您可以使用 java.nio.ByteBuffer.allocateDirect 或 JNI NewDirectByteBuffer 函式建立這些項目。儲存空間與一般位元組緩衝區不同,系統不會在代管堆積上分配儲存空間,且一律可透過原生程式碼存取 (使用 GetDirectBufferAddress 取得位址)。視直接位元組緩衝區存取實作的方式而定,從代管程式碼存取資料的速度可能非常緩慢。

要使用哪一種方式取決於兩個因素:

  1. 大部分資料存取作業都是透過以 Java 或 C/C++ 編寫的程式碼執行嗎?
  2. 如果資料最終會傳送至系統 API,資料需採用哪種形式?(例如,如果資料最終會傳遞到接受位元組 [] 的函式,在直接 ByteBuffer 中處理則可能並不適用)。

如果沒有明顯勝出者,請使用直接位元組緩衝區。JNI 直接內建這些支援功能,因此在日後推出的版本中,效能應該會有所改善。