Android NDK单元测试

Android NDK单元测试_第1张图片

笔者之前已经写了好多关于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 

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");
    }
}

结果:

Android NDK单元测试_第2张图片

测试跑通了。
同学们留意笔者在最后 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


Android NDK单元测试_第3张图片

Android NDK单元测试_第4张图片

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

Junit+JNI


Android NDK单元测试_第5张图片

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底层逻辑测试最后一个短板。赶紧加速你写代码速度吧!

你可能感兴趣的:(Android NDK单元测试)