Android NDK: The Interaction Between Kotlin and C/C++

Kotlin has already been here for a while. Over the past few years, this language has managed to win the trust and loyalty of many web and mobile developers around the globe.

Kotlin has become especially popular in the Android community. It’s relatively easy to learn and, thanks to its concise and flexible nature, Kotlin makes it possible for us – Android developers – to build “healthy and performant” apps quickly. Finally (and perhaps most importantly), Kotlin can work together with Java and existing Android tools. This is exactly the topic we’re going to dive into today.

While there are a number of articles that can give you a sense for what the Android Native Development Kit (NDK) is and how to use it when writing an Android app in Java, it’s very hard to find information on how to use the Android NDK when building Android apps in Kotlin. So I ventured a little experiment...  

This post is for developers who want to get started with Android NDK and want to know all the niceties of using it for developing in Kotlin.

To see the whole picture, I suggest we start with the theoretical part. Then we’ll proceed to the practical part, with a detailed sample project written in Kotlin.

What’s the Android NDK?

The Android Native Development Kit (NDK) is a set of tools that lets developers write parts of their apps in native code (C/C++), squeezing more performance out of devices and achieving better app performance.

Why do we need the Android NDK?

The NDK may significantly improve application performance, especially for processor-bound applications. Many multimedia applications and games use native code for processor-intensive tasks. There are three reasons why C/C++ can offer performance improvements:

  • C/C++ code is compiled to binary that runs directly on the operating system, while Java code is compiled to Java bytecode and executed by the Java Virtual Machine.

  • Native code allows developers to make use of certain processor features that are not accessible via the Android SDK.

  • Critical code can be optimized at the assembly level.

Used in conjunction with the Android SDK, the NDK toolset provides access to platform libraries that app developers can use to manage native activities and access physical device components such as sensors and touch input. It’s also possible to use your own libraries or to use popular C/C++ libraries that have no equivalents in Java (such as the ffmpeg library written in C/C++ to process audio and video or the jnigraphics library to process bitmaps).

What exactly does the NDK include?

The NDK’s default tools are a debugger, CMake, and the Java Native Interface (or JNI), which does all the heavy lifting – handles the interaction between Java and native code.

The JNI defines how managed code (written in Java or Kotlin) communicates with native code (written in C/C++). How does this happen? Both managed code and native code have functions and variables. The JNI makes it possible to call functions written in C/C++ from Java or Kotlin, and vice versa. It also lets us read and change values stored in variables across languages.

“Why can we apply the JNI to both Java and Kotlin?” you may ask. The answer is pretty simple: Java and Kotlin are interoperable to such an extent that they are compiled to the same bytecode. In fact, the JNI’s task is not to manage the interaction between Java/Kotlin and C/C++, but to manage the interaction between this bytecode and the native language. Since we always get the same bytecode regardless of which high-level language we compile, we can apply JNI to both Java and Kotlin.

Now let’s look at the NDK in action.

Getting started

Let’s create a new Kotlin project (as of the time of writing, the current version of Android Studio is 2.3.2). To set up your project, do the following:

  • Download NDK, LLDB (a software debugger), and CMake using the SDK Manager.

  • Include C++ support using the checkbox on the New Project screen.

how to use android NDK: interaction between C/C++ and Kotlin

  • If you want to use some features like lambda or delegating constructors, you’ll have to use C++11. To do so, choose the appropriate item from the drop-down list on the Customize C++ support screen.

how to use android NDK: interaction between C/C++ and Kotlin 2

Otherwise, the process of creating a new project in Kotlin is the same as with Java. To adjust your project to use Kotlin, you have to install a special Kotlin plugin. After that, press Ctrl+Shift+a and input the “Configure Kotlin in Project” command. To convert any Java file to Kotlin, open it and use the “Convert Java file to Kotlin” command.

Exploring the project structure

If all previous steps have been done successfully, you’ll have a project with the following structure:

how to use android NDK: interaction between C/C++ and Kotlin project structure

You can put your C/C++ source code in the cpp folder. CMake will generate the .externalNativeBuild folder.

  • СMake

So what’s CMake? CMake is a tool that controls and manages the compilation process using a configuration file called CMakeList.txt.

You can set a minimum required version of CMake:

cmake_minimum_required(VERSION 3.4.1)

