KEEP K.I.S.S.

tk's blog

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.htsmath.calgorithm.halgorithm.cAndroid.mk 以及 Application.mk 文件。

其中,tsmath.htsmath.c 是 so 库的实现文件,algorithm.halgorithm.c 是实际算法实现,相对来说 tsmath.c 是 JNI 接口层,而 algorithm.c 则是实际的算法C代码。这样区分有助于代码逻辑分层,当然都写在 tsmath.c 中也是可行的。

Android.mkApplication.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_versionnative_calc_centerTSMath 的 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_versionnative_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。这些信息到后面获取/设置类对象字段时会用到,用全局结构体保存这些信息是为了效率,对于 FieldIDMethodID 来说,一旦类初始化后就不再变化,如果每次需要获取类对象信息时都去调用 GetFieldIDGetMethodID ,会给 JVM 带来负担,而且代码也有冗余。这里需要注意的是类句柄,对于 ICS4.0 以后的安卓系统,内存中的句柄可能会因为内存整理而移动,这意味着类句柄是会变化的,需要使用函数 NewGlobalRef 来保证句柄不变。

函数 helper_init_tscoord_jniinfo 用于获取类信息,这个函数需要在 JNI_OnLoad 中调用,保证在函数被调用前初始化全局信息。

函数 helper_new_tscoordhelper_get_tscoordhelper_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.javaTSMath.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的一个问题。