Native dependency in Kotlin/Multiplatform — part 2: JNI for JVM & Android

Salomon BRYS
Kodein Koders
Published in
5 min readJun 29, 2020
Photo by Angela Compagnone

Other article in this serie:

In the first part, we discussed an architecture that will allows us to use a native library on a Kotlin/Multiplatform project, and implement a common interface on each platform. Today, we’ll have a look at building, implementing and testing on Android & the JVM.

First, we need to compile the library that we’re going to use for the JVM & Android. We’ll need to compile it as a static library. Luckily, the former is very simple : just compile on your system as you normally would (using the build system of your choice). You can very simply automate its build on your Gradle build. The simplest way is to create a shell script that builds the library:

And then call it from Gradle:

Compiling the library on your system only makes it for your operating system. If you’re supporting the JVM only for test purposes, then it’s OK: each tester will automatically build its own version. If, however, you plan on releasing the JVM version, then you need a way to build for at least Windows, MacOS & Linux.
You could use CI automation to run a build on each targeted platform, or you could use cross-build docker images, such as DockCross or CrossBuild. The result is the same: you need a static library (.a file) compiled for each targeted JVM OS.

To compile a static library for Android, you first need to download and install the NDK (which you can download with Android Studio or here). It provides several ways to cross-compile for all supported Android architectures (namely x86_64, x86, arm64-v8a and armeabi-v7a).
The Android NDK provides instructions to cross-compile with CMake, or with other build systems (such as Autotools). You can also find documentation on the Rust website to configure Cargo to build Rust on Android.

Do not re-create a CMake specifically for Android! Use, as much as possible the build system provided for the library you are compiling.

The library’s build system configuration matters, and even if the library compiles with a “ home-made” build configuration, it may not behave as expected, or as efficiently as hoped.

JNI is an “old” layer of configuration, with very few automation and a lot of boilerplate. To write a JNI layer, you need to write both Kotlin & C (or C++).

For the rest of this article, we'll use the Base64 example we discussed in part 1.

We’ll start by writing the Kotlin facade that conforms to the common interface we defined in part 1 and that delegates the work to the native JNI layer:

Nothing much to say: it simply says that both required methods are external, which means they will be implemented in native JNI.
Here is the encode method implementation (the decode method follows the exact same pattern):

Note: I use the C++ JNI API, there’s also a C API that’s very close. You can use either with no performance impact.

The signature of the function is defined by the JNI specification.

The JDK used to ship a tool named javah that would generate a C/C++ header for you based on JVM bytecode (therefore, compatible with Kotlin). It was deprecated in JDK 9 and removed in subsequent releases in favour of javac that only works on Java source code (therefore, incompatible with Kotlin). I like to keep a JDK 8 installed on my development machine, mainly to use that tool.

Note that I use GetPrimitiveArrayCritical instead of GetBytesArrayElements, which may (or may not) be faster, but can only be used if the code between it and ReleasePrimitiveArrayCritical conforms to certain constraints.

Creating simple JNI code is easy. Creating optimized JNI code is hard.

You need to understand how the JVM works, how C memory works, and make best possible use JNI functions.
Also note that calling a native JNI methods carries an overhead that needs to be taken into account when designing high performance systems.

Compiling the JNI for desktop JVMs is very straightforward. There are multiple build systems that supports it. I like to use CMake:

Now, the annoying thing about standard JVMs is that there is no standard way to package a native library with the bytecode in a jar file. If you don’t plan on distributing for desktop JVMs and only want to use it for testing, then you don’t need to handle distribution. If you do, however, there is no standard way of doing it. One “classic” scheme is to package the native libraries inside the jar as a resource, and extract it when needed. The exact implementation of the extraction (Do you re-extract every time? Where do you extract? Do you check the extracted file?) as well as the distribution itself (Do you package all libraries in one jar or create on jar for each target?) is left for you to figure out and implement.
You still need to inform the Kotlin/Multiplatform Gradle plugin to include the native JNI libraries in the Jar as resource :

Now Android is a lot more convenient: its Gradle plugin provides a standard way to compile the JNI code and embed the native libraries for each supported Android architecture, and its AAR format handles distribution and extraction of the needed native library.
You can even use the same CMakeLists.txt file for Android, you just need to inform CMake that only a subset of JNI is needed for Android:

Of course, remember to load the library before any external method is used:

Here loadBase64Native simply calls System.loadLibrary("base64_jni") on Android, or properly extract and calls System.load(extractedLibraryPath) on desktop JVMs.

Of course, we validate our implementation with testing. As we have already written common tests in part 1, all we have to do for desktop JVMs is to launch the tests with the jvmTests task…
… if you went through the trouble of packaging the library and embedding the extraction logic. If you didn’t (because you only want the JVM target for tests, or because you want to prepare distribute the native library outside of the jar archive), you need to inform the test JVM where to find said native library:

To test on Android, we need to use the connectedCheck Gradle task, which means running the tests on a device (or emulator). This is because we need to check that our native library was properly compiled and is running fine on a real (or emulated) Android device architecture.
To that end, we need to disable on-host unit tests (that are already taken care of by the JVM target) so that the tests in src/commonTest are interpreted by the Android Gradle plugins as connected tests:

Finally, note that there need to be a source file (which may be empty) in src/androidTest/java for the Android Gradle plugin to consider that there are tests to run on device.

In the next part, we’ll see how to interface our native library for Kotlin/Native targetting native desktop OS (Windows, Linux, Mac), embedded Darwin OS (iOS, tvOS & watchOS) as well as embedded Linux architectures (such as ARM & MIPS).

This article is brought to you by Kodein Koders, a european service & training provider focused on Kotlin & Kotlin/Multiplatform.

--

--