Android Studio開發JNI示例
JNI和NDK介紹
JNI(Java Native Interface),是方便Java呼叫C、C++等Native程式碼所封裝的一層介面,相當於一座橋樑。通過JNI可以操作一些Java無法完成的與系統相關的特性,尤其在影象和視訊處理中大量用到。
NDK(Native Development Kit)是Google提供的一套工具,其中一個特性是提供了交叉編譯,即C或者C++不是跨平臺的,但通過NDK配置生成的動態庫卻可以相容各個平臺。比如C在Windows平臺編譯後生成.exe檔案,那麼原始碼通過NDK編譯後可以生成在安卓手機上執行的二進位制檔案.so
在AS中使用ndk-build開發JNI示例
Android Studio2.2之前對於JNI開發的支援不是很好,開發一般使用Eclipse+外掛編寫本地動態庫。後面Google官方全面增強了對JNI的支援,包括內建NDK。
1.在AS中新建一個專案
2.宣告一個native方法
package com.mercury.jnidemo;
public class JNITest {
public native static String getStrFromJNI();
}
3.通過javah命令生成標頭檔案
在AS的Terminal中,先進入要呼叫原生代碼的類所在的目錄,也就是在專案中的具體路徑,比如這裡是cd app\src\main\java
javah -jni com.mercury.jnidemo.JNITest
,生成標頭檔案com_mercury_jnidemo_JNITest.h
實際專案最終可以不包含此標頭檔案,不熟悉C的語法的開發人員,藉助於該標頭檔案可以知道JNI的相關語法:
/* DO NOT EDIT THIS FILE - it is machine generated */
#include <jni.h>
/* Header for class com_mercury_jnidemo_JNITest */
#ifndef _Included_com_mercury_jnidemo_JNITest
#define _Included_com_mercury_jnidemo_JNITest
#ifdef __cplusplus
extern "C" {
#endif
/*
* Class: com_mercury_jnidemo_JNITest
* Method: getStrFromJNI
* Signature: ()Ljava/lang/String;
*/
JNIEXPORT jstring JNICALL Java_com_mercury_jnidemo_JNITest_getStrFromJNI
(JNIEnv *, jclass);
#ifdef __cplusplus
}
#endif
#endif
首先引入jni.h,裡面包含了很多巨集定義及呼叫本地方法的結構體。重點是方法名的格式。這裡的JNIEXPORT和JNICALL都是jni.h中所定義的巨集。JNIEnv *表示一個指向JNI環境的指標,可通過它來訪問JNI提供的介面方法。jclass也是jni.h中定義好的,型別是jobject,實際上是一個不確定型別的指標,這裡用來接收Java中的this。實際編寫中一般只要遵循Java_包名_類名_方法名
就好了。
4.實現JNI方法
像上面的標頭檔案只是定義了方法,並沒有實現,就像一個介面一樣。這裡就用C寫一個簡單的無參的JNI方法。
先建立一個jni目錄,我直接在src的父目錄下建立的,也可以在其他目錄建立,因為最終只需要編譯好的動態庫。在jni目錄下建立Android.mk和demo.c檔案。
Android.mk是一個makefile配置檔案,安卓大量採用makefile進行自動化編譯。LOCAL_MODULE定義的名稱就是編譯好的so庫名稱,比如這裡是jni-demo
,最終生成的動態庫名稱就叫libjni-demo.so。 LOCAL_SRC_FILES表示參與編譯的原始檔名稱,這裡就是demo.c
LOCAL_PATH := $(call my-dir)
include $(CLEAR_VARS)
LOCAL_MODULE := jni-demo
LOCAL_SRC_FILES := demo.c
include $(BUILD_SHARED_LIBRARY)
這裡的demo.c實現了一個很簡單的方法,返回String型別。
#include<jni.h>
jstring Java_com_mercury_jnidemo_JNITest_getStrFromJNI(JNIEnv *env,jobject thiz){
return (*env)->NewStringUTF(env,"I am Str from jni libs!");
}
這時候NDK編譯生成的動態庫會有四個CPU平臺:arm64-v8a、armeabi-v7a、x86、x86_64。如果建立Application.mk就可以指定要生成的CPU平臺,語法也很簡單:
APP_ABI := all
這樣就會生成各個CPU平臺下的動態庫。
5.使用ndk-build程式設計生成.so庫
切回到jni目錄的父目錄下,在Terminal中執行ndk-build指令,就可以在和jni目錄同級生成一個libs資料夾,裡面存放相對應的平臺的.so庫。同時生成的還有一箇中間臨時的obj資料夾,和jni資料夾可以一起刪除。
需要注意,使用NDK一定要先在build.gradle下要配置ndk-build的相關路徑,這樣在編寫原生代碼時才會有相關的提示功能,並且可以關聯到相關的標頭檔案:
externalNativeBuild {
ndkBuild {
path 'jni/Android.mk'
}
}
還有一點,網上很多資料都在build.gradle中加入以下程式碼:
sourceSets{
main{
jniLibs.srcDirs=['libs']
}
}
這樣就指定了目標.so庫的存放位置。但在實際使用中,就算不指定,執行時仍然可以載入正確的.so庫檔案,並且如果新增該程式碼後有時會報出以下錯誤:
Error:Execution failed for task ':usejava:transformNativeLibsWithMergeJniLibsForDebug'.
> More than one file was found with OS independent path 'lib/x86/libjni-calljava.so'
>
6.載入.so庫並呼叫方法
在類初始化的時候要載入該.so庫,一般會寫在靜態程式碼塊裡。名稱就是前面的LOCAL_MODULE。
static {
System.loadLibrary("jni-demo");
}
需要注意的是如果是有參的JNI方法,那麼直接在引數列表裡補充在jni.h預先typedef好的資料型別就可以了。
JNI呼叫Java
不同於JNI呼叫C,JNI呼叫Java的過程不是單獨存在的。而是編寫native方法,Java先通過JNI呼叫該方法,在方法內部再去回撥類中對應的Java方法。步驟有些類似於Java中的反射。這裡寫定義三個點選事件,三個Native方法,三種Java的方法型別,根據相關的Log判斷是否成功。
public class MainActivity extends AppCompatActivity {
public static final String TAG = "MainActivity";
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
}
static {
System.loadLibrary("jni-calljava");
}
public void noParamMethod() {
Log.i(TAG, "無參的Java方法被呼叫了");
}
public void paramMethod(int number) {
Log.i(TAG, "有參的Java方法被呼叫了" + number + "次");
}
public static void staticMethod() {
Log.i(TAG, "靜態的Java方法被呼叫了");
}
public void click1(View view) {
test1();
}
public void click2(View view) {
test2();
}
public void click3(View view) {
test3();
}
public native void test1();
public native void test2();
public native void test3();
}
1.呼叫Java無參方法
- JNI呼叫本地方法,根據類名找到類,注意類名用"/"分隔。
- 找到類後,根據方法名找到方法。該函式GetMethodID最後一個形參是該形參列表的簽名。不同於Java,C中是通過簽名標識去找方法。
- 獲取方法的簽名:首先定位到該類的位元組碼檔案所在的父目錄,一般在
module\build\intermediates\classes\debug>
,通過javap -s com.mercury.usejava.MainActivity
獲取整個類所有的內部型別簽名。無參方法test1()的簽名是()V
。 - 通過JNIEnv物件的CallVoidMethod來完成方法的回撥,最後一個形參是可變引數。
JNIEXPORT void JNICALL Java_com_mercury_usejava_MainActivity_test1
(JNIEnv * env, jobject obj){
//回撥MainActivity中的noParamMethod
jclass clazz = (*env)->FindClass(env, "com/mercury/usejava/MainActivity");
if (clazz == NULL) {
printf("find class Error");
return;
}
jmethodID id = (*env)->GetMethodID(env, clazz, "noParamMethod", "()V");
if (id == NULL) {
printf("find method Error");
}
(*env)->CallVoidMethod(env, obj, id);
}
2.呼叫Java有參方法
類似於無參方法,只是引數簽名和可變引數的不同
3.呼叫Java靜態方法
注意獲取方法名的方法是GetStaticMethodID
,呼叫方法的函式名是CallStaticVoidMethod
,並且由於是靜態方法,不應該傳入jobject引數,而直接是jclass.
JNIEXPORT void JNICALL Java_com_mercury_usejava_MainActivity_test3
(JNIEnv * env, jobject obj){
jclass clazz = (*env)->FindClass(env, "com/mercury/usejava/MainActivity");
if (clazz == NULL) {
printf("find class Error");
return;
}
jmethodID id = (*env)->GetStaticMethodID(env, clazz, "staticMethod", "()V");
if (id == NULL) {
printf("find method Error");
}
(*env)->CallStaticVoidMethod(env, clazz, id);
}
相應日誌
使用CMake開發JNI
CMake是一個跨平臺的安裝(編譯)工具,通過編寫CMakeLists.txt,可以生成對應的makefile或project檔案,再呼叫底層的編譯。AS 2.2之後工具中增加了對CMake的支援,官方也推薦用CMake+CMakeLists.txt的方式,代替ndk-build+Android.mk+Application.mk的方式去構建JNI專案.
1.建立使用CMake構建的專案
開始前AS要先在SDK Manager中安裝SDK Tools->CMake
只要勾選Include C++ Support
。其中會提示配置C++支援的功能.
一般預設就可以了,各個選項的具體含義:
- C++ Standard:指定編譯庫的環境。
- Exception Support:當前專案支援C++異常處理
- Runtime Type Information Support:除異常處理外,還支援動態轉型別(dynamic casting) 、模組整合、以及物件I/O
2.工程的目錄結構
建立好的工程主Module下直接就有.externalNativeBuild,多出一個CMakeLists.txt,相當於以前的配置檔案。並且在src/main目錄下多了一個cpp資料夾,裡面存放的是C++檔案,相當於以前的jni資料夾。這個是工程建立後AS生成的示例JNI方法,返回了一個字串。後面開發JNI就可以按照這個目錄結構。
相應的,build.gradle下也增加了一些配置。
android {
...
defaultConfig {
...
externalNativeBuild {
cmake {
cppFlags "-std=c++14 -frtti -fexceptions"
}
}
}
buildTypes {
...
}
externalNativeBuild {
cmake {
path "CMakeLists.txt"
}
}
}
defaultConfig中的externalNativeBuild各項屬性和前面建立專案時的選項配置有關,外部的externalNativeBuild則定義了CMakeLists.txt的存放路徑。
如果只是在自己的專案中使用,CMake的方式在打包APK的時候會自動將cpp檔案編譯成so檔案拷貝進去。如果要提供給外部使用時,Make Project
,之後在libs
目錄下就可以看到生成的對應配置的相關CPU平臺的.so檔案。
CMakeLists.txt
CMakeLists.txt可以自定義命令、查詢檔案、標頭檔案包含、設定變數,具體可見 官方文件。專案預設生成的CMakeLists.txt核心內容如下:
# 編譯本地庫時我們需要的最小的cmake版本
cmake_minimum_required(VERSION 3.4.1)
# 相當於Android.mk
add_library( # Sets the name of the library.設定編譯生成本地庫的名字
native-lib
# Sets the library as a shared library.庫的型別
SHARED
# Provides a relative path to your source file(s).編譯檔案的路徑
src/main/cpp/native-lib.cpp )
# 新增一些我們在編譯我們的本地庫的時候需要依賴的一些庫,這裡是用來打log的庫
find_library( # Sets the name of the path variable.
log-lib
# Specifies the name of the NDK library that
# you want CMake to locate.
log )
# 關聯自己生成的庫和一些第三方庫或者系統庫
target_link_libraries( # Specifies the target library.
native-lib
# Links the target library to the log library
# included in the NDK.
${log-lib} )
使用CMakeLists.txt同樣可以指定so庫的輸出路徑,但一定要在add_library之前設定,否則不會生效:
set(CMAKE_LIBRARY_OUTPUT_DIRECTORY
${PROJECT_SOURCE_DIR}/libs/${ANDROID_ABI}) #指定路徑
#生成的so庫在和CMakeLists.txt同級目錄下的libs資料夾下
如果想要配置so庫的目標CPU平臺,可以在build.gradle中設定
android {
...
defaultConfig {
...
ndk{
abiFilters "x86","armeabi","armeabi-v7a"
}
}
...
}
需要注意的是,如果是多次使用add_library,則會生成多個so庫。如果想將多個本地檔案編譯到一個so庫中,只要最後一個引數新增多個C/C++檔案的相對路徑就可以
用C語言實現字串加密
Java中實現字串加密的一種比較簡單的方法是異或,將字串轉換為字元陣列,遍歷對其中的每個字元用金鑰(可以是字元)進行一次異或運算,生成新的字串。如果用JNI和C實現,大致步驟如下(jstring是要加密的字串):
1 獲取jstring的長度
2 動態開闢一個跟data長度一樣的char*
3 將 jstring型別轉換為char陣列(用char*接收)
4 遍歷char陣列,進行異或運算
5 將char*轉換為jstring型別返回
6 釋放動態開闢的堆記憶體空間
效果圖
我是用的是5.0的模擬器,有時會閃退,檢視系統日誌,會報出一下錯誤:
JNI DETECTED ERROR IN APPLICATION: input is not valid Modified UTF-8
網上查了一下,JNI在呼叫NewStringUTF方法時,遇到不認識的字元就會退出,因為虛擬機器dalvik/vm/CheckJni.cpp裡面的checkUTFString會對字元型別進行檢查。替代方案是在開始轉換前,先檢查char*中是否含有非UTF-8字元,有的話返回空字串。完整程式碼如下:
#include<jni.h>
#include <stdlib.h>
jboolean checkUtfBytes(const char* bytes, const char** errorKind) ;
jstring Java_com_mercury_cmakedemo_MainActivity_encryptStr
(JNIEnv *env, jobject object, jstring data){
if(data==NULL){
return (*env)->NewStringUTF(env, "");
}
jsize len = (*env)->GetStringLength(env, data);
char *buffer = (char *) malloc(len * sizeof(char));
(*env)->GetStringUTFRegion(env, data, 0, len, buffer);
int i=0;
for (; i <len ; i++) {
buffer[i] = (char) (buffer[i] ^ 2);
}
const char *errorKind = NULL;
checkUtfBytes(buffer, &errorKind);
free(buffer);
if (errorKind == NULL) {
return (*env)->NewStringUTF(env, buffer);
} else {
return (*env)->NewStringUTF(env, "");
}
}
//把char*和errorKind傳入,如果errorKind不為NULL說明含有非utf-8字元,做相應處理
jboolean checkUtfBytes(const char* bytes, const char** errorKind) {
while (*bytes != '\0') {
jboolean utf8 = *(bytes++);
// Switch on the high four bits.
switch (utf8 >> 4) {
case 0x00:
case 0x01:
case 0x02:
case 0x03:
case 0x04:
case 0x05:
case 0x06:
case 0x07:
// Bit pattern 0xxx. No need for any extra bytes.
break;
case 0x08:
case 0x09:
case 0x0a:
case 0x0b:
case 0x0f:
/*
* Bit pattern 10xx or 1111, which are illegal start bytes.
* Note: 1111 is valid for normal UTF-8, but not the
* modified UTF-8 used here.
*/
*errorKind = "start";
return utf8;
case 0x0e:
// Bit pattern 1110, so there are two additional bytes.
utf8 = *(bytes++);
if ((utf8 & 0xc0) != 0x80) {
*errorKind = "continuation";
return utf8;
}
// Fall through to take care of the final byte.
case 0x0c:
case 0x0d:
// Bit pattern 110x, so there is one additional byte.
utf8 = *(bytes++);
if ((utf8 & 0xc0) != 0x80) {
*errorKind = "continuation";
return utf8;
}
break;
}
}
return 0;
}