Android NDK单元测试


笔者之前已经写了好多关于Android Java单元测试的文章,但NDK单元测一直没写。最近总算决心搞一下。

首先,笔者对单元测试追求编译、运行速度,尽量在jvm上跑。C++单元测试探索:

1.Junit
2.AndroidJUnit
3.Robolectric
4.Googletest

示例:

package com.example.ndk;

public class JNI {
    public native int add(int a, int b);
}

jni.cpp放在app/src/main/cpp/目录下:

#include <jni.h>

extern "C"
JNIEXPORT jint JNICALL
Java_com_example_ndk_JNI_add(JNIEnv *env, jobject instance, jint a, jint b) {
    return a + b;
}

工程部分目录结构

./
├── app
│   └── src
│       ├── androidTest
│       │   └── java
│       │       └── com
│       │           └── example
│       │               └── ndk
│       │                   └── TestJNI.java
│       ├── main
│       │   ├── AndroidManifest.xml
│       │   ├── cpp
│       │   │   ├── CMakeLists.txt
│       │   │   └── jni.cpp
│       │   └── java
│       │       └── com
│       │           └── example
│       │               └── ndk
│       │                   ├── JNI.java
│       │                   └── MainActivity.java
│       └── test
│           └── java
│               └── com
│                   └── example
│                       └── ndk
│                           └── JVMTestJNI.java

Junit

Junit就是java单元测试框架,要测C++要运用到JNI技术,也是本文详细讨论的技术点。

我们先按正常思路写一下测试用例JVMTestJNI(在app/src/test/java/目录下):

public class JVMTestJNI {
    @Test
    public void add() {
        System.loadLibrary("jni");

        JNI jni = new JNI();
        Assert.assertEquals(2, jni.add(1, 1));
    }
}

必然会报错:

java.lang.UnsatisfiedLinkError: no jni in java.library.path

因为System.loadLibrary(...)会从java.library.path环境变量指向目录,加载动态链接库(so、dylib等文件)。我们输出一下java.library.path:

public class JVMTestJNI {
    @Test
    public void printJavaLibraryPath(){
        System.out.println(System.getProperty("java.library.path"));
    }
}

结果:

/Users/kkmike999/Library/Java/Extensions:/Library/Java/Extensions:/Network/Library/Java/Extensions:/System/Library/Java/Extensions:/usr/lib/java:.

而project编译生成的so文件,在app/build/intermediates/cmake/debug/obj/{platform}/libjni.so. ({platform}有4个:arm64-v8aarmeabi-v7ax86x86_64). java.library.path没有引用该目录。

我们常识指定so文件绝对路径(一定要绝对路径,不能是相对路径)加载(轮流试4个platform):

public class JVMTestJNI {
    @Test
    public void add() {
        File file = new File("build/intermediates/cmake/debug/obj/x86_64/libjni.so");
        System.load(file.getAbsolutePath());

        JNI jni = new JNI();
        Assert.assertEquals(2, jni.add(1, 1));
    }
}

Run一下....嗯....也是报错:

java.lang.UnsatisfiedLinkError: .../NdkDemo/app/build/intermediates/cmake/debug/obj/x86_64/libjni.so: dlopen(.../NdkDemo/app/build/intermediates/cmake/debug/obj/x86_64/libjni.so, 1): no suitable image found.  Did find:
    .../NdkDemo/app/build/intermediates/cmake/debug/obj/x86_64/libjni.so: unknown file type, first eight bytes: 0x7F 0x45 0x4C 0x46 0x02 0x01 0x01 0x00
    .../NdkDemo/app/build/intermediates/cmake/debug/obj/x86_64/libjni.so: unknown file type, first eight bytes: 0x7F 0x45 0x4C 0x46 0x02 0x01 0x01 0x00

ndk编译出来的so文件,并不适用于macOS和windows,macOS需要dylib文件,而windows需要dll. 假如你用Linux系统,估计x86_64/libjni.so可以适用。

假如要在macOS或windows使用动态链接库,必须重新编译cpp文件。编译步骤我在下文会详细讲解。

动态链接库文件,是一种不可执行的二进制程序文件,它允许程序共享执行特殊任务所必需的代码和其他资源。 Windows提供的DLL文件中包含了允许基于Windows的程序在Windows环境下操作的许多函数和资源。 一般被存放在C:视窗系统System目录下。

AndroidJunit

AndroidJunit是google本身提供的android单元测试方式。因为代码跑在真机android环境上,所以跟app运行在真机上调用native方法没区别,也是相对简单的一种方式。

Run一下测试用例TestJNI(app/src/androidTest/java/目录下):

public class TestJNI {
    @Test
    public void add() {
        System.loadLibrary("jni");

        JNI jni = new JNI();
        Assert.assertEquals(2, jni.add(1, 1));

        System.out.println("Hello World");
    }
}

结果:


测试跑通了。
同学们留意笔者在最后System.out.println("Hello World"),但在测试结果并没有看到Hello World。我们看看Logcat:

有点小遗憾,AndroidJunit不能直接在Run窗口查看输出流(System.outLog.d等),而需要在Logcat查看。我相信同学们都深有体会Logcat不太稳定,包括笔者也很烦这个问题。

Robolectric

很遗憾Robolectric不支持加载so文件,原因跟上面Junit总结的一样,因为Robolectric也是跑在JVM上。

GoogleTest

GoogleTest是Google官网提供的专门的C++单元测试框架。用起来比较麻烦,要用C++写单元测,而不是java调用库文件,并且要adb push到真机上运行,而不是IDE自带工具完成测试。

有兴趣的可以参考《Android Cmake 配置 Googletest 单元测试》

结论1

根据以上讲解,现成、无成本最适合普通android工程师使用的C++单元测试,就是androidTest.


Junit+JNI做C++单元测试

尽管上面已经有初步结论,用androidTest最方便,但还未分析过Junit+JNI的可能性。接下来我们探讨一下。

在编程领域,JNI (Java Native Interface,Java本地接口)是一种编程框架,使得Java虚拟机中的Java程序可以调用本地应用/或库,也可以被其他程序调用。 本地程序一般是用其它语言(C、C++或汇编语言等)编写的,并且被编译为基于本机硬件和操作系统的程序。

本地库就是在上文Junit一节介绍的动态链接库。我们可以编译cpp文件,生成适合macOS和windows的动态链接库。本文着重介绍macOS编译C.

编译cpp生成dylib

macOS使用的动态链接库格式是dylib,因此在macOS上jvm只能调用dylib。我们把cpp编译成dylib,再加载该库即可。

(请先安装gcc、配置好JAVA_HOME环境变量)在app目录新建一个make_macOS_dylib.sh:

#!/usr/bin/env bash
# 指定动态库名称(即cpp文件名)
name=jni
# 指定cpp目录
INTPUT=./src/main/cpp/ 
# 指定dylib输出目录
OUTPUT=./build/dylibs
mkdir -p ${OUTPUT}

# cpp编译成.o file
cc -c \
-I$JAVA_HOME/include/darwin \
-I$JAVA_HOME/include/ \
${INTPUT}/${name}.cpp \
-o ${OUTPUT}/lib${name}.o

# .o编译成.dylib file
g++ -dynamiclib -undefined suppress -flat_namespace ${OUTPUT}/*.o -o ${OUTPUT}/lib${name}.dylib

echo "生成dylib:"${OUTPUT}/lib${name}.dylib

再在命令行执行:

cd ./app
sh ./make_macOS_dylib.sh

编译过程输出:

输入cpp文件名
生成dylib:./build/dylibs/libjni.dylib

app/build/dylibs/目录下生成了libjni.olibjni.dylib,jni用libjni.dylib即可

.
├── build
│   ├── dylibs
│   │   ├── libjni.dylib
│   │   └── libjni.o
...
├── make_macOS_dylib.sh
...

JNI加载dylib

System.load

上文讲Junit时提过,System.load()加载动态库要传入绝对路径

public class JVMTestJNI {
    @Test
    public void add() throws Exception {
        File file = new File("build/dylibs/libjni.dylib");
        System.load(file.getAbsolutePath());

        JNI jni = new JNI();
        Assert.assertEquals(2, jni.add(1, 1));

        System.out.println("测试成功");
    }
}

运行结果:

测试成功

Process finished with exit code 0

System.loadLibrary

System.loadLibrary()会从环境变量java.library.path指向目录,寻找合适的动态库。上文提到原本的java.library.path没有本工程的目录,因此我们要自己加上去:

public class JVMTestJNI {
    @Before
    public void setUp() throws Exception {
        File   dylibsDir   = new File("build/dylibs/");
        String libraryPath = dylibsDir.getAbsolutePath() + ":" + System.getProperty("java.library.path");
        System.setProperty("java.library.path", libraryPath);

        Field fieldSysPath = ClassLoader.class.getDeclaredField("sys_paths");
        fieldSysPath.setAccessible(true);
        fieldSysPath.set(null, null);
    }

    @Test
    public void add() throws Exception {
        System.loadLibrary("jni");

        JNI jni = new JNI();
        Assert.assertEquals(2, jni.add(1, 1));

        System.out.println("测试成功");
    }
}

运行结果:

测试成功

Process finished with exit code 0

改进:Java调用命令行编译

写一个命令行工具类:

public class ShellUtils {

    public static void exec(String commend) throws IOException, InterruptedException {
        Process proc = Runtime.getRuntime().exec(commend);

        InputStream in = proc.getInputStream();

        BufferedReader br = new BufferedReader(new InputStreamReader(in, "UTF-8"));
        String         line;

        while ((line = br.readLine()) != null) {
            System.out.println(line);
        }

        // 读取标准错误流
        if (proc.getErrorStream() != null) {
            BufferedReader brError = new BufferedReader(new InputStreamReader(proc.getErrorStream(), "UTF-8"));
            String         errline;
            while ((errline = brError.readLine()) != null) {
                System.err.println(errline);
            }
        }
    }
}

在测试用例调用System.loadLibrary前加上ShellUtils.exec("/bin/sh make_macOS_dylib.sh")进行编译即可:

    @Test
    public void add() throws Exception {
        ShellUtils.exec("/bin/sh make_macOS_dylib.sh");

        System.loadLibrary("jni");

        JNI jni = new JNI();
        Assert.assertEquals(2, jni.add(1, 1));

        System.out.println("测试成功");
    }

运行结果:

生成dylib:./build/dylibs/libjni.dylib
测试成功

Process finished with exit code 0

对比:JNI和AndroidTest

对比运行时间:

AndroidTest




在所有代码都编译好,直接运行测试,从编译到push到真机到运行,总共用了1s+。同学们应该以实测时间为准,笔者每次运行的时间都不太一样,有时是几秒。

Junit+JNI


Junit就好快了,只需要500+ms,这里编译dylib占大部分时间。如果事先编译好dylib,junit仅需要10ms不到。

从运行时间上看,Junit比AndroidTest要快,不过几秒内的差距,不会影响太大。

对比自动化程度

AndroidTest
代码跟普通单元测试没区别,不需要额外配置,非常方便。

Junit+JNI
需要写脚本,测试用例还要加点代码。
其实可以把编译cpp做得更自动化,例如从.externalNativeBuild目录下找到gradle编译so的log,然后自动发现需要编译的cpp进行编译。这里不展开了,有兴趣的同学可以试试。

在这点上,AndroidTest比Junit+JNI好用。但AndroidTest必须连真机,如果你刚好没测试机在手,嗯......

YuiHatano开源库

笔者的Android单元测试开源库YuiHatano已经支持MacOS上native方法单元测试。

用法非常简单,仅需要让测试用例继承JNICase即可。

public class TestJNI extends JNICase {

    static {
        System.loadLibrary("jni");
    }

    @Test
    public void testJNI() {
        JNI jni = new JNI();
        Assert.assertEquals(2, jni.add(1, 1));
    }
}

前提也是测试的module有cpp源文件,详情查看github 文档介绍。


写在最后

学会C++单元测试,就补上android底层逻辑测试最后一个短板。赶紧加速你写代码速度吧!

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 202,056评论 5 474
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 84,842评论 2 378
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 148,938评论 0 335
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,296评论 1 272
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,292评论 5 363
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,413评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,824评论 3 393
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,493评论 0 256
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,686评论 1 295
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,502评论 2 318
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,553评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,281评论 4 318
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,820评论 3 305
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,873评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,109评论 1 258
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 42,699评论 2 348
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,257评论 2 341