As we know, first we compile an Android program and then we package all its parts into a single APK file. The contents of our APK will look as follows:

how to use android NDK: interaction between C/C++ and Kotlin; project structure

As you can see, our APK has an additional lib folder that contains folders for different CPUs, which, in turn, support different instruction sets. The thing is, the part of your app written in native code is usually packaged into a separate library (the lib folder, in my case) or into several of them. To get all of your native code gathered into such a library, you need to specify how CMake should package your C/C++ code. This is usually done with the help of the add_library function.

The add_library function creates and names your library, declares it as either STATIC or SHARED, and provides relative paths to its source code. You can define multiple libraries and CMake will build them for you. Gradle will then automatically package all shared libraries into your APK.

add_library( # Sets the name of the library.
             StoreUtil

             # Sets the library as a shared library.
             SHARED

             # Provides a relative path to your source file(s).
             src/main/cpp/StoreUtil.cpp)

add_library( # Sets the name of the library.
             Store

             # Sets the library as a shared library.
             SHARED

             # Provides a relative path to your source file(s).
             src/main/cpp/Store.cpp
             src/main/cpp/StoreUtil.cpp)

You can use different third-party C/C++ libraries in your project. To integrate a library, use the find-library function, which retrieves the name and type of a library as well as a path. The find_library function searches for your specified prebuilt library and stores its path as a variable.

In my case, I used only system libraries. Because CMake includes system libraries in the search path by default, the only thing I had to do was specify the name of the public system NDK library I wanted to add. CMake then needed to check if this library existed before completing its build. The following code snippet demonstrates how to add a library for logs:

find_library( # Sets the name of the path variable.
              log-lib

              # Specifies the name of the NDK library that
              # you want CMake to locate.
              log)

We can now use the target_link_libraries function to specify libraries that CMake will link to our target libraries created by the add_library function. You can link multiple libraries (such as libraries you’ve already defined in this build script), prebuilt third-party libraries, or system libraries.

target_link_libraries( # Specifies the target library.
                       Store

                       # Links the target library to the log library
                       # included in the NDK.
                       ${log-lib})

target_link_libraries( # Specifies the target library.
                       StoreUtil

                       # Links the target library to the log library
                       # included in the NDK.
                       ${log-lib})
  • Gradle Configuration

In the build.gradle file, we can specify our extra cppFlags flags and define the path to CMakeLists.txt for CMake. There’s no need to manually create the CMakeLists.txt file, since it was automatically created during project setup.

android {
    compileSdkVersion 26
    buildToolsVersion "26.0.0"

    defaultConfig {
        minSdkVersion 15
        targetSdkVersion 26
        versionCode 1
        versionName "1.0"

+        externalNativeBuild {
+            cmake {
+                cppFlags "-std=c++11"
+            }
+        }

    }
    buildTypes {
        release {
            minifyEnabled false
            proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
        }
    }
+    externalNativeBuild {
+        cmake {
+           path "CMakeLists.txt"
+        }
+    }
}
  • Header files

In C/C++, header files contain definitions of functions and variables. There are two types of header files: system header files (which come with a compiler) and user header files (which are written by a developer). Why do we need header files?

You can use a header file to create certain new data types:

#ifndef INMEMORYSTORAGE_STORE_H
#define INMEMORYSTORAGE_STORE_H

#include <cstdint>
#include "jni.h"

#define STORE_MAX_CAPACITY 16

typedef struct {
    StoreEntry mEntries[STORE_MAX_CAPACITY];
    int32_t mLength;
} Store;

#endif //INMEMORYSTORAGE_STORE_H

You can also declare functions in these files:

#ifndef INMEMORYSTORAGE_STOREUTIL_H
#define INMEMORYSTORAGE_STOREUTIL_H

#include <jni.h>
#include "Store.h"

bool isEntryValid(JNIEnv* pEnv, StoreEntry* pEntry, StoreType pType);

StoreEntry* allocateEntry(JNIEnv* pEnv, Store* pStore, jstring pKey);

StoreEntry* findEntry(JNIEnv* pEnv, Store* pStore, jstring pKey);

void releaseEntryValue(JNIEnv* pEnv, StoreEntry* pEntry);

void throwNoKeyException(JNIEnv* pEnv);

#endif //INMEMORYSTORAGE_STOREUTIL_H

You can then use these new data types and declared functions in your project.

