JNI 提示

JNI 是 Java Native Interface,它定義了 Android 從受管理程式碼 (以 Java 或 Kotlin 程式設計語言編寫) 編譯的位元碼,與原生程式碼 (以 C/C++ 編寫) 互動的方式。JNI 與供應商無關,支援從動態共用程式庫載入程式碼,雖然有時很麻煩,但效率相當高。

注意:Android 會以類似 Java 程式設計語言的方式,將 Kotlin 編譯為 ART 友善的位元碼,因此就 JNI 架構及其相關費用而言,您可以將本頁的指南套用至 Kotlin 和 Java 程式設計語言。詳情請參閱「Kotlin 和 Android」。

如果您還不熟悉 JNI,請詳閱 Java Native Interface 規格,瞭解 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 只允許一個。

JNIEnv 提供大部分的 JNI 函式。除了 @CriticalNative 方法外,您的所有原生函式都會收到 JNIEnv 做為第一個引數,請參閱更快速的原生呼叫

JNIEnv 用於執行緒本機儲存空間。因此您無法在執行緒之間共用 JNIEnv。 如果程式碼無法透過其他方式取得 JNIEnv,您應共用 JavaVM,並使用 GetEnv 探索執行緒的 JNIEnv。(假設有,請參閱下方的AttachCurrentThread)。

JNIEnv 和 JavaVM 的 C 宣告與 C++ 宣告不同。"jni.h" 包含檔案會根據是否納入 C 或 C++,提供不同的 typedef。因此,不建議在兩種語言包含的標頭檔中納入 JNIEnv 引數。(換句話說,如果標頭檔案需要 #ifdef __cplusplus,且該標頭中的任何項目參照 JNIEnv,您可能需要執行一些額外工作)。

執行緒

所有執行緒都是由核心排程的 Linux 執行緒。這些通常是從受管理程式碼 (使用 Thread.start()) 啟動,但也可以在其他位置建立,然後附加至 JavaVM。舉例來說,使用 pthread_create()std::thread 啟動的執行緒可以使用 AttachCurrentThread()AttachCurrentThreadAsDaemon() 函式附加。執行緒附加前沒有 JNIEnv,因此無法發出 JNI 呼叫

通常最好使用 Thread.start() 建立任何需要呼叫 Java 程式碼的執行緒。這樣做可確保您有足夠的堆疊空間、位於正確的 ThreadGroup,以及使用與 Java 程式碼相同的 ClassLoader。此外,在 Java 中設定執行緒名稱以進行偵錯,也比從原生程式碼設定更簡單 (如果您有 pthread_setname_np(),請參閱 pthread_tthread_t;如果您有 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 儲存在執行緒本機儲存空間中;這樣一來,該鍵就會做為引數傳遞至解構函式中)。

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 是不透明型別,並非物件參照,不應傳遞至 NewGlobalRef。函式 (例如 GetStringUTFCharsGetByteArrayElements) 傳回的原始資料指標也不是物件。(這些物件可能會在執行緒之間傳遞,且在相符的 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),否則必須移除無效字元,或將其轉換為正確的 Modified UTF-8 格式。否則 UTF-16 轉換可能會產生非預期的結果。 CheckJNI (模擬器預設為開啟) 會掃描字串,並在收到無效輸入時中止 VM。

在 Android 8 之前,使用 UTF-16 字串通常較快,因為 Android 不需要在 GetStringChars 中複製,而 GetStringUTFChars 則需要分配空間並轉換為 UTF-8。Android 8 將 String 表示法改為每個 ASCII 字串字元使用 8 位元 (以節省記憶體),並開始使用移動式垃圾收集器。這些功能大幅減少 ART 可提供 String 資料指標的情況,即使是 GetStringCritical 也不例外,而且不需要複製資料。不過,如果程式碼處理的大多是短字串,則可以使用堆疊分配的緩衝區和 GetStringRegionGetStringUTFRegion,在大多數情況下避免分配和解除分配。例如:

    constexpr size_t kStackBufferSize = 64u;
    jchar stack_buffer[kStackBufferSize];
    std::unique_ptr<jchar[]> 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 呼叫為止 (這表示如果資料未複製,陣列物件會固定不動,且無法在堆積壓縮時重新定位)。您必須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>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 呼叫,而非兩次,可減少額外負荷。
  • 不需要釘選或額外複製資料。
  • 降低程式設計師出錯的風險,不會忘記在發生錯誤後呼叫 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 傳回非空值,您就不需要檢查例外狀況。不過,如果您呼叫方法 (使用 CallObjectMethod 等函式),則一律必須檢查例外狀況,因為如果擲回例外狀況,回傳值就不會有效。

請注意,受管理程式碼擲回的例外狀況不會解除原生堆疊框架。(一般不建議在 Android 上使用 C++ 例外狀況,且不得從 C++ 程式碼跨 JNI 轉換邊界擲向受管理程式碼)。JNI ThrowThrowNew 指令只會在目前執行緒中設定例外狀況指標。從原生程式碼返回受管理程式碼時,系統會記錄例外狀況並適當處理。

原生程式碼可以呼叫 ExceptionCheckExceptionOccurred「擷取」例外狀況,並使用 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 呼叫。
  • 直接 ByteBuffers:將錯誤的引數傳遞至 NewDirectByteBuffer
  • 例外狀況:在有待處理的例外狀況時發出 JNI 呼叫。
  • JNIEnv*:從錯誤的執行緒使用 JNIEnv*。
  • jfieldIDs:使用 NULL jfieldID,或使用 jfieldID 將欄位設為錯誤類型的值 (例如嘗試將 StringBuilder 指派給 String 欄位),或使用靜態欄位的 jfieldID 設定執行個體欄位或反之,或使用一個類別的 jfieldID 搭配另一個類別的執行個體。
  • jmethodID:發出 Call*Method JNI 呼叫時使用錯誤類型的 jmethodID:傳回型別不正確、靜態/非靜態不符、'this' 的型別錯誤 (適用於非靜態呼叫) 或類別錯誤 (適用於靜態呼叫)。
  • 參照:在錯誤的參照類型上使用 DeleteGlobalRef/DeleteLocalRef
  • 發布模式:將錯誤的發布模式傳遞至發布呼叫 (0JNI_ABORTJNI_COMMIT 以外的模式)。
  • 型別安全:從原生方法傳回不相容的型別 (例如從宣告會傳回 String 的方法傳回 StringBuilder)。
  • UTF-8:將無效的 Modified UTF-8 位元組序列傳遞至 JNI 呼叫。

(方法和欄位的存取權仍未經過檢查:存取限制不適用於原生程式碼)。

啟用 CheckJNI 的方法有很多種。

如果您使用的是模擬器,系統會預設啟用 CheckJNI。

如果裝置已完成 Root,可以使用下列一連串指令重新啟動執行階段,並啟用 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。原生實作項目必須從函式簽章中排除 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 起,只要所有引數都適合用於暫存器 (例如,在 arm64 上最多可有 8 個整數和 8 個浮點引數),從已編譯的管理方法呼叫 @CriticalNative 原生方法,幾乎與 C/C++ 中的非內嵌呼叫一樣便宜。

有時,最好將原生方法分成兩個,一個是可能失敗的快速方法,另一個則處理緩慢的情況。例如:

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 位元指標的架構,請使用 long 欄位,而非 int,將指標儲存至 Java 欄位中的原生結構體。

不支援的功能/回溯相容性

系統支援所有 JNI 1.6 功能,但有以下例外狀況:

  • DefineClass尚未實作。Android 不會使用 Java 位元組碼或類別檔案,因此傳遞二進位類別資料無效。

如要與舊版 Android 回溯相容,您可能需要注意下列事項:

  • 動態查閱原生函式

    在 Android 2.0 (Eclair) 之前,搜尋方法名稱時,「$」字元不會正確轉換為「_00024」。如要解決這個問題,必須使用明確註冊,或將原生方法移出內部類別。

  • Detaching threads

    在 Android 2.0 (Eclair) 之前,無法使用解構函式來避免「執行緒必須先分離才能結束」檢查。pthread_key_create(執行階段也會使用 pthread 鍵解構函式,因此會競爭看誰先被呼叫。)

  • 弱式全域參照

    在 Android 2.2 (Froyo) 之前,系統並未實作弱式全域參照。 舊版會嚴格拒絕使用這些功能。您可以使用 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 8.0 開始,Android 支援無限的本機參照。

  • 使用 GetObjectRefType 判斷參照類型

    在 Android 4.0 (Ice Cream Sandwich) 之前,由於使用直接指標 (如上所述),因此無法正確實作 GetObjectRefType。我們改用啟發式方法,依序查看弱全域資料表、引數、區域資料表和全域資料表。第一次找到直接指標時,系統會回報您的參照屬於正在檢查的類型。舉例來說,如果您在全域 jclass 上呼叫 GetObjectRefType,而該 jclass 剛好與傳遞至靜態原生方法的隱含引數相同,您會取得 JNILocalRefType,而不是 JNIGlobalRefType

  • @FastNative@CriticalNative

    在 Android 7 以前,系統會忽略這些最佳化註解。@CriticalNative 的 ABI 不符會導致引數封送處理錯誤,並可能導致當機。

    Android 8 至 10 未實作 @FastNative@CriticalNative 方法的原生函式動態查閱功能,Android 11 則包含已知錯誤。如果沒有透過 JNI RegisterNatives 明確註冊,使用這些最佳化功能可能會導致 Android 8 到 11 發生當機。

  • FindClass throws ClassNotFoundException

    為了提供回溯相容性,當 FindClass 找不到類別時,Android 會擲回 ClassNotFoundException,而不是 NoClassDefFoundError。這項行為與 Java 反射 API Class.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" 和適當的瀏覽權限 (JNIEXPORT) 宣告 C++ 函式。請注意,在 Ice Cream Sandwich 之前,JNIEXPORT 巨集有誤,因此使用新版 GCC 和舊版 jni.h 無效。你可以使用 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 會使用正確的類別載入器。
  • 將類別的例項傳遞至需要該例項的函式,方法是宣告原生方法來接受 Class 引數,然後傳遞 Foo.class
  • ClassLoader 物件的參照快取到方便存取的位置,然後直接發出 loadClass 呼叫。這需要付出一些努力。

常見問題:如何與原生程式碼共用原始資料?

您可能會遇到需要從受管理和原生程式碼存取大量原始資料緩衝區的情況。常見的例子包括點陣圖或音訊樣本的操控。基本方法有兩種。

您可以將資料儲存在 byte[] 中。這樣一來,受管理程式碼就能非常快速地存取。不過,在原生端,您不一定能存取資料,而無需複製資料。在某些實作中,GetByteArrayElementsGetPrimitiveArrayCritical會傳回受管理堆積中原始資料的實際指標,但在其他實作中,則會在原生堆積上分配緩衝區,並複製資料。

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

選擇使用哪一種方法取決於兩個因素:

  1. 大部分的資料存取作業是否會透過以 Java 或 C/C++ 編寫的程式碼進行?
  2. 如果資料最終會傳遞至系統 API,則必須採用哪種形式?(舉例來說,如果資料最終會傳遞至採用 byte[] 的函式,直接在 ByteBuffer 中進行處理可能不太明智)。

如果沒有明確勝出的版本,請使用直接位元組緩衝區。JNI 直接支援這些型別,且效能應會在日後版本中提升。