Android JNI/NDK 开发实用技巧

由于工作内容大多是和Camera相关的算法集成, 所以经常会用到JNI/NDK, 在此记录一下开发过程中一些注意事项以及一些小技巧.

动态注册JNI函数失败(no static or non-static method)

此类问题一般有两种情况:

  1. 包名,类名或者函数签名写错了, 这个解决方法就仔细检查一下就行, 当然如果一些签名不知道怎么写, 可以用静态注册方法通过javah生成头文件, 然后看下头文件里面注释, 把注释的签名复制过来就行
  1. 由于加入了混淆机制, 导致无法通过类名来找到对应方法了, 这种情况我遇到过两次, 表现稍有差异, 但本质问题都是混淆引起的.

    • Java中定义的native方法, 在代码中其他地方用到了就能注册上, 没用到的只声明了的函数就会注册失败, 原因是有些没有用到的函数, 混淆是会自动剔除, 所以实际运行的代码中就没有相关方法了, 动态注册就会失败

    • 所有方法都注册不上, 同时也确定相关包名类名签名没有错, 这种情况就是即使你用到了相关方法, 但经过过混淆后已经不是原来的名称了, 所以注册失败

这两种错误解决方法也简单:

  1. 禁用混淆, 这样做不是太好, 混淆对防止反编译和apk瘦身有很大帮助

  2. 添加混淆白名单, 和JNI相关的类或者方法不做混淆, 比如在项目proguard.flags(或者其他自定义的混淆文件中)加入如下代码:

-keep class com.android.gallery3d.jpegstream.JPEGOutputStream { *; }

64位系统中App调用32位so库

由于某些原因(比如使用比较老的算法库), 算法库或者其他C/C++库只有32位的, 但使用的Android系统是64位的, 因此我们需要在64位系统上调用32位算法库, Android 64位系统本身是既能使用64位库, 也能使用32位库.Android中对于调用32位还是64位算法库判断依据如下:

Apk本身(Java代码)不分32位和64位, 只有so库会分32位和64位, 当App首次运行时, 默认会去加载64位so库, 如果没有一个so库是64位的, 就加载32位so库, 只要有一个so库是64位, 则所有so库都是加载64位, 简单说64位系统中App加载32位so库做法就是只放32位so库, 不包含任何64位so库.

要达到上述目的, 要注意如下两点(分为通过Android编译系统编译的系统App和使用IDE开发的第三方App):

  • 通过Android.mk组织编译规则编译的系统App

    此类型编译方式是做ROM开发比较常见的, 主要修改Android.mk, 在编译apk和so库的Android.mk中都加入

    LOCAL_MULTILIB := 32表明只编译32位, 同时App对应的mk中加入LOCAL_JNI_SHARED_LIBRARIES := libxxx表明App需要的so库, LOCAL_JNI_SHARED_LIBRARIES := libxxx在.mk中的作用是表明App依赖这个so, 并且编译的时候会在App对应目录下的arm文件夹中创建对应的so库软链接, 比如编译SnapdragonGallery后, 我们看下out目录的内容:

wenzhe@ubuntucomp:~/code/HLOS$ ll out/target/product/msm8909w/system/app/SnapdragonGallery/lib/arm/
total 8
drwxrwxr-x 2 wenzhe wenzhe 4096  4月 25 20:52 ./
drwxrwxr-x 3 wenzhe wenzhe 4096  4月 25 20:52 ../
lrwxrwxrwx 1 wenzhe wenzhe   38  4月 25 20:52 libjni_gallery_eglfence.so -> /system/lib/libjni_gallery_eglfence.so
lrwxrwxrwx 1 wenzhe wenzhe   37  4月 25 20:52 libjni_gallery_filters.so -> /system/lib/libjni_gallery_filters.so
lrwxrwxrwx 1 wenzhe wenzhe   40  4月 25 20:52 libjni_gallery_jpegstream.so -> /system/lib/libjni_gallery_jpegstream.so