To add a standard header file, use #include <file>; for your own header files, you need to use #include “file”. This way the content from a header file will be copied to your *.cpp file.

You may accidently duplicate one header file in one *.cpp file; as a result, you will receive an error message during compilation. To avoid this, you can wrap the header content in the #ifndef#endif block.

  • Primitive Types of the JNI

We have to invoke functions written in C/C++ to deal with our native code. There are special native types (defined by the JNI) to pass arguments to a native function or get a result in the form of a primitive type. In other words, each primitive in Java has a corresponding native type:

how to use android NDK: interaction between C/C++ and Kotlin. primitive types of JNI

If you want to create a function that adds two values and returns a result, you’ll  need to write something like this:

extern "C"
JNIEXPORT jint JNICALL
Java_your_package_name_Math_add(
        JNIEnv* pEnv,
        jobject pThis,
        jint a,
        jint b) {
    return a + b;
}
  • Reference Types in the JNI

The JNI also includes a number of reference types that correspond to different kinds of Java objects. These JNI reference types have the following hierarchy:

how to use android NDK: interaction between C/C++ and Kotlin. Reference types og JNI

The function that takes and returns an object may look like this:

extern "C"
JNIEXPORT void JNICALL
Java_your_package_name_SomeClass_passObject(
        JNIEnv* pEnv,
        jobject pThis,
        jobject pObject) {
   ///.......
}
  • The difference between Java strings and C/C++ strings

The JNI uses modified UTF-8 strings to represent various string types. Java strings are stored in memory as UTF-16 strings. When the content of Java strings is extracted into native code, the returned buffer is encoded in the Modified UTF-8 format. Modified UTF-8 encoding is compatible with standard C string functions, which usually work on string buffers composed of 8 bits per character. To convert a Java string to a C/C++ sting, you can use something like this:

extern "C"
JNIEXPORT void JNICALL
Java_com_ihorkucherenko_storage_Store_setString(
        JNIEnv* pEnv,
        jobject pThis,
        jstring pKey,
        jstring pString) {
    // Turns the Java string into a temporary C string.
    StoreEntry* entry = allocateEntry(pEnv, &gStore, pKey);
    if (entry != NULL) {
        entry->mType = StoreType_String;
        // Copy the temporary C string into its dynamically allocated
        // final location. Then releases the temporary string.
        jsize stringLength = pEnv->GetStringUTFLength(pString);
        entry->mValue.mString = new char[stringLength + 1];
        // Directly copies the Java String into our new C buffer.
        pEnv->GetStringUTFRegion(pString, 0, stringLength, entry->mValue.mString);
        // Append the null character for string termination.
        entry->mValue.mString[stringLength] = '\0';
    }
}
  • JNI functions

To call a native function from Java or Kotlin, you have to add the extern "C" keyword and the JNIEXPORT macro. A macro is a fragment of code with a corresponding name. Whenever this name is used, it’s replaced by the contents of the macro. In our case, we have JNIEXPORT from the jni.h file:

#define JNIIMPORT
#define JNIEXPORT  __attribute__ ((visibility ("default")))
#define JNICALL

JNIEXPORT will be replaced by __attribute__ ((visibility (“default”))). Then we have to specify a return type and the JNICALL macro. The name of your function should start with Java and the full name of your class that contains the corresponding method in Java or Kotlin code:

package your.package.name

class Math {

    external fun add(a: Int, b: Int): Int

}

Your native function needs to take at least two arguments – JNIEnv* pEnv and jobject pThis. Roughly speaking, JNIEnv* is a pointer to a table that contains pointers to functions. It provides most JNI functions that are defined in the jni.h file. The jobject pThis variable is a class instance that contains this same function in Java code. In our case, it’s an object of the Math class.

  • Enum, Union, and Struct

Enumeration (or Enum)  is a user-defined data type that consists of integral constants. To define enumeration, we need to use the “enum” keyword.

typedef enum {
    StoreType_Float,
    StoreType_String,
    StoreType_Integer,
    StoreType_Object,
    StoreType_Boolean,
    StoreType_Short,
    StoreType_Long,
    StoreType_Double,
    StoreType_Byte,
} StoreType;

A union is a special data type in C that allows you to store different data types in the same memory location. You can define a union with many members, but one member can only store one value at a time. Unions provide an efficient way of using the same memory location for multiple purposes.

