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。编译项目并运行,就可以看到结果了。

源码可以点击这里下载

Mingw + CMake + SDL2开发环境搭建

1. 安装 Mingw

如果已经安装有Mingw则可跳过此节。

这里推荐 TDM-GCC 的版本,在 Download 页面选择下载在线安装包 tdm-gcc-webdl.exe

运行在线安装包,默认选项一路Next。

安装完成后建议新打开一个命令行窗口,输入gcc --version 来检查Mingw是否已经在PATH路径中。

2. 安装 CMake

去CMake下载页面,选择 Latest Release 中的 Windows (Win32 Installer) 右侧的exe文件下载,安装时在 Install Options 界面,选择第2项 Add CMake to the system PATH for all user 或者第3项。

3. 安装 SDL2 开发库

本节参考 Setting up SDL 2 on MinGW

去SDL下载页面,选择下方的 Development Libraries 的 SDL2-devel-2.x.x-mingw.tar.gz (MinGW 32/64-bit)(当前版本是 2.0.3),下载完成后解压。

复制解压出的文件夹中的i686-w64-mingw32文件夹到某个位置,这里以D:\根目录为例,然后重命名 i686-w64-mingw32 为 mingw_dev_lib。以后类似其他的开发库也可以放在这里,便于集中管理。

SDL2.0.3的版本中有一个头文件依赖的bug,点此下载SDL_platform.h 覆盖掉目录include\SDL2中的同名文件[*]

4. SDL2 例子测试

下载压缩包 helloworld.zip 并解压,解压后文件夹应该有3个文件,分别是 CMakeLists.txtles1_hello.bmp 和 main.cpp,这里如果SDL2库安装的位置不是D:\mingw_dev_lib,则需要修改 CMakeLists.txt 中对应的路径为指定位置。

在解压后的文件夹中打开命令行窗口(Win7下Shift+鼠标右键,选择在此处打开命令窗口),
依次输入命令

mkdir build && cd build
cmake -G "MinGW Makefiles" ..
mingw32-make

如果没有任何报错,那么在文件夹中的 build 文件夹下会出现一个 main.exe,双击它然后你应该可以看到一个 Hello world 的界面。

至此,SDL2 的开发环境搭建完成。

link_directories位置问题

昨天在Win上整SDL2的编译环境,遇到一个问题。

用的编译器是MinGW(TDM-GCC),因为不想手写Makefile,所以构建工具用的是CMake。在安装好SDL2的头文件和库后,下载了一个示例代码测试。编写CMakeLists.txt

cmake_minimum_required(VERSION 2.6)
project(hellosdl)
aux_source_directory(. DIR_SRCS)
include_directories("D:/mingw_dev_lib/include")
add_definitions("-Wall")
add_executable(main ${DIR_SRCS})
link_directories("D:/mingw_dev_lib/lib")
target_link_libraries(main mingw32 SDL2main SDL2)

mkdir build && cd buildcmake -G "MinGW Makefiles" ..之后mingw32-make,结果链接器ld报错,cannot find -lSDL2maincannot find -lSDL2

应该是链接器找不到SDL2的库文件。但是明明已经添加了link_directories了。

查找link_directories文档,赫然写着

The command will apply only to targets created after it is called.

link_directories这句放到add_executable之前就可以了。

PS: 其实可以通过查看build目录下CMakeFiles\main.dir中的link.txt文件中来确定最终链接器的参数。可以对比下修正前后该文件的内容。

PS2: 其实开始我是通过对比link.txt的文件内容才知道link_directories没有起作用,没有去查阅文档而是直接谷歌。 虽然最后问题解决了,但还是感觉有点2啊(┬_┬)。

C文件读写一个盲点

《C陷阱与缺陷》第5.2节说道

为了保持与过去不能同时进行读写操作的程序的向下兼容性,一个输入操作不能随后直接紧跟一个输出操作,反之亦然。如果要同时进行输入和输出操作,必须在其中插入fseek函数的调用。

这里指以读+写模式打开的文件,既要进行读操作,还要进行写操作时需要注意的。

那么原因是什么?这个“向下兼容性”是指什么?

想看英文的直接戳这里 why fseek or fflush is always required between reading and writing in the read/write “+” modes

解释说:文件流的实现中可能会有单独的输入缓冲区和输出的缓冲区,分别用于fread 和 fwrite两类操作的缓冲优化。当一个fread操作紧跟一个fwrite操作时,逻辑上,fwrite操作之前必须要保证输入缓冲区中的内容已经被处理完毕(缓冲区的内容都提交给应用程序处理,即缓冲区为空),否则fwrite操作后,缓存与实际就不同步了(试想一下接下来调用fread,因为输入缓冲区残留有上次的内容,那么这次读取的就不是当前位置的文件内容了)。但是fread操作后紧跟另外一个fread操作就不需要这种保证(缓冲区的意义所在)。反之亦然。

这种保证,在实际文件流实现中,如果每次fread/fwrite都去检查并刷新缓冲区(fread检查输出缓冲区,fwrite检查输入缓冲区),会导致实现代码变的复杂,而且也会降低性能。并且只有在复合模式(可以同时读入和写出)下才需要这种检查。只读和只写则无需考虑。

所以标准库把这种只在某些情况下才需要做的工作留给了库的使用者。我觉得这挺符合C的principle的

那么,其他高级语言有这个问题吗?比如CPython,Ruby MRI等,这些依赖C实现的。

答案是有。

Python: Mixing read() and write() on Python files in Windows

Ruby: ruby read and write/change the same file

但是在CPython和Ruby MRI自带文档中都没有找到这样的提示,依赖C实现,也是没有太多注意吧。

编写国际化软件的一些提示

笔记,摘自 Internationalization Tips,基于国际化工具 GNU gettext

1. 不要分割整句

例如

String s = i18n("Press OK to delete ") + n + i18n(" files");

这样把一句话拆成几部分,在抽取字符串资源后,翻译人员在看到 " files" 这样的字符串时会莫名其妙,翻译到其他语言里很有可能意思不伦不类,表达不清。而且 像 " files" 这样短的词会整个程序中出现多次,并不能保证在每个语境下都是同一个 意思。

这种情况下应该使用占位符

printf(i18n("Press OK to delete %d files"), n);

这里还提到了一个C语言里的技巧:编译时于预处理器会将连续多行的字符串字面量 自动拼接,合并成一个,这样在写很长的字符串时,可以拆成多行来写。

2. 避免同时使用多个占位符

例如

printf("Press OK to %s %d file(s)", action, number);

像这样一条句子里使用了两个占位符,由于后面的参数位置写死在代码里,在翻译时,翻 译字符串中的占位符也必须依照参数的顺序,在某些语言习惯里,可能造成翻译困难或者 不够地道。比如

printf("You should say %s when %s comes.", hello, somebody);

当翻译成中文时

printf("当%s来的时候,你得说%s", somebody, hello);

后面的参数需要调整顺序。

由于C语言的 printf 族函数并不提供类似 C#、Python 那种 {0} 带序号的占位符用法,所以使用多个占位符不是明智之举。虽然标准库不支持,但是有一 些第三方库可以做到,例如 win32 下的 FormatMessage 和 java 的 MessageFormat ,还可以考虑 Qt。

其实个人觉得 Ruby 中的变量名占位符更好,翻译时候翻译人员可以根据有意义的变量名 来翻译,而不是数字。

当然,和作者再三强调的一样,最好的方法就是避免使用多个占位符。

3. 多复数处理

在某些语言中,单数和复数表达可能不一样。

例如

printf(i18n("You have won %d point(s)!"), n);

n == 1 时,输出 "1 points" 就欠妥当,最好修改成

if (n == 1)
    printf(i18n("You have won one point!"));
else
    printf(i18n("You have won %d points!"), n);

注意,这还没完,并不是所有语言就仅仅区分 n == 1n != 1 这两种情况, 有些语言中,当 n == 101 时,也应当使用单数。

用硬编码来处理所有这些情行则会把代码弄得臃肿不堪,gettext 则提供了解决方法:ngettext

4. 翻译歧义

在编码时要注意多义词的处理。比如英文单词 right ,表示方向时是 的意思, 在表示对错时是 的意思。这个时候就需要给字符串加上前缀来以示区别。

作者给了个菜单项的例子,使用 | 来作为前缀

Menu|File
Menu|Printer
Menu|File|Open
Menu|File|New
Menu|Printer|Select
Menu|Printer|Open

然后使用一个新函数来查找翻译字符串

char * sgettext (const char *msgid)
{
    char *msgval = gettext (msgid);
    if (msgval == msgid)
        msgval = strrchr (msgid, '|') + 1;
    return msgval;
}

这个方法有几个缺点

*   包含了 `|` 字符的字符串不能这样来处理
*   翻译人员必须清楚只有 `|` 字符之后的字符串才是翻译的内容
*   开发者也许只用 `sgettext` 函数,并没有注意到字符串是否包含有 `|`

从这点上看,个人认为 MSVS 那套字符串也是有优势的。