在工作中有个需求需要集成后端用C语言编写的p2p模块,并在java层调用native层的代码,这在安卓中需要通过jni来完成,下面将介绍如何在项目中使用jni。
1. 安装相关sdk
安卓提供了ndk帮助我们编译native层的C代码,因此我们需要确保sdk中已经下载如下工具:
上图中的LLDB可以帮助我们调试集成的C代码,这里还是建议安装一下。
安装成功后,需要在local.properties文件中添加下载的ndk绝对路径,例如:
ndk.dir=/Users/everglow/Library/Android/sdk/ndk/21.0.6113669
然后需要在app/build.gradle中指定编译native代码支持的abi和包含相关编译选项的MakeFile文件(Android.mk)的路径,例如:
android {
......
defaultConfig {
......
externalNativeBuild {
ndkBuild {
abiFilters 'armeabi-v7a', 'arm64-v8a', 'x86', 'x86_64'
arguments "V=1 -j${Runtime.runtime.availableProcessors()}"
}
}
ndk {
abiFilters 'armeabi-v7a', 'arm64-v8a', 'x86', 'x86_64'
}
}
buildTypes {
externalNativeBuild {
ndkBuild {
path 'src/main/jni/Android.mk'
}
}
}
}
在此之前,需要将C代码放到main/目录下,例如:
2. jni静态注册
jni注册方式有两种,分别是静态注册和动态注册,静态注册相对简单一些,我在项目中也使用的是静态注册的方式。
首先需要新建一个类,用于调用native层暴露的函数,例如:JniUtils.java
class JniUtils {
static {
System.loadLibrary("p2p");
}
/**
*
* @param center_host 中心服务器ip
* @param center_port 中心服务器端口
* @param nas_server_id nas的id
* @param nas_client_id 客户端id
* @param listen_ip 本地监听ip
* @param listen_port 本地端口
* @param server_port 设备端口
* @return 0 服务开启成功 -1 服务开启失败
*/
public static native int startClient(int log_level, int forced_relay, String center_host, String center_port, String nas_server_id,
String nas_client_id, String listen_ip, String listen_port, String server_port);
/**
* 关闭服务
*/
public static native void closeClient();
/**
* 返回当前p2p服务是否正在运行,运行:1,未运行:0
*/
public static native int isServiceStart();
/**
* 返回当前运行中的p2p服务连接是否正常,连接成功:0,连接失败:1
*/
public static native int isConnect();
/**
* 在jni中调用java层saveNativeExceptionLogs方法
*/
public static native void callSave(String logMsg, boolean forceFlush);
/**
* 写入natice层日志
*/
public static void saveNativeExceptionLogs(String logMsg, boolean forceFlush) {
String externalFilePath = FileUtils.getExternalFileDir().getPath();
String targetDir = externalFilePath + File.separator + "nativeCrashLogs";
File log = new File(targetDir);
if (log.exists() ||
(!log.exists() && log.mkdir())) {
StringBuilder targetFilePath = new StringBuilder();
Date now = new Date();
DateFormat dateFormat = new SimpleDateFormat("yyyy_MM_dd_HH_mm_ss", Locale.getDefault());
targetFilePath.append(targetDir).append(File.separator).append("crashLog_").append(dateFormat.format(now)).append(".txt");
File targetFile = new File(targetFilePath.toString());
FileUtils.writeTextToFiles(targetFile, logMsg);
}
}
}
(1)静态代码块中的System.loadLibrary("p2p"),指定的是要调用编译后产生的动态链接库的文件名,在我的代码中就是libp2p.so(lib前缀可以省略)。
(2)使用public static native的方式去声明要调用的方法名称,这里只需要包含方法签名,而不用具体的实现(具体实现在jni对应的bridge_jni.c文件中)。
在java层定义好调用类之后,我们需要生成对应的头文件,分为以下两步:
cd xxx/src/main/java/youPackageName/ // 进入JniUtils.java文件存放的目录
javac JniUtils.java // 编译得到字节码文件JniUtils.class
cd ../ // 回到java目录下
javah com.xxx.yyy.JniUtils // 生成头文件(java目录下)
在步骤1中,JniUtils.java文件如果引用了其他类,单独编译是无法找到其他类的。
将步骤2中生成的com_xxx_yyy_JniUtils.h头文件拷贝到native代码目录中,保证可以在bridge_jni.c文件中可以引用到该头文件。
最后就是编写bridge_jni.c文件,在这个文件中,我们可以调用C代码中导出的函数,例如:
#include "com_xxx_yyy_JniUtils.h"
#include "p2p.h"
#include
#include
#include
#include
static void on_log_callback(const char* msg, int len, void* userdata);
jstring charToJstring(JNIEnv *env, const char *pat) {
jclass strClass = (*env)->FindClass(env, "java/lang/String");
jmethodID ctorID = (*env)->GetMethodID(env, strClass, "","([BLjava/lang/String;)V");
jbyteArray bytes = (*env)->NewByteArray(env, strlen(pat));
(*env)->SetByteArrayRegion(env, bytes, 0, strlen(pat), (jbyte *) pat);
jstring encoding = (*env)->NewStringUTF(env, "utf-8");
return (jstring) (*env)->NewObject(env, strClass, ctorID, bytes, encoding);
}
JNIEXPORT jint JNICALL Java_com_xxx_yyy_JniUtils_startClient(JNIEnv* env, jclass clazz, jint log_level, jint forced_relay, jstring center_host, jstring center_port, jstring nas_server_id,
jstring nas_client_id, jstring listen_ip, jstring listen_port, jstring server_port)
{
// jstring转const char*
const char* c_center_host = (*env) -> GetStringUTFChars(env, center_host, JNI_FALSE);
const char* c_center_port = (*env) -> GetStringUTFChars(env, center_port, JNI_FALSE);
const char* c_nas_server_id = (*env) -> GetStringUTFChars(env, nas_server_id, JNI_FALSE);
const char* c_nas_client_id = (*env) -> GetStringUTFChars(env, nas_client_id, JNI_FALSE);
const char* c_listen_ip = (*env) -> GetStringUTFChars(env, listen_ip, JNI_FALSE);
const char* c_listen_port = (*env) -> GetStringUTFChars(env, listen_port, JNI_FALSE);
const char* c_server_port = (*env) -> GetStringUTFChars(env, server_port, JNI_FALSE);
p2pex_set_on_log(on_log_callback);
int ret;
ret = p2p_client_start(log_level, forced_relay, (char*)c_center_host, (char*)c_center_port, (char*)c_nas_server_id,
(char*)c_nas_client_id, (char*)c_listen_ip, (char*)c_listen_port, (char*)c_server_port);
if (ret != 0) {
return -1;
}
return 0;
}
JNIEXPORT void JNICALL Java_com_xxx_yyy_JniUtils_closeClient(JNIEnv *env, jclass clazz)
{
p2p_close();
}
JNIEXPORT jint JNICALL Java_com_xxx_yyy_JniUtils_isServiceStart(JNIEnv *env, jclass clazz)
{
return is_p2p_start();
}
JNIEXPORT jint JNICALL Java_com_xxx_yyy_JniUtils_isConnect(JNIEnv *env, jclass clazz)
{
return p2p_get_connect_status();
}
JNIEXPORT void JNICALL Java_com_xxx_yyy_JniUtils_callSave(JNIEnv* env, jclass clazz, jstring log_msg,
jboolean force_flush)
{
jclass jniClass = (*env)->FindClass(env, "com/xxx/yyy/JniUtils");
jmethodID jniSaveLogs = (*env)->GetStaticMethodID(env, jniClass, "saveNativeExceptionLogs", "(Ljava/lang/String;Z)V");
(*env)->CallStaticVoidMethod(env, jniClass, jniSaveLogs, log_msg, force_flush);
}
void on_log_callback(const char* msg, int len, void* userdata)
{
if (!msg || len == 0) {
return;
}
__android_log_print(ANDROID_LOG_INFO, "p2p", "%s\n", msg); //log i类型
return;
}
在这个文件中,我们需要通过JNIEXPORT returnType JNICALL xxx的方式将JniUitls.java中调用的方法导出。
这里需要注意函数的命名方式:Java_com_xxx_yyy_JniUtils_startClient ,从左到右分别是Java_包名_调用的Java类名_具体方法名。然后对应参数的类型也需要进行转换,参数表如下:
Java类型 | Jni本地类型 |
boolean | jboolean |
byte | jbyte |
char | jchar |
short | jshort |
int | jint |
long | jlong |
float | jfloat |
double | jdouble |
void | void |
以上就是静态注册jni的大致过程,静态注册的优点就是简单,缺点就是一旦需要增加、删除或修改JniUtils.java中的调用函数,就需要重新编译并生成对应的头文件。有编写C代码相关经验的开发可以采用动态注册的方式。
最后不要忘了将bridge_jni.c文件在Android.mk文件中引入:
LOCAL_SRC_FILES := libp2p/bridge_jni.c
3. 通过jni实现java层调用native层代码
在对应的类文件中直接使用JniUtil.xxx的方式调用即可。
4. 通过jni实现native层调用java层代码
有时候需要native层回调Java层编写的方法,大致可以分为两种形式,一种是在JNIEXPORT的方法中,在这里我们可以获取到env环境变量,从而通过反射来找到对应的类和方法并执行。
以上面的代码为例,假如我需要在bridge_jni.c文件中调用JniUitls.java文件中定义的saveNativeExceptionLogs方法,可以通过如下方式:
jclass jniClass = (*env)->FindClass(env, "com/xxx/yyy/JniUtils");
jmethodID jniSaveLogs = (*env)->GetStaticMethodID(env, jniClass, "saveNativeExceptionLogs", "(Ljava/lang/String;Z)V");
(*env)->CallStaticVoidMethod(env, jniClass, jniSaveLogs, log_msg, force_flush);
(1)通过FindClass找到需要调用方法所在的类文件(完整包路径);
(2)通过GetStaticMethodID(这个方法根据你定义方法的类型决定,我这里定义的是静态方法),引用要调用的方法。参数列表分别是:环境变量env,(1)中jclass引用,方法名,方法签名字符串。因为java中支持方法的重载,所以必须传入第四个参数表明调用方法的完整签名才能唯一确定这个方法。
(3)通过CallStaticVoidMethod(这个方法同样根据定义方法的类型决定)调用该方法。参数列表分别是:环境变量env,(1)中jclass引用,(2)中jmethodID引用,参数1,参数2,...。后面的参数列表是执行saveNativeExceptionLogs时需要传入的参数。
备注1:如何知道方法签名对应的字符串?
方法签名可以这样表示:(参数1类型;参数2类型;...)返回值来表示。java提供了相应的字段来表示对应的参数类型:
域 | 类型 |
Z | boolean |
B | byte |
C | char |
S | short |
I | int |
J | long |
F | float |
D | double |
V | void |
引用类型:L + 类型的类描述符,例如String表示为Ljava/lang/String,这里需要传入完整的包路径。
数组类型:[ + 类型的描述符,例如String[]表示为[Ljava/lang/String。
java还提供了一个命令帮助我们生成类中方法对应的签名字符串:
javap -p -s com.xxx.yyy.JniUtils
另一种C代码回调java代码的情况发生在非JNIEXPORT的方法中,这里需要解决的关键问题就是要想办法获取到env环境变量。我们可以通过重写JNI_Onload方法来完成:
JNIEnv* env = NULL;
jint JNI_OnLoad(JavaVM* vm, void* reserved)
{
jint result = -1;
if ((*vm)->GetEnv(vm, (void**)&env, JNI_VERSION_1_6) != JNI_OK) {
printf("ERROR: JNI_OnLoad GetEnv failed\n");
goto failed;
}
result = JNI_VERSION_1_6;
failed:
return result;
}
但是这种如果C代码中引用的其他库也重写了这个方法(例如pjsip),这样就行不通了,编译时会报错,存在两个位置定义了JNI_Onload方法。
5. 调试native层代码
有时候我们需要在Android环境下去调试native层的代码,大概有以下几种方式:
(1)打断点:这种方式只能在debug模式下使用,我们首先需要点击AS右侧控制栏的bug图标安装debug包,然后在需要调试C代码的地方添加断点,逐步执行。这种方式能够清楚的打印调用栈,及传递的参数,非常的方便。
(2)输出日志:在C代码中的printf产生的日志并不会输出到logcat中,万幸的是,android为我们提供了方法去打印这部分由native层产生的日志。例如:
#include
__android_log_print(ANDROID_LOG_INFO, "p2p", "%s\n", msg); // ANDROID_LOG_INFO是日志级别
(注:需要在Android.mk中添加LOCAL_LDLIBS := -llog)
不过这种方式也局限于debug状态下,因为毫无疑问release包中我们是看不到输出的日志的。
(3)将输出日志写入到本地:
上面两种方法都是在debug状态下有效,但是有的问题可能只有在测试过程中才能发现,这就需要我们去收集日志信息,并保存到手机中。主要有以下几种方式:
1、 使用爱奇艺开源的框架xCrash,这个还是非常有用的,支持捕获java层、native层的异常以及ANR相关的日志。集成和使用也非常的简单。
2、将logcat输出的日志写入到文件中:
在上面的bridge_jni.c文件中,可以看到on_log_callback方法提供给C代码进行回调,并将日志输出到logcat中。我们可以把这部分日志保存起来,在MainApplication.java文件中:
@Override
public void onCreate() {
......
if (FileUtils.isExternalStorageMounted()) {
File appDirectory = new File(FileUtils.getInternalFileDir() + "/p2pLog" );
File logDirectory = new File(appDirectory + "/log" );
File logFile = new File( logDirectory, "logcat" + System.currentTimeMillis() + ".txt" );
if (appDirectory.exists() || (!appDirectory.exists() && appDirectory.mkdir())) {
if (logDirectory.exists() || (!logDirectory.exists() && logDirectory.mkdir())) {
try {
Runtime.getRuntime().exec("logcat -c"); // 清空日志
Runtime.getRuntime().exec("logcat -f " + logFile); // 写入日志到指定文件
Log.d(TAG, "start collect logs");
} catch (IOException e ) {
e.printStackTrace();
}
}
}
}
}
这样,我们的日志就会保存在/data/data/yourPackageName/files/p2pLog/log/logcatXXXXXXX.txt中(建议将日志写入到App内部存储的files路径下,虽然读取release包这部分应用数据比较麻烦,但是相比于写在外部存储中,没有动态申请写入权限的麻烦)。而且只要App在后台运行,日志就会不断的写到文件中。所以这种方式可以在内部测试时使用,尽量不要在上架的包中包含这部分日志写入的代码。
那么如何去读取release包下这个应用内部存储路径的日志文件呢?如果是debug状态,我们可以使用AS提供的Device File Explorer轻松打开,但是release包肯定是不可以的。
这里就需要用到安卓提供的backup功能了。首先在AndroidManifest.xml文件的application标签下添加:
android:allowBackup="true"
启用备份功能(不建议在上架包中开启该功能,可能会有安全漏洞)。
之后从测试那边拿到出现问题的手机,连接到电脑上,运行如下命令获取app的备份文件backup.ab。
adb backup -noapk com.your.packagename
此时手机会弹出一个Activity提示是否备份对应app的文件,其中还有输入密码的选项(建议不要设置密码),点击我要备份即可。
拿到backup.ab后,可以使用这个开源jar包android-backup-extractor来提取其中的内容。下载好abe-all.jar后,将backup.ab放到同级目录下,运行如下命令:
java -jar abe-all.jar unpack backup.ab backup.tar
之后我们会得到一个backup.tar压缩文件,解压后即可得到备份的app文件数据(如果得到的压缩文件解压得到的还是一个压缩文件,那就说明这是一个空包,确认一下release包是否有设置allowBackup以及是否正确的备份出对应app的文件)。
6. 打包so和aar进行集成
经过测试之后,我们往往需要将native代码编译成多个abi动态库文件的形式,然后将调用的Android端代码打包成aar的形式集成到我们的app中。为了方便测试和生成模块包,可以在gradle.properties文件中定义一个变量标识编译选项:
# 编译选项
isModule=true
然后在src/main文件夹下创建release(模块)和debug(应用)文件夹,创建不同的AndroidManifest.xml文件。这是因为作为一个模块,是不需要诸如Activity之类的标签的。
接着在app/build.gradle文件中:
if (isModule.toBoolean()) {
apply plugin: 'com.android.library'
} else {
apply plugin: 'com.android.application'
}
android {
defaultConfig {
applicationId "com.xxx.yyy" // 如果isModule是true,需要注释
}
sourceSets {
main {
if (isModule.toBoolean()) {
manifest.srcFile 'src/main/release/AndroidManifest.xml' // 作为module编译时引用的manifest文件
} else {
manifest.srcFile 'src/main/debug/AndroidManifest.xml' // 作为application编译时引用的manifest文件
}
}
}
}
最后如果不想作为模块编译时的部分资源被集成的APP访问,可以在res/values/public.xml文件中定义:
这样,我们只需要修改isModule字段就可以在编译apk和aar之间进行切换。
在将项目作为模块进行编译时,生成的aar文件在build/outputs/aar/路径下,生成的so动态库文件再build/intermediates/ndkBuild/debug/obj/local路径下,根据你在build.gradle中的配置,包含特定abi下的动态库文件。
aar和so包的集成:
集成aar很简单,只需要在项目中File->New->New Module中选择Import .JAR/AAR Package,然后指定引入aar的路径即可。
至于so包的引用,可以在app/src的同级目录下创建libs文件夹,将动态库文件放入其中,接着在app/build.gradle文件中:
android {
defaultConfig {
ndk {
abiFilters 'armeabi', 'armeabi-v7a', 'arm64-v8a', 'x86', 'x86_64'
}
}
sourceSets{
main{
jniLibs.srcDirs = ['libs']
}
}
}
dependencies {
implementation fileTree(dir: "libs", include: ["*.jar"])
}
再sync project即可。