typedef union {
    float mFloat;
    int32_t mInteger;
    char* mString;
    jobject mObject;
    jboolean mBoolean;
    jshort mShort;
    jlong mLong;
    jdouble mDouble;
    jbyte mByte;
} StoreValue;

A structure (or Struct) allows us to combine data items of different kinds.

typedef struct {
    char* mKey;
    StoreType mType;
    StoreValue mValue;
} StoreEntry;

You can create instances of these data types in the *.h or *.cpp file. If you want to use a structure in different places, you should place the structure in the header file. Otherwise, it’s possible to choose *.cpp for creating new data types and declaring functions.

  • Pointers

In computer science there’s a term “hardware word”. A hardware word is a fixed-size piece of data that’s handled as a unit by processor hardware, usually occupying 32 or 64 bits of memory. The RAM in a computer is like a succession of cells, each one hardware word in size.

The pointer in C/C++ is a variable that contains a unique address to each cell of memory.

int var = 20; //actual variable decalration
int *ip; //pointer variable declaration
ip = &var; //store address of var in pointer

As you can see, to get a pointer to some variable you have to use &, and declare this pointer you have to use *.

Each pointer also occupies a cell and has a size equal to the hardware word, so you can also create a pointer to a pointer.

There are several nuances of dealing with pointers to Java objects though. For example, a pointer to a Java object in your native code may be invalid. Unlike С/C++, Java objects don’t usually have a fixed location in memory because they move around within memory all the time. If your Java object moves to another memory location, a pointer to that object in C/C++ will still point to its old location in memory. What I’m driving at is that it’s not advisable to have a lot of pointers to Java objects in our native code.

You can check out these tests to get a better understanding of all the subtleties:

 @Test
    fun storeObject() {
        val calendar = Calendar.getInstance()
        val key = "object"
        store.setObject(key, calendar)
        assertSame(calendar, store.getObject(key))
    }

    @Test
    fun storeIntegerEquals() {
        val int = 3
        val key = "int"
        store.setInteger(key, int)
        assertEquals(int, store.getInteger(key))
    }

    @Test
    fun storeIntegerTheSame() {
        val int = 3
        val key = "int"
        store.setInteger(key, int)
        assertSame(int, store.getInteger(key))
    }

    @Test
    fun storeIntegerNotTheSame() {
        val int = 128
        val key = "int"
        store.setInteger(key, int)
        assertNotSame(int, store.getInteger(key))
    }
  • Types of references

To work with your jobject, you have to create a Local, Weak, or Global reference to it.

As we know, JNIEnv* pEnv provides most of the JNI functions, including jobject NewGlobalRef(JNIEnv *env, jobject obj), jweak NewWeakGlobalRef(JNIEnv *env, jobject obj), and jobject NewLocalRef(JNIEnv *env, jobject ref), which creates references of an appropriate type (see the reference type in the JNI section).

extern "C"
JNIEXPORT void JNICALL
Java_com_ihorkucherenko_storage_Store_setObject(
        JNIEnv* pEnv,
        jobject pThis,
        jstring pKey,
        jobject pObject) {
    StoreEntry* entry = allocateEntry(pEnv, &gStore, pKey);
    if (entry != NULL) {
        entry->mType = StoreType_Object;
        entry->mValue.mObject = pEnv->NewGlobalRef(pObject);
        if(entry->mValue.mObject != NULL) {
            __android_log_print(ANDROID_LOG_INFO,  __FUNCTION__, "SUCCESS");
        }
    }
}

Here we have a pointer to the JNIEnv* pEnv structure. If you want to refer to a member of this pointer, you have to use the arrow operator (->).

Local references are only valid during a native method call. They are freed automatically after a native method returns. Each local reference occupies some amount of Java Virtual Machine resources. Programmers need to make sure that native methods don’t excessively allocate local references. Although local references are automatically freed after a native method returns to Java, excessive allocation of local references may cause the JVM to run out of memory during the execution of a native method.

Weak global references are a special kind of global reference. Unlike normal global references, weak global references allow the underlying Java object to be garbage collected. Weak global references may be used in any situation where global or local references are used. When the garbage collector runs, it frees the underlying object if the object is only referred to by weak references. A weak global reference pointing to a freed object is functionally equivalent to NULL. Programmers can detect whether a weak global reference points to a freed object by using IsSameObject to compare the weak reference against NULL.