可以看到一些so库是通过软链接映射到system/lib/对应的so库的, 之所以要这样做, 因为系统源码方式编译的App是不会将so打包到apk中的, 当Java中loadLibraries时就默认加载64位的了, 所以要通过LOCAL_JNI_SHARED_LIBRARIES := libxxx建立软链接收, 就知道应该加载那种类型的so了.

  • 通过IDE开发App

    这种类型做法就比较简单了, 直接移除所有64位相关的so库(arm64-v8a), 只保留32位so库.

注意事项: 通过源码编译的方式调试的时候, 需要清除App数据后, 删除system目录下对应的文件夹, 重新push apk, 然后重启, 不然关于加载32位还是64位的so库修改可能不生效. 对应IDE开发的App, 我自己没试过, 但应该是要卸载原有apk然后重新安装, 这样可避免一些不必要的坑.

通过JNI传递byte数组

图像/视频处理就少不了数据传递, 图像/视频处理的数据都是用数组存储的, 一张图片的数据一般在一块连续内存中(也有不连续分开的, 比如YUV中Y和UV不是同一块内存区域), Java中用byte数组存储(0~255)这些图像像素信息, C/C++中则用unsigned char数组, 如果是在App中集成算法, 就需要把Java中bype[]传到C/C++中, 常用的方式有如下两种:

GetByteArrayElements()

这是比较常用的方式, Java中以byte[]作为native方法参数, C/C++中通过如下方式进行获取和释放:

jbyte* data = env->GetByteArrayElements(array, NULL);
// 将data转为unsigned char* 传给算法 ...
// 释放
env->ReleaseByteArrayElements(array, data, 0);
}

但是大多数人可能没有注意到这两个方法的最后一个参数, GetByteArrayElements(jbyteArray array, jboolean* isCopy)最后一个参数是指获取到的数组是否是copy的, 由于VM实现不同, 从Java层传下来的数组有可能是虚拟机重新分配内存, 然后copy Java数组中的数据, 最后返回指针到C/C++中, 在比较低的Android版本中可能存在这个问题, 现在都2018年了, 基本上都不是copy的, 都是直接获取原始数组指针, 当我们需要确定是否是copy的时候, 可以传递一个bool类型指针, 通过判断bool值就知道了, 即:

jboolean isCopy = JNI_FALSE;
jbyte* data = env->GetByteArrayElements(array, &isCopy);
if (isCopy) {/*do something*/}

另外如果不是copy的方式, 有两个关于虚拟机(VM)的操作叫做pinned down和un-pinned, 我理解意思是Java层的数组被native层获得后相当于被占用, un-pinned后才表明被释放.

同样由于获得的数组后可能并不是原始数组的指针, 所以释放的时候有个参数来指定相关数据是否要回写到Java层

 void ReleaseByteArrayElements(jbyteArray array, jbyte* elems, jint mode)

释放的时候最后一个参数 mode有下面三种取值:

  • 0 :
    • isCopy为false: 数组会被 un-pinned
    • isCopy为true: 数组内容的更改会被复制回Java层, 并且copy的内存会被释放
  • JNI_COMMIT :
    • isCopy为false: 不做任何事情
    • isCopy为true: 数组内容的更改会被复制回Java层, copy的内存没有被释放
  • JNI_ABORT :
    • isCopy为false: 数组会被 un-pinned, 之前的修改依然生效
    • isCopy为true: 数组内容的更改会被丢弃, copy的内存会被释放

上面的说法可能不是太好理解, 但我们需要知道有这么个事情, 同时大多数场景mode的值一般为0, 另外上面所讲的内容是在Android Developer上看到的, 有兴趣可以读一下 https://developer.android.google.cn/training/articles/perf-jni
如果有不同理解或者我的理解有误的话, 欢迎指正并讨论.

我们常见的大多数应用场景下, release最后一个参数设置为0即可.

GetDirectBufferAddress()

通过此方法获取数据有些局限性, 但在一些应用场景效率非常高, 基本没有额外的开销, 使用方法如下:

  1. Java层通过ByteBuffer.allocateDirect()申请内存空间.

  2. 将ByteBuffer作为native方法参数传到C/C++中

  3. C/C++代码中通过env->GetDirectBufferAddress(jobject)可直接获取到数组指针进行操作

之所以可以这样操作, 是因为ByteBuffer.allocateDirect()函数申请的内存是通过系统(OS)级别操作来分配, 所以就可以方便的获取地址进行操作, 一个典型的应用场景: 使用Camera API2获取预览数据用进行算法处理后, 用OpenGL ES进行绘制并显示.
用OpenGL ES的人应该知道很多OpenGL ES的接口很多是用java.nio.Buffer作为参数的, 并且申请的时候要使用allocateDirect()也是这个道理, 同时Camera API2中预览拍照返回的数据也都是ByteBuffer类型, 这样的处理方式以后也会用的更多.
关于使用Camera API2获取预览数据用进行算法处理后, 用OpenGL ES进行绘制并显示这个应用场景我后续会单独写一篇博客, 敬请关注.

C/C++中打印Log

这个比较常见, 教程也比较多, 我这里也做下记录:

  1. 在C/C++中引入系统Log头文件 #include
  2. 通过宏定义的方式定义不同等级Log和Log标题
#define TAG "HelloTAG"
#define LOGD(...) __android_log_print(ANDROID_LOG_DEBUG,TAG ,__VA_ARGS__)
#define LOGI(...) __android_log_print(ANDROID_LOG_INFO,TAG ,__VA_ARGS__)
#define LOGW(...) __android_log_print(ANDROID_LOG_WARN,TAG ,__VA_ARGS__)
#define LOGE(...) __android_log_print(ANDROID_LOG_ERROR,TAG ,__VA_ARGS__)
#define LOGF(...) __android_log_print(ANDROID_LOG_FATAL,TAG ,__VA_ARGS__)
  1. 在编译C/C++文件的Android.mk中加入LOCAL_LDLIBS :=-llog然后就可以使用LOGE("Hello World")打印Log了

定位native层的crash

crash问题是开发中比较常见的, Java由于其特性, Crash问题我们直接看AndroidRuntime的Log就行, C/C++的就要麻烦些了.
说明: 如果Crash对应的so没有符号表, so库是别人编译的, 额外去除了符号表(Releas版本), 这种情况是没法定位crash代码具体是在哪个位置的.
对于有源码并且是自己可以编译的情况下, 可通过如下方式对Crash代码进行定位:

  • 通过ndk-stack定位
    ndk-stack是NDK开发工具包中自带的, 配置好NDK后即可使用, 使用方式有如下两种:
    1. adb logcat |ndk-stack -sym 带符号表的so库路径, 如果是Android源码方式编译, 带符号表的so库路径为(32位)out/target/product/xxx/symbols/system/lib/, 64位就在lib64目录想, 如果使用ndk-build编译的代码, 带符号表的so就在和libs同级的obj目录里面. 执行adb命令后, 只需复现crash即可看到Log中输出的Crash栈对应的代码行数和位置
    2. 如果Log是以文件方式存储的, 可通过ndk-stack -sym [带符号表的so库路径] -dump [log文件路径]进行查看
  • 通过addr2line定位
    addr2line相当于缩减版的ndk-stack, 每次只能看一个地址的位置, 使用非常简单addr2line -f -e [带符号表的so路径] [crash地址0xxxx]

注意事项: 如果源码和so库不是完全对应的, 即so库发布后, 源码有过修改,这样会导致定位的行数有些偏移, 不完全准确, 需要额外注意下.

小技巧: 只看native层crash log, 可直接 adb logcat *:F

你可能感兴趣的:(Android JNI/NDK 开发实用技巧)