这非常非常棘手。设置java.library.path 不起作用,但试图理解someone else’s Mac OSX approach 我最终找到了一个可行的解决方案。
合法发布:直接复制到这篇文章中的所有代码示例都可以在CC0 下找到,但我的雇主⮡ tarent,LLCTO 项目 Deutsche Telekom ,作者mirabilos。
注意事项首先:
-
有了这个,您正在测试针对您的 system 库编译的本机代码版本(通常是 GNU/Linux 上的 glibc,而在 BSD、Mac OSX 和 Windows 上则更加棘手),因此添加无论如何都应该进行一些仪器测试,仅使用单元测试来更快地测试实际上可以在主机操作系统上测试的东西
-
我只在 GNU/Linux 主机上对此进行了测试(事实上,我不包括在所有其他主机操作系统上进行的这些本机测试,见下文)
- 它应该可以在具有 GNU/BSD 样式共享库的 unixoid 操作系统下按原样运行
- 对上面链接的“别人的”文章进行了小幅改编,它可能适用于 Mac OSX
- Windows……不,只是不。使用 WSL,它基本上是 Linux,让事情变得更容易,并且更接近 Android,它也基本上是 Linux,而不是 GNU
-
IDE 集成需要在每个开发人员的计算机上执行手动步骤(但这些步骤很容易记录,见下文(很多))
先决条件
您需要确保所有本地代码的构建依赖项也安装在主机系统中。这包括cmake(因为我们很遗憾不能重用 NDK cmake)和主机 C 编译器。请注意,这些在构建中引入了进一步的差异:您正在测试使用宿主 C 编译器(通常是 GCC,不像 Android 中的 clang)针对宿主 C 库和宿主 clang 的其他库构建的东西。 请在编写测试时考虑这一点。由于无法在 glibc 下进行测试,因此我不得不将其中一项测试移至 Instrumented。
对于文件系统布局,我们假设如下:
-
~/MYPRJ/build.gradle 是顶层构建文件(由 IntelliJ / Android Studio 生成)
-
~/MYPRJ/app/build.gradle 是构建相关 Android 代码的位置(由 IntelliJ / Android Studio 生成)
-
~/MYPRJ/app/src/main/native/CMakeLists.txt 是本机代码所在的位置
这意味着build.gradle(对于应用程序)已经有了类似的东西,直到您开始怀疑您的项目是否可以进行单元测试:
externalNativeBuild {
cmake {
path "src/main/native/CMakeLists.txt"
return void // WTF‽
}
}
确保您的代码在主机上构建
乍一看应该很容易做到这一点:
$ rm -rf /tmp/build
$ mkdir /tmp/build
$ cd /tmp/build
$ cmake ~/MYPRJ/app/src/main/native/
$ make
(确保将cmake 的路径提供给CMakeLists.txt 主文件所在的目录,但不是文件本身!)
当然,这对于所有不平凡的事情都会失败。大多数人会使用 Android 日志记录。 (它也会失败,因为它找不到<jni.h>,并且因为GNU libc 需要一个额外的_GNU_SOURCE 定义来访问一些原型,等等……)
所以我写了一个包含而不是 <android/log.h> 的标题来抽象日志记录......
#ifndef MYPRJ_ALOG_H
#define MYPRJ_ALOG_H
#ifndef MYPRJ_ALOG_TAG
#define MYPRJ_ALOG_TAG "MYPRJ-JNI"
#endif
#if defined(MYPRJ_ALOG_TYPE) && (MYPRJ_ALOG_TYPE == 1)
#include <android/log.h>
#define ecnlog_err(msg, ...) __android_log_print(ANDROID_LOG_ERROR, \
MYPRJ_ALOG_TAG, msg, ##__VA_ARGS__)
#define ecnlog_warn(msg, ...) __android_log_print(ANDROID_LOG_WARN, \
MYPRJ_ALOG_TAG, msg, ##__VA_ARGS__)
#define ecnlog_info(msg, ...) __android_log_print(ANDROID_LOG_INFO, \
MYPRJ_ALOG_TAG, msg, ##__VA_ARGS__)
#elif defined(MYPRJ_ALOG_TYPE) && (MYPRJ_ALOG_TYPE == 2)
#include <stdio.h>
#define ecnlog_err(msg, ...) fprintf(stderr, \
"E: [" MYPRJ_ALOG_TAG "] " msg "\n", ##__VA_ARGS__)
#define ecnlog_warn(msg, ...) fprintf(stderr, \
"W: [" MYPRJ_ALOG_TAG "] " msg "\n", ##__VA_ARGS__)
#define ecnlog_info(msg, ...) fprintf(stderr, \
"I: [" MYPRJ_ALOG_TAG "] " msg "\n", ##__VA_ARGS__)
#else
# error What logging system to use?
#endif
#endif
...并更新了我的CMakeLists.txt 以指明是为 NDK 构建(必须为默认)还是原生:
cmake_minimum_required(VERSION 3.10)
project(myprj-native)
option(UNDER_NDK "Build under the Android NDK" ON)
add_compile_options(-fvisibility=hidden)
add_compile_options(-Wall -Wextra -Wformat)
add_library(myprj-native SHARED
alog.h
myprj-jni.c
)
if (UNDER_NDK)
add_definitions(-DECNBITS_ALOG_TYPE=1)
find_library(log-lib log)
target_link_libraries(myprj-native ${log-lib})
else (UNDER_NDK)
add_definitions(-DECNBITS_ALOG_TYPE=2)
include(FindJNI)
include_directories(${JNI_INCLUDE_DIRS})
add_definitions(-D_GNU_SOURCE)
endif (UNDER_NDK)
请注意,这还包括对 <jni.h> (FindJNI) 的修复和额外的定义。
现在让我们再次尝试构建它:
$ rm -rf /tmp/build
$ mkdir /tmp/build
$ cd /tmp/build
$ cmake -DUNDER_NDK=OFF ~/MYPRJ/app/src/main/native/
$ make
就我而言,这就足够了。如果您仍然不在那里,请先解决此问题,然后再继续。如果您无法解决此问题,请放弃针对您的 JNI 代码的 buildhost-local 单元测试,并将相应的测试移至 Instrumented。
让 Gradle 构建主机原生代码
将以下内容添加到应用程序build.gradle:
def dirForNativeNoNDK = project.layout.buildDirectory.get().dir("native-noNDK")
def srcForNativeNoNDK = project.layout.projectDirectory.dir("src/main/native").asFile
task createNativeNoNDK() {
def dstdir = dirForNativeNoNDK.asFile
if (!dstdir.exists()) dstdir.mkdirs()
}
task buildCMakeNativeNoNDK(type: Exec) {
dependsOn createNativeNoNDK
workingDir dirForNativeNoNDK
commandLine "/usr/bin/env", "cmake", "-DUNDER_NDK=OFF", srcForNativeNoNDK.absolutePath
}
task buildGMakeNativeNoNDK(type: Exec) {
dependsOn buildCMakeNativeNoNDK
workingDir dirForNativeNoNDK
commandLine "/usr/bin/env", "make"
}
project.afterEvaluate {
if (org.gradle.internal.os.OperatingSystem.current().isLinux()) {
testDebugUnitTest {
dependsOn buildGMakeNativeNoNDK
systemProperty "java.library.path", dirForNativeNoNDK.asFile.absolutePath + ":" + System.getProperty("java.library.path")
}
testReleaseUnitTest {
dependsOn buildGMakeNativeNoNDK
systemProperty "java.library.path", dirForNativeNoNDK.asFile.absolutePath + ":" + System.getProperty("java.library.path")
}
}
}
这定义了一些新任务来编译共享库的 buildhost-native 版本,如果主机操作系统是“Linux”,则将其挂钩。 (此语法也适用于其他 unixoid 操作系统 — BSD、Mac OSX — 但不适用于 Windows。但无论如何我们可能只能在 Linux 下测试它。WSL 算作 Linux。)它还设置了 JVM 库路径,以便@987654357 @ 将让 JVM 从其路径中获取库。
松散的末端
您可能已经注意到这里有一些松散的结局:
-
在上一节的最后一段中,我提到了../gradlew test 将拿起图书馆。 IDE 中的测试还不能工作;这涉及手动设置。
-
我提到如果buildhost OS不是“Linux”,则必须跳过相关的单元测试;我们还没有这样做。不幸的是,JUnit 4 缺乏这样的设施,但是将单元测试切换到 JUnit 5 “Jupiter” 将使我们能够做到这一点。 (不过,我们不会切换仪器测试;那样会更具侵入性。)
-
您可能还没有注意到,但是由于 Gradle 的默认设置,我们需要更改本机代码的日志记录输出。
那么,让我们这样做吧。首先,再次编辑您的应用程序build.gradle 文件。将有一个dependencies { 块。我们需要为任一 JUnit 填充合适的依赖项:
dependencies {
testImplementation 'org.junit.jupiter:junit-jupiter-api:5.7.0'
testRuntimeOnly 'org.junit.jupiter:junit-jupiter-engine:5.7.0'
//noinspection GradleDependency
androidTestImplementation 'com.android.support.test:runner:1.0.1'
//noinspection GradleDependency
androidTestImplementation 'com.android.support.test.espresso:espresso-core:3.0.1'
//noinspection GradleDependency
androidTestImplementation 'junit:junit:4.12'
}
顶部还有一行 apply plugin: 'com.android.application'(或者可能是 apply plugin: 'com.android.library')。在该行的正下方,插入这一行:
apply plugin: 'de.mannodermaus.android-junit5'
另外,确保android { defaultConfig { 下的testInstrumentationRunner 仍然是"android.support.test.runner.AndroidJUnitRunner"(由 IntelliJ / Android Studio 生成的默认值)。
接下来,编辑 顶级 ~/MYPRJ/build.gradle 文件。您已经有一个 buildscript { dependencies { 并且必须在该部分添加一行以首先使 JUnit5 插件可用:
//noinspection GradleDependency
classpath 'de.mannodermaus.gradle.plugins:android-junit5:1.5.2.0'
然后,在allprojects { 下添加一个新部分:
tasks.withType(Test) {
testLogging {
outputs.upToDateWhen { false }
showStandardStreams = true
exceptionFormat = 'full'
}
systemProperty 'java.util.logging.config.file', file('src/test/resources/logging.properties').getAbsolutePath()
}
这样可以确保……
现在看看你的测试,比如~/MYPRJ/app/src/test/java/org/example/packagename/JNITest.java。首先,您应该添加一个可以始终运行的“测试”(我使用的只是测试我的 JNI 类是否可以加载),并确保它首先显示一些信息:
// or Lombok @Log
private static final java.util.logging.Logger LOGGER = java.util.logging.Logger.getLogger(JNITest.class.getName());
@Test
public void testClassBoots() {
LOGGER.info("running on " + System.getProperty("os.name"));
if (!LINUX.isCurrentOs()) {
LOGGER.warning("skipping JNI tests");
}
// for copy/paste into IntelliJ run options
LOGGER.info("VM options: -Djava.library.path=" +
System.getProperty("java.library.path"));
LOGGER.info("testing Java™ part of JNI class…");
[…]
}
然后,注释需要在其他操作系统上跳过的实际 JNI 测试:
@Test
@EnabledOnOs(LINUX)
public void testJNIBoots() {
LOGGER.info("testing JNI part of JNI class…");
final long tid;
try {
tid = JNI.n_gettid();
} catch (Throwable t) {
LOGGER.log(Level.SEVERE, "it failed", t);
Assertions.fail("JNI does not work");
return;
}
LOGGER.info("it also works: " + tid);
assertNotEquals(0, tid, "but is 0");
}
为了比较,插桩测试(在 Android 设备或模拟器上运行的单元测试) — 例如~/MYPRJ/app/src/androidTest/java/org/example/packagename/JNIInstrumentedTest.java — 看起来像这样:
@RunWith(AndroidJUnit4.class)
public class JNIInstrumentedTest {
@Test
public void testJNIBoots() {
Log.i("ECN-Bits-JNITest", "testing JNI part of JNI class…");
final long tid;
try {
tid = JNI.n_gettid();
} catch (Throwable t) {
Log.e("ECN-Bits-JNITest", "it failed", t);
fail("JNI does not work");
return;
}
Log.i("ECN-Bits-JNITest", "it also works: " + tid);
assertNotEquals("but is 0", 0, tid);
}
}
顺便说一句,如果您需要 assertThrows 进行插桩测试(JUnit 5 已经自带),请参阅 Testable.java。 (请注意,这不属于上述 CC0 授权,而是属于许可许可。)
现在,您可以同时运行测试、单元测试和(如果 Android 模拟器已启动或设备已启动)插桩测试:
../gradlew test connectedAndroidTest
这样做。注意来自 buildhost-native 单元测试的 VM options: logger 调用的输出;实际上,将其复制到剪贴板。您现在需要它来在 IDE 中设置测试。
在项目视图(左侧树)中,右键单击您的 JNITest 类或整个 src/test/java/ 目录。点击Run 'JNITest'(或Run 'Tests in 'java''),它会像原帖一样显示UnsatisfiedLinkError。
现在单击菜单栏下方测试下拉菜单中的箭头,然后选择Save JNITest configuration,然后再次执行此操作并选择Edit configurations… 并选择您的配置。将整个粘贴的内容附加到VM options:,因此该字段现在看起来像-ea -Djava.library.path=/home/USER/MYPRJ/app/build/native-noNDK:/usr/java/packages/lib:/usr/lib/x86_64-linux-gnu/jni:/lib/x86_64-linux-gnu:/usr/lib/x86_64-linux-gnu:/usr/lib/jni:/lib:/usr/lib(当然,实际值会有所不同),然后单击确定。重新运行测试,它会成功。
不幸的是,您必须为每个本机测试类以及整个目录执行一次此操作,因此将涵盖所有可能的调用方式。对于每个 IDE 实例,您还必须通过单击来手动执行此操作,并且这些值取决于代码签出到的路径。我还没有找到一种方法来自动化这些(如果你知道的话,请告诉我)。
异常回溯
如果您从代码中抛出自定义异常,您很可能希望包含文件/行号/函数信息。使用MyprjNativeException(final String file, final int line, final String func, final String msg, … /* custom data */, final Throwable cause) 之类的构造函数,并在调用super(msg, cause)(可能带有更改的消息)之后,执行以下操作:
StackTraceElement[] currentStack = getStackTrace();
StackTraceElement[] newStack = new StackTraceElement[currentStack.length + 1];
System.arraycopy(currentStack, 0, newStack, 1, currentStack.length);
newStack[0] = new StackTraceElement("<native>", func, file, line);
setStackTrace(newStack);
然后,从本机代码中抛出这样的异常:
#define throw(env,...) vthrow(__FILE__, __func__, env, __LINE__, __VA_ARGS__)
static void vthrow(const char *loc_file, const char *loc_func, JNIEnv *env,
int loc_line, /* custom args */ const char *msg, ...);
如下使用:
if (func() != expected)
throw(env, /* custom args */ "foo");
实现(假设您缓存类和构造函数方法引用)如下所示(针对自定义参数进行调整):
static void vthrow(const char *loc_file, const char *loc_func, JNIEnv *env,
int loc_line, const char *fmt, ...)
{
jthrowable e;
va_list ap;
jstring jfile = NULL;
jint jline = loc_line;
jstring jfunc = NULL;
jstring jmsg = NULL;
jthrowable cause = NULL;
const char *msg;
char *msgbuf;
if ((*env)->PushLocalFrame(env, /* adjust for amount you need */ 5)) {
cause = (*env)->ExceptionOccurred(env);
(*env)->ExceptionClear(env);
(*env)->Throw(env, (*env)->NewObject(env, classreference, constructorreference,
jfile, jline, jfunc, jmsg, /* custom */…, cause));
return;
}
if ((cause = (*env)->ExceptionOccurred(env))) {
/* will be treated as cause */
(*env)->ExceptionClear(env);
}
va_start(ap, fmt);
if (vasprintf(&msgbuf, fmt, ap) == -1) {
msgbuf = NULL;
msg = fmt;
} else
msg = msgbuf;
va_end(ap);
jmsg = (*env)->NewStringUTF(env, msg);
free(msgbuf);
if (!jmsg)
goto onStringError;
if (!(jfunc = (*env)->NewStringUTF(env, loc_func)))
goto onStringError;
/* allocate NewStringUTF for any custom things you need */
/* exactly like the one for loc_func above */
/* increase PushLocalFrame argument for each */
jfile = (*env)->NewStringUTF(env, loc_file);
if (!jfile) {
onStringError:
(*env)->ExceptionClear(env);
}
e = (*env)->PopLocalFrame(env, (*env)->NewObject(env, classreference, constructorreference,
jfile, jline, jfunc, jmsg, /* custom */…, cause));
if (e)
(*env)->Throw(env, e);
}
现在使用__FILE__ 会将完整的绝对路径放入消息和回溯中。这不是很好。有一个编译器选项可以解决这个问题,但是 NDK r21 的 clang 太旧了,所以我们需要一个解决方法。
CMakeLists.txt:
if (NOT TOPLEV)
message(FATAL_ERROR "setting the top-level directory is mandatory")
endif (NOT TOPLEV)
[…]
if (UNDER_NDK)
[…]
execute_process(COMMAND ${CMAKE_CXX_COMPILER} --version OUTPUT_VARIABLE cxx_version_full)
string(REGEX REPLACE "^Android [^\n]* clang version ([0-9]+)\\.[0-9].*$" "\\1" cxx_version_major ${cxx_version_full})
if (${cxx_version_major} VERSION_GREATER_EQUAL 10)
add_definitions("-ffile-prefix-map=${TOPLEV}=«MyPrj»")
else (${cxx_version_major} VERSION_GREATER_EQUAL 10)
add_definitions(-DOLD_CLANG_SRCDIR_HACK="${TOPLEV}/")
endif (${cxx_version_major} VERSION_GREATER_EQUAL 10)
else (UNDER_NDK)
[…]
add_definitions("-ffile-prefix-map=${TOPLEV}=«MyPrj»")
endif (UNDER_NDK)
应用build.gradle:
(紧跟在应用插件行之后)
def dirToplev = project.layout.projectDirectory.asFile.absolutePath
(在android { defaultConfig {内添加一个新块)
externalNativeBuild {
cmake {
//noinspection GroovyAssignabilityCheck because Gradle and the IDE have different world views…
arguments "-DTOPLEV=" + dirToplev
}
return void // WTF‽
}
(稍后,你调用 cmake 的地方)
commandLine "/usr/bin/env", "cmake", "-DTOPLEV=" + dirToplev, "-DUNDER_NDK=OFF", srcForNativeNoNDK.absolutePath
然后,将jfile = (*env)->NewStringUTF(env, loc_file); 行替换为以下sn-p:
#ifdef OLD_CLANG_SRCDIR_HACK
if (!strncmp(loc_file, OLD_CLANG_SRCDIR_HACK, sizeof(OLD_CLANG_SRCDIR_HACK) - 1) &&
asprintf(&msgbuf, "«ECN-Bits»/%s", loc_file + sizeof(OLD_CLANG_SRCDIR_HACK) - 1) != -1) {
msg = msgbuf;
} else {
msg = loc_file;
msgbuf = NULL;
}
#else
#define msg loc_file
#endif
jfile = (*env)->NewStringUTF(env, msg);
#ifdef OLD_CLANG_SRCDIR_HACK
free(msgbuf);
#else
#undef msg
#endif
将它们结合在一起
这一切都在ECN-Bits 项目中实现。我发布了一个永久链接,因为它目前在一个非默认分支上,但预计会被合并(一旦 actual 功能不再是 WIP),所以一定要在某个时候检查master时间也是如此(尽管这个永久链接可能是一个更好的例子,因为它已经进行了测试,并且没有那么多“实际”代码会妨碍)。请注意,这些链接不属于上述 CC0 授权;虽然这些文件都有一个许可许可证(没有明确的文件(gradle/cmake 文件)与 unittest 类的永久链接相同),但是在本文中重新发布了足够多的文件,所以这应该不是问题为你;这些仅用于显示实际编译和测试的示例。
在这个项目中,它不在app/ 中,而是作为一个单独的库模块。