Weak global references in the JNI are a simplified version of Java Weak References, available as part of the Java 2 Platform API ( java.lang.refpackage and its classes).

Global references allow native code to promote a local reference into a form usable by native code in any thread attached to the JVM. References of this type are not automatically deleted, so the programmer must handle memory management. Every global reference establishes a root for a referent and makes its entire subtree reachable. Therefore, every global reference created must be freed to prevent memory leaks.

There are several functions to delete different types of references: void DeleteLocalRef(JNIEnv *env, jobject localRef), void DeleteGlobalRef(JNIEnv *env, jobject globalRef), and void DeleteWeakGlobalRef(JNIEnv *env, jweak obj).

Sample project

Now you have some basic theoretical knowledge that’s enough to start using C/C++ in your projects. I’ve decided to create a sample project that contains a simple in-memory storage module written in C/C++.

how to use android NDK: interaction between C/C++ and Kotlin sample project

To encapsulate all native functions, I created a Store class.

class Store {

    external fun getCount(): Int

    @Throws(IllegalArgumentException::class)
    external fun getString(pKey: String): String

    @Throws(IllegalArgumentException::class)
    external fun getInteger(pKey: String): Int

    @Throws(IllegalArgumentException::class)
    external fun getFloat(pKey: String): Float

    ....

    external fun setString(pKey: String, pString: String)

    external fun setInteger(pKey: String, pInt: Int)

    external fun setFloat(pKey: String, pFloat: Float)

    external fun setBoolean(pKey: String, pBoolean: Boolean)

    ....

    companion object {
        init {
            System.loadLibrary("Store")
        }
    }
}

All these methods have their corresponding native functions in the Store.cpp file.

In the CMake section, we learned how to use the add_library function to create a shared library. You can load your native code from shared libraries with the standard System.loadLibrary call. At this time, the method JNI_onLoad is invoked.

extern "C" JNIEXPORT jint JNI_OnLoad(JavaVM* vm, void* reserved)
{
    __android_log_print(ANDROID_LOG_INFO,  __FUNCTION__, "onLoad");
    JNIEnv* env;
    if (vm->GetEnv(reinterpret_cast<void**>(&env), JNI_VERSION_1_6) != JNI_OK) {
        return -1;
    }

    gStore.mLength = 0;
    return JNI_VERSION_1_6;
}

You can use this method to initialize some variable.

Our CMakeLists.txt file also contains the target_link_libraries call to add a library to log to the project. We can include this library (#include <android/log.h>) in the *.cpp file and log an event: __android_log_print(ANDROID_LOG_INFO, __FUNCTION__, “onLoad”).

Note that in Kotlin we use the init block of a companion object instead of the static block; we also use an external keyword instead of a native.

As you might have already noticed, native methods can throw Java exceptions. If you try to get some non-existent value from the Store, it will throw an exception. The StoreUtil.cpp file contains the throwNoKeyException function for this:

bool isEntryValid(JNIEnv* pEnv, StoreEntry* pEntry, StoreType pType)
{
    if(pEntry == NULL)
    {
        throwNoKeyException(pEnv);
    }

    return ((pEntry != NULL) && (pEntry->mType == pType));
}

void throwNoKeyException(JNIEnv* pEnv) {
    jclass clazz = pEnv->FindClass("java/lang/IllegalArgumentException");
    if (clazz != NULL) {
        pEnv->ThrowNew(clazz, "Key does not exist.");
    }
    pEnv->DeleteLocalRef(clazz);
}

This simple in-memory storage module that can be used to pass some value – primitive data type or reference data type – from one activity to another. This storage works similarly to IBinder by creating a global reference to a Java object.

Today, we’ve learned how to make Kotlin communicate with C/C++. Kotlin can call C/C++ code with any type of data or objects.

We started with a bit of theory and then created our sample project based on it. We converted Java strings inside native code, passed Kotlin objects to native code, called functions, and threw exceptions in Java.

Kotlin is a new official tool for Android Development, and this article demonstrates that this programming language works with the Android NDK without any difficulties. In our second article, we’ll find out how to call Kotlin from native code.

4.3/ 5.0
Article rating
12
Reviews
Remember those Facebook reactions? Well, we aren't Facebook but we love reactions too. They can give us valuable insights on how to improve what we're doing. Would you tell us how you feel about this article?