NDK实例
本文通过编写一个算法类二进制库以及示例App应用来介绍NDK开发,重点是二进制库编写、编译以及自定义参数的传递。
1. Java层接口定义
在做so库之前,需要首先设计约定库的接口,包括实现哪些功能,具体函数以及接口参数等等,这也是Java层调用所使用的接口。
在这个例子中,我们准备实现一个算法库,其中实现2个函数,一个返回算法库版本号,另一个计算线段中点坐标,主要用来展示so库编写以及参数传递过程。如果有需要其他的接口实现,可以参照这两个函数来做。
要实现的接口文件 com/zhdgps/ts/TSMath.java
,其中 tsmath
是要实现的so库名字。
package com.zhdgps.ts;
public class TSMath {
static {
System.loadLibrary("tsmath");
}
/* 获取版本信息 */
public static native String getVersion();
/* 计算两点连线中心点 */
public static native TSCoord calcCenter(TSCoord a, TSCoord b);
}
System.loadLibrary
用来加载so库,static
表示这段代码要最先运行。
接口函数用 native
关键字修饰表明这是源生方法实现而不是 java 代码实现(JNI),在这里就是指在so库中实现的方法。
同时辅助用的 TSCoord
定义文件 com/zhdgps/ts/TSCoord.java
。
package com.zhdgps.ts;
public class TSCoord {
public double N;
public double E;
public double Z;
}
两个文件都放属于包 com.zhdgps.ts
。
2. so库C代码实现和编译
完成库接口设计后,下面介绍如何用C来实现接口对应的功能。
新建文件夹 tsmath
,用于存放 so 库工程。在 tsmath
下新建子文件夹 jni
(命名为jni
是 NDK 编译的需要)用于存放 so 库的源文件。
在 jni
文件夹下新建文件 tsmath.h
、tsmath.c
、algorithm.h
、algorithm.c
、Android.mk
以及 Application.mk
文件。
其中,tsmath.h
和 tsmath.c
是 so 库的实现文件,algorithm.h
和 algorithm.c
是实际算法实现,相对来说 tsmath.c
是 JNI 接口层,而 algorithm.c
则是实际的算法C代码。这样区分有助于代码逻辑分层,当然都写在 tsmath.c
中也是可行的。
Android.mk
和 Application.mk
是用于编译so库所需要的 ndk-build
脚本文件,后面会进行详细叙述。
2.1 算法实现(Algorithm
)
Algorithm.h
文件内容:
#ifndef TS_ALGORITHM_H
#define TS_ALGORITHM_H
#ifdef __cplusplus
extern "C" {
#endif
typedef struct _TSCoord {
double N;
double E;
double Z;
}TSCoord;
enum TSAlgo_ErrorCode {
TSALGO_NOERROR,
};
int TSAlgo_CalcCenter(const TSCoord *a, const TSCoord *b, TSCoord *result);
#ifdef __cplusplus
}
#endif
#endif
头文件主要声明了一个计算中点的函数以及参数结构体类型,实现文件 Algorithm.c
内容如下:
#include "algorithm.h"
int TSAlgo_CalcCenter(const TSCoord *a, const TSCoord *b, TSCoord *result)
{
result->N = (a->N + b->N) / 2;
result->E = (a->E + b->E) / 2;
result->Z = (a->Z + b->Z) / 2;
return TSALGO_NOERROR;
}
如果有其他要实现的算法,都可以在这个模块内实现。此模块将被 JNI 接口层调用。
2.2 JNI 接口实现
JNI 接口部分是 so 库的核心,用于在 Java 调用和实际的 C/C++ 调用之间充当中间层。JNI 的实现有两种方法,一种是静态注册,一种是动态注册。
静态注册是指用 javah
工具来生成 C/C++ 头文件,获得正确的函数名。在运行时 JNI 按照指定规则的函数命名来调用对应的 C 函数。
动态注册是指在动态库模块被加载的时候,模块注册的函数功能到 JVM 中。在对应函数被调用时,JVM会按照指定的注册函数名去调用实际的函数。
静态注册生成的函数命名很长,而且如果要修改函数名,那么就要重新修改编译。静态注册的模块只有在被调用时才会被查找检查,如果函数命名有问题,会直接运行异常。
动态注册在向 JVM 注册函数时,可以指定函数名,在编写时可以使用自定义的函数命名,如果需要修改维护,则只需要修改注册时的命名即可。
NDK 推荐使用动态注册,在模块中定义 JNI_OnLoad
函数,此函数在模块被加载时(即System.loadLibrary
)被调用,模块在此函数中注册所有函数。
综上,我们要在模块中实现3个主要函数:
/* 动态库加载时候被调用的方法,进行初始化并注册模块函数 */
JNIEXPORT jint JNICALL JNI_OnLoad(JavaVM *vm, void *reserved);
/* 对应 TSMath.getVersion */
JNIEXPORT jstring JNICALL native_get_version(JNIEnv *env, jobject thiz);
/* 对应 TSMath.calcCenter */
JNIEXPORT jobject JNICALL native_calc_center(JNIEnv *env, jobject thiz, jobject coorda, jobject coordb);
native_get_version
和 native_calc_center
即 TSMath
的 JNI 实现,函数参数和返回值也与之对应,每个函数的前两个参数 JNIEnv *
和 jobject
是 JNI 函数固定传入的参数。jstring
对应 Java 的 String
,而自定义类对象均用 jobject
来对应,完整的 JNI 类型匹配可以参见 Primitive Types.
要实现动态注册,我们需要编写注册方法:
/* ------------------------------------------------------------- */
/* 方法注册资源表 */
static JNINativeMethod native_methods[] = {
{"getVersion", "()Ljava/lang/String;", (void *)native_get_version},
{"calcCenter", "(Lcom/zhdgps/ts/TSCoord;Lcom/zhdgps/ts/TSCoord;)Lcom/zhdgps/ts/TSCoord;", (void *)native_calc_center},
};
#define NATIVE_METHODS_COUNT (sizeof(native_methods)/sizeof(native_methods[0]))
/* 为某一个类注册方法 */
static int register_navtive_methods(JNIEnv *env,
const char *classname,
JNINativeMethod *methods,
int methods_num)
{
jclass clazz;
clazz = (*env)->FindClass(env, classname);
if(clazz == NULL) {
return JNI_FALSE;
}
if((*env)->RegisterNatives(env, clazz, methods, methods_num) < 0) {
return JNI_FALSE;
}
return JNI_TRUE;
}
/* 为所有类注册本地方法 */
static int register_natives(JNIEnv *env)
{
/* 指定要注册的类名 */
const char *classname = "com/zhdgps/ts/TSMath";
return register_navtive_methods(env, classname, native_methods, NATIVE_METHODS_COUNT);
}
JNINativeMethod
是在 <jni.h>
中定义的结构体,用于存储要动态注册的函数信息。第一个成员是字符串,用以表示要注册的函数所使用的函数名。第二个成员是字符串,用以表示函数的参数和返回值接口(Type Signatures
),在这个字符串中,"()"
内表示函数的参数类型,然后是函数的返回值类型,"()"
表示函数参数为空,而如果函数返回值为空,则用 "()V"
表示。 "Ljava/lang/String;"
表示为 java.lang.String
,"Lxxx;"
是 xxx
类型的完整写法,包字段分隔用斜杠代替,比如 "Lcom/zhdgps/ts/TScoord;"
表示类型为 com.zhdgps.ts.TSCoord
。如果参数有多个,依次写出对应类型。Java 源生类型可以参考 Type Signatures.
在 register_natives
中,我们将这两个函数注册到了类 com/zhgps/ts/TSmath
中,这个类就是之前在 Java 层定义的 com.zhdgps.ts.TSMath
。
下面继续 native_get_version
和 native_calc_center
两个函数的实现
#define TSMATH_VERSION "v0.1 alpha"
JNIEXPORT jstring JNICALL native_get_version(JNIEnv *env, jobject thiz)
{
return (*env)->NewStringUTF(env, TSMATH_VERSION);
}
native_get_version
比较简单,就返回一个版本文本字符串。这里需要注意的是,如果函数需要返回非ASCII的字符串,则不能直接使用 NewStringUTF
,因为 JNI 使用了修改版的 UTF-8 编码,具体可以参考 Modified UTF-8 Strings.
/* 保存全局 TSCoord 的信息,便于后续检索成员 */
struct TSCoordJNIInfo {
/* ICS 4.0 之后,jclass 可能会变化,所以在获取后,调用 NewGlobalRef 保存引用,然后就不再变化 */
jclass cls;
/* ID 一般不会变化 */
jfieldID fid_n;
jfieldID fid_e;
jfieldID fid_z;
jmethodID mid_init;
} g_tscoord_jni;
static int helper_init_tscoord_jniinfo(JNIEnv *env)
{
jclass cls = (*env)->FindClass(env, "com/zhdgps/ts/TSCoord");
jfieldID fid_n = (*env)->GetFieldID(env, cls, "N", "D");
jfieldID fid_e = (*env)->GetFieldID(env, cls, "E", "D");
jfieldID fid_z = (*env)->GetFieldID(env, cls, "Z", "D");
jmethodID mid_init = (*env)->GetMethodID(env, cls, "<init>", "()V");
/* ICS 4.0 之后保存全局引用需要调用此函数,后续需要解除引用,使用函数 DeleteGlobalRef */
cls = (jclass)((*env)->NewGlobalRef(env, cls));
g_tscoord_jni.cls = cls;
g_tscoord_jni.fid_n = fid_n;
g_tscoord_jni.fid_e = fid_e;
g_tscoord_jni.fid_z = fid_z;
g_tscoord_jni.mid_init = mid_init;
return 0;
}
static jobject helper_new_tscoord(JNIEnv *env)
{
jobject tscoord = (*env)->NewObject(env, g_tscoord_jni.cls, g_tscoord_jni.mid_init);
return tscoord;
}
static TSCoord helper_get_tscoord(JNIEnv *env, jobject coord)
{
TSCoord res_coord;
jdouble n = (*env)->GetDoubleField(env, coord, g_tscoord_jni.fid_n);
jdouble e = (*env)->GetDoubleField(env, coord, g_tscoord_jni.fid_e);
jdouble z = (*env)->GetDoubleField(env, coord, g_tscoord_jni.fid_z);
res_coord.N = n;
res_coord.E = e;
res_coord.Z = z;
return res_coord;
}
static void helper_set_tscoord(JNIEnv *env, jobject coord, const TSCoord *source)
{
(*env)->SetDoubleField(env, coord, g_tscoord_jni.fid_n, source->N);
(*env)->SetDoubleField(env, coord, g_tscoord_jni.fid_e, source->E);
(*env)->SetDoubleField(env, coord, g_tscoord_jni.fid_z, source->Z);
}
JNIEXPORT jobject JNICALL native_calc_center(JNIEnv *env, jobject thiz, jobject coorda, jobject coordb)
{
TSCoord a, b, c;
jobject obj;
a = helper_get_tscoord(env, coorda);
b = helper_get_tscoord(env, coordb);
TSAlgo_CalcCenter(&a, &b, &c);
obj = helper_new_tscoord(env);
helper_set_tscoord(env, obj, &c);
return obj;
}
我们定义了一个结构体 struct TSCoordJNIInfo
用于保存 Java 类 com.zhdgps.ts.TSCoord
保存在 JVM 中的信息,包括类句柄、各字段ID(N,E,Z)以及构造函数ID。这些信息到后面获取/设置类对象字段时会用到,用全局结构体保存这些信息是为了效率,对于 FieldID
和 MethodID
来说,一旦类初始化后就不再变化,如果每次需要获取类对象信息时都去调用 GetFieldID
和 GetMethodID
,会给 JVM 带来负担,而且代码也有冗余。这里需要注意的是类句柄,对于 ICS4.0 以后的安卓系统,内存中的句柄可能会因为内存整理而移动,这意味着类句柄是会变化的,需要使用函数 NewGlobalRef
来保证句柄不变。
函数 helper_init_tscoord_jniinfo
用于获取类信息,这个函数需要在 JNI_OnLoad
中调用,保证在函数被调用前初始化全局信息。
函数 helper_new_tscoord
、 helper_get_tscoord
和 helper_set_tscoord
是定义的三个辅助函数,用于新建 TSCoord
Java 对象、TSCoord
Java 对象与 C 结构体互相转换。
如上,要获取一个类对象信息,依次需要使用 FindClass
来获取类句柄,然后通过句柄来获取各个字段的 FieldID
,之后就可以通过这些字段 ID 来获取实际的值。com.zhdgps.ts.TSCoord
字段均为 double
所以使用 GetDoubleField
来获取字段值,如果有其他类型,可以以此类推。
函数 native_calc_center
的逻辑就比较简单了,通过转换对象,然后转为调用 Algorithm
中的算法,然后再将结果转换为 Java 对象返回。
2.3 编译 so 库
编辑 Android.mk
文件
LOCAL_PATH := $(call my-dir)
include $(CLEAR_VARS)
# 架构
LOCAL_ARM_MODE := arm
# 模块名
LOCAL_MODULE := tsmath
# 模块编译源文件
LOCAL_SRC_FILES := tsmath.c algorithm.c
# 模块依赖的库,比如要使用 android log 库
LOCAL_LDLIBS := -llog
# 编译为动态库
include $(BUILD_SHARED_LIBRARY)
这个文件是用于 so 模块的编译,其中模块名为 tsmath
,这样编译出的文件会自动加前后缀,输出为 libtsmath.so
。源文件部分加上项目使用的所有 C 文件,头文件不必加入其中。如果有依赖的安卓库,则加到 LOCAL_LDLIBS
链接部分。
编辑 Application.mk
文件
APP_OPTIM := release
这里指定生成 release 版本的 so 库。如果这里如果需要生成其他平台库,则需要设置 APP_ABI
字段。比如要生成全平台,则添加一句 APP_ABI := all
,这样会同时生成其他平台(x86 等)。
下载 NDK ,完成安装并设置好系统 Path
变量。下载地址 Android NDK.
在 jni
上一级目录 tsmath
下打开命令行,输入命令 ndk-build
进行编译,编译完成后的 so 文件自动会保存到 libs
目录下。
3. so 库的使用
下面来新建一个 Android 项目来测试一下 so 库。
使用 Android Studio 新建一个 Hello World 项目,这里可以设置项目命名空间为 com.zhdgps.ts
来方便后面的测试。在项目文件夹的 app/src/main
目录下,新建文件夹 jniLibs
,然后复制 tsmath/libs
目录下的编译输出到该文件夹中,注意保留 so 库的目录结构,比如 arm 架构编译的为 jniLibs/armeabi/libtsmath.so
。将 TSCoord.java
和 TSMath.java
文件复制到 app/src/main/java/com/zhdgps/ts
目录下。Android Studi 会自动将添加的文件加入到工程中。
修改 MainActivity.java
文件, 在 onCreate
中添加测试代码
TSCoord a = new TSCoord();
a.N = 1.0;
a.E = 2.0;
a.Z = 3.0;
TSCoord b = new TSCoord();
b.N = 3.0;
b.E = 6.0;
b.Z = 9.0;
TSCoord c = TSMath.calcCenter(a, b);
String output = String.format("A(%f, %f, %f), B(%f, %f, %f) center: (%f, %f, %f)",
a.N, a.E, a.Z,
b.N, b.E, b.Z,
c.N, c.E, c.Z);
TextView view = (TextView)findViewById(R.id.message);
view.setText(output);
这里测试了函数 TSMath.calcCenter
。编译项目并运行,就可以看到结果了。
源码可以点击这里下载。
ndk-build提示“系统找不到指定的路径”问题
如果是win32系统,使用ndk-build时提示出错,但是在命令行工具中看不到错误信息,进一步使用 ndk-build 2>1.txt
来重定向,发现错误信息是“系统找不到路径”,那么你可能和我遇到相同的问题了。
这个并不是因为ndk-build不在 path
路径中,而是NDK自己的问题。观察 ndk-build
命令输出详细,调用 gcc
的路径实际上并不对。
在命令中使用的是 <ndk_dir>/toolchains/arm-linux-androideabi-4.9/prebuilt/windows-x86/
路径,但实际在x86版本的NDK目录中对比,其中只有 <ndk_dir>/toolchains/arm-linux-androideabi-4.9/prebuilt/windows
目录,不是 windows-x86
,将其重命名为 windows-x86
即可。
这可能是r11b-x86版本NDK的一个bug。
-x86目录即可。
这可能是r11b-x86版本NDK的一个问题。