「音影片直播技術」JNI編程常見問題

前言

本文是JNI編程注意事項的第二篇文章。在上篇中講解了 JavaVM/JNIEnv, Threads, jclass/jfieldID/jmethodID 以及 Local/Global 引用。今天我們繼續講解餘下的部分。

Native 庫

我們可以使用System.loadLibrary將共享庫導入進來。引入Native程式碼的最好方法如下:

  • 靜態類初始化時,調用System.loadLibrary。參數是未聲明的庫名子,如要載入「libfubar.so」,你應傳入「fubar」
  • 提供一個本地函數 jint JNI_OnLoad(JavaVM* vm, void* reserved)。
  • 在JNI_OnLoad函數里,註冊所有Native方法。你應該用"static"聲明方法 ,這樣名子在設備的符號表裡不佔空間。

如果用C++編寫,JNI_OnLoad函數應該看起來像下面的樣子:

jint JNI_OnLoad(JavaVM* vm, void* reserved)  {      JNIEnv* env;      if (vm->GetEnv(reinterpret_cast<void**>(&env), JNI_VERSION_1_6) != JNI_OK) {          return -1;      }        // Get jclass with env->FindClass.      // Register methods with env->RegisterNatives.        return JNI_VERSION_1_6;  }

你也可以用共享庫的完整路徑名調用System.load函數而不是System.loadLibrary。對於Andrioid應用來說, 您可能會發現從上下文對象獲取應用程式的私有數據存儲區域的完整路徑非常方便。

上面的方法是推薦方法,但不是唯一的方法。其實,可以不需要顯式註冊JNI方法,也不需要提供JNI_OnLoad函數。您可以使用以特定方式命名的Native方法。但這種方式很不好,因為如果方法簽名是錯的,直到第一次它被使用時你才知道它出錯了。

另一個關於JNI_OnLoad需要注意的事項:任何FindClass操作,都應該在載入共享庫的類載入器上下文中調用。通常,FindClass使用與解釋棧頂端方法相關聯的載入器,如果沒有(因為執行緒剛剛綁定),它將使用「系統」類載入器。這使JNI_OnLoad成為查找和快取類對象引用的最好地方。

UTF-8 和 UTF-16 符字串

Java程式語言使用UTF-16編碼。為了方便,JNI提供了與UTF-8一起使用的方法。但這種UTF-8是修改過的UTF-8編碼方式。這種方式對於C程式碼是有用的,因為它將u0000編碼為0xc0 0x80而不是0x00。好處是,您可以依靠擁有C風格的零終止字元串。壞處是,您不能將任意的UTF-8數據傳遞給JNI,並希望它能正常工作。

如果可能,通常使用UTF-16字元串操作更快。在Android當前版本中,使用GetStringChars函數不需要拷貝其內容(它的內容是UTF-8編碼),但使用GetStringUTFChars則需要分配和轉換為UTF-8。請注意,UTF-16字元串不是以零終止的,u0000被認為是正常數據,所以你需要自己保存字元串長度以及jchar指針。

不要忘記釋放你獲得的字元串。字元串函數返回jchar *或jbyte *,它們是C樣式的指向原始數據的指針,而不是本地引用。它們被保證有效,直到調用Release,這意味著當native方法返回時它們不會自動釋放。

傳遞給NewStringUTF的數據必須使用修改過的UTF-8格式。常見的錯誤是從文件或網路流讀取字元數據,並將其傳遞給NewStringUTF,而不對其進行過濾。除非你知道數據是7位ASCII,否則你需要去掉高ASCII字元或將它們轉換成適當的UTF-8格式。

如果不這樣做,UTF-16轉換可能不會是您期望結果的。擴展的JNI檢查將掃描字元串並警告您它是無效數據,但它們不會捕獲所有內容。

原始數組

JNI提供了訪問數組對象內容的功能,雖然對象數組必須一次訪問一個條目,但是可以直接讀取和寫入原始數組,就像它們在C中被聲明一樣。

使介面儘可能高效,除非受到VM實現的限制,Get<PrimitiveType>ArrayElements系列調用允許運行時返回指向實際元素的指針,或分配一些記憶體並複製他們。無論哪種方式,返回的原始指針都將保證是有效的,直到發出相應的Release調用(這意味著,如果數據未被複制,數組中的對象是固定的,並且不能被重新定位)。你必須釋放你獲得的每個數組,此外,如果Get調用失敗,您必須確保程式碼不會釋放這個空指針。

您可以通過傳遞isCopy參數是否是NULL來確定數據是否被複制了。但這種方式基本沒什麼用。

Release函數的mode參數有三種值。運行時的行為依賴於返回的是實際數據的指針還是其副本:

  • 0
    • 實際:數組對象是非固定的。
    • 複製:數據被複制回來。具有副本的緩衝區被釋放。
  • JNI_COMMIT
    • 實際:什麼都不做。
    • 複製:數據被複制回來。具有副本的緩衝區被釋放。
  • JNI_ABORT
    • 實際:數組對象是非固定的。早期寫入的數據不會被中止。
    • 複製:具有副本的緩衝區被釋放;對它的任何更改都會丟失。

檢查isCopy標誌的原因之一,是在更改數組後知道是否需要使用JNI_COMMIT參數調用Release。如果在更改數組和執行程式碼之間進行交替,你可以什麼都不做。檢查標誌的第二個原因,是有效地處理JNI_ABORT。例如,您可能需要得到一個數組,修改它,並將其傳遞給其他函數,然後丟棄更改。如果您知道JNI正在為您製作新的副本,則無需創建另一個「可編輯的」副本。如果JNI傳給你的是原始的數據,那麼你需要自己做拷貝。

常見的錯誤,是認為如果 *isCopy為false,則可以跳過Release調用。如果沒有分配複製緩衝區,則原始記憶體必須被固定,並且不能被垃圾收集器移動。另請注意,JNI_COMMIT標誌不會釋放數組,您需要再次使用不同的標誌調用Release。

Region Calls

拷貝數據時有一種替代方法,例如,使用Ge<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,減少開銷。
  • 不需要固定或額外的數據拷貝。
  • 減少程式設計師錯誤的風險 – 沒有任何失敗後忘記調用釋放的風險。

類似地,您可以使用Set<Type>ArrayRegion調用將數據複製到數組中,並使用GetStringRegion或GetStringUTFRegion從字元串中複製字元。

異常

當異常待處理時,不能調用大多數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 Throw和ThrowNew指令在當前執行緒中設置了一個異常指針。返回到本地程式碼管理後,異常將被注意到和處理。

本地程式碼可以通過調用ExceptionCheck或ExceptionOccurred「捕獲」異常,並用ExceptionClear清除它。像往常一樣,拋棄異常而不處理它們可能會導致問題。

沒有用於操作Throwable對象的內置函數,所以如果你想得到異常字元串,你需要找到Throwable類,查找getMessage的方法ID "()java/lang/String;",並且如果結果是非空的,則使用GetStringUTFChars獲取可以傳遞給printf(3)或等同物的資訊。

擴展檢查

JNI幾乎沒有錯誤檢查,錯誤通常會導致崩潰。Android提供了一種稱為CheckJNI的模式,在調用標準實現之前,將JavaVM和JNIEnv函數表指針切換到執行擴展系列檢查的函數表。

擴展檢查包括:

  • 數組:嘗試分配負大小的數組。
  • 錯誤的指針:將一個壞的jarray/jclass/jobject/jstring傳遞給JNI調用,或者傳遞一個NULL指針到一個不可空參數的JNI調用。
  • 類名稱:傳遞類似 「java/lang/String」 樣式的類名傳給JNI調用。
  • Critical調用:在「Critical」獲取和釋放之間進行JNI調用。
  • Direct ByteBuffers:將錯誤的參數傳遞給NewDirectByteBuffer。
  • Exceptions:在異常掛起時進行JNI調用。
  • JNIEnv* :在錯誤的執行緒中使用 JNIEnv* 。
  • fieldIDs :使用空的jfieldID,或使用jfieldID將欄位設置為錯誤類型的值(嘗試將StringBuilder分配給String欄位),或給靜態 jfieldID設置實例的欄位或者相反,或者使用一個類的實例但卻用的另一個類的欄位。
  • jmethodIDs:在進行調用時,使用錯誤的jmethodID方法做JNI調用:不正確的返回類型,靜態/非靜態不匹配,錯誤類型為'this'(非靜態調用)或錯誤類(用於靜態調用)。
  • References:使用DeleteGlobalRef/DeleteLocalRef時,用了錯誤的引用。
  • 釋放模式:將錯誤的mode值傳遞給Release(除0,JNI_ABORT或JNI_COMMIT之外)。
  • 類型安全:從本機方法返回不兼容的類型(例如:從聲明返​​回String的方法返回StringBuilder)。
  • UTF-8:將無效的修改後的UTF-8位元組序列傳遞給JNI調用。

(方法和欄位的輔助功能仍未被檢查:訪問限制不適用於Native程式碼。)

有幾種啟用CheckJNI的方法: 如是你使用的是模擬器,CheckJNI默認是打開的。 如果擁有root許可權的設備,你可以使用下面的一系列命令重啟 Runtime 並開啟 CheckJNI:

adb shell stop  adb shell setprop dalvik.vm.checkjni true  adb shell start

在這些情況下,當 Runtime 啟動時,在 logcat 輸出中可以看到如下資訊:

D AndroidRuntime: CheckJNI is ON

如果你是一台普通設備,你可以使用下面的命令

adb shell setprop debug.checkjni 1

這不會影響已經運行的應用程式,但從該點啟動的任何應用程式將啟用CheckJNI。(將屬性更改為任何其他值或重新啟動將會再次禁用CheckJNI。)在這種情況下,你能在下次應用程式啟動時在logcat輸出中看到下面的資訊:

D Late-enabling CheckJNI

您還可以在應用程式的manifest中設置android:debuggable屬性,以便為您的應用程式啟用CheckJNI。請注意,Android構建工具會自動為某些構建類型執行此操作。

常見問題

FAQ: 為什麼會出現 UnsatisfiedLinkError?

在處理Native程式碼時,看到這樣的失敗並不罕見:

java.lang.UnsatisfiedLinkError: Library foo not found

在某些情況下這意味著,庫沒有發現。其它情況是說庫存在,但不能由 dlopen 打開。失敗的具體資訊在異常的資訊中可以找到。

您可能遇到「庫未找到」異常的常見原因:

  • 庫不存在或應用程式無法訪問。使用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宏不正確,因此使用新的GCC與舊的jni.h將無法正常工作。您可以使用arm-eabi-nm查看在庫中出現的符號;如果它們看起來很像(_Z15Java_Foo_myfuncP7_JNIEnvP7_jclass而不是Java_Foo_myfunc),或者如果符號類型是小寫't'而不是大寫字母'T',則需要調整聲明。
    • 對於顯式註冊,輸入方法簽名時會出現較小的錯誤。確保您傳遞到registration 調用的內容與日誌文件中的簽名相匹配。記住'B'是位元組,'Z'是布爾值。簽名中的類名稱組件以'L'開始,以';'結尾,使用'/'分隔包/類名稱,並使用'$'分隔內部類名稱(Ljava / util / Map $ Entry; say )。

使用javah自動生成JNI頭可能有助於避免一些問題。

FAQ: 為什麼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將使用正確的類載入器。
  • 將類的實例傳遞到需要它的函數中,通過聲明本地方法來接受Class參數,然後傳遞Foo.class。
  • 快取對ClassLoader對象的引用,方便起見,並直接發出loadClass調用。這相對麻煩一些。

FAQ: 在Native程式碼間如何共享原始數據?

您可能會發現自己需要在從託管和本地程式碼之間訪問大量原始數據緩衝區的情況。通常的例子包括操作點陣圖或聲音樣本。有兩種基本方法: 您可以將數據存儲在byte[]中。這樣從託管程式碼訪問非常快。但是,在本地方面您無法保證不複製數據就可訪問數據。在某些實現中,GetByteArrayElements和GetPrimitiveArrayCritical將返回實際指向託管堆中原始數據的指針,但另一方面,它將在本機堆上分配一個緩衝區並複製數據。

另一種方法是將數據存儲在直接位元組緩衝區中。這些可以使用java.nio.ByteBuffer.allocateDirect或JNI NewDirectByteBuffer函數創建。與常規位元組緩衝區不同,存儲不會在託管堆上分配,並且可以直接從本地程式碼訪問(使用GetDirectBufferAddress獲取地址)。根據實現直接位元組緩衝訪問的方式,從託管程式碼訪問數據可能非常慢。

選擇哪個使用取決於兩個因素:

  1. 大多數數據訪問是由Java或C / C ++編寫的程式碼發生的?
  2. 如果數據最終被傳遞給系統API,那麼它應該是什麼形式的?(例如,如果數據最終被傳遞給byte[]的函數,那麼在直接ByteBuffer中進行處理可能是不明智的。)

如果基於上面的兩點仍然判斷不出來,請使用直接位元組緩衝區。JNI直接構建對它們的支援,並且在將來的版本中性能會得到改善。

小結

本文首先介紹了JNI載入動態庫的常用規則,然後講了使用UTF-8需要注意的事項。僅接著介紹了訪問原始數組,區塊調用,異常等要注意的點,最後對編寫JNI程式常見的問題給出了問題的原因和解決辦法。

希望本篇文章對您有所幫助,並繼續關注我,謝謝!