笔者之前已经写了好多关于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-v8a
、armeabi-v7a
、x86
、x86_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.out
、Log.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.o
和libjni.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底层逻辑测试最后一个短板。赶紧加速你写代码速度吧!