← Back to list

Getting Started with Android NDK: Unlocking Native Power

A Comprehensive Kotlin Tutorial to Integrate C/C++ for High-Performance Android Development.

Android Expert · 2025-10-16 15:08 · 16 claps · 11.9 min read paywalled
#android-ndk #kotlin #android-development #jni #native-code
Open on Medium ↗
Wiki topics: 📱 · Mobile Development

Getting Started with Android NDK: Unlocking Native Power

Android NDK

Android NDK

Not a Medium Member? “Read For Free”

Android development primarily thrives on Java or Kotlin, offering a robust and high-level environment. However, there are scenarios where tapping into the raw power and performance of C/C++ can be a game-changer. This is where the Android Native Development Kit (NDK) comes into play. The NDK allows you to implement parts of your application using native-code languages like C and C++.

This tutorial will guide you through the process of setting up your environment, writing native code, and integrating it into your Android application, all while keeping things engaging and easy to understand with Kotlin examples!

Why Consider the Android NDK?

Before we dive into the “how,” let’s explore the “why.” While the NDK isn’t for every project, it offers significant advantages in specific situations:

  • Performance-Critical Operations: For tasks demanding high computational power, such as game engines, signal processing, or complex algorithms, native code can provide a substantial performance boost.
  • Reusing Existing Native Libraries: If you have existing C/C++ libraries that you want to integrate into your Android app, the NDK provides a seamless bridge. A critical security consideration when working with native code is key management. If your C/C++ logic handles sensitive encryption, the keys must be stored securely. Always couple your native security routines with the gold standard for on-device key management, which is detailed in my article on the Android KeyStore: Secure On-Device Data Encryption.
  • Low-Level Hardware Access: In some specialized cases, you might need direct access to hardware features that are not exposed through the standard Android SDK.
  • Security (to an extent): While not a foolproof solution, obfuscating critical logic in native code can make reverse engineering slightly more challenging.

Setting Up Your Development Environment

To begin our journey into native development, you’ll need a few tools. Luckily, Android Studio makes this process quite straightforward.

  1. Install the NDK and CMake: Open Android Studio, navigate to Tools > SDK Manager. In the SDK Tools tab, check NDK (Side by side) and CMake. Click Apply to install them. CMake is a tool that manages the build process of native code.

Install the NDK and CMake

Install the NDK and CMake

2. Create a New Android Project (or use an existing one): When creating a new project, you can select the Native C++ template, which will pre-configure everything for you. However, we'll assume you're adding NDK to an existing project or a basic empty activity for this tutorial to cover the manual setup.

Your First Native Function: Hello NDK!

Let’s create a simple native function that returns a string. This will demonstrate the fundamental steps of calling C++ code from Kotlin.

Step 1: Create a Native Source File

Inside your app module, create a new directory named cpp (if it doesn't already exist). Inside cpp, create a new C++ file, for example, native-lib.cpp.

Your project structure might look like this:

app
├── src
│   ├── main
│   │   ├── java
│   │   │   └── com
│   │   │       └── example
│   │   │           └── myndkapp
│   │   │               └── MainActivity.kt
│   │   ├── cpp
│   │   │   └── native-lib.cpp
│   │   └── AndroidManifest.xml
│   └── build.gradle
└── CMakeLists.txt

Now, add the following C++ code to native-lib.cpp:

#include <jni.h> // Essential for Java Native Interface
#include <string>  // For string manipulation

// This function is called from Kotlin/Java and returns a C++ string
extern "C" JNIEXPORT jstring JNICALL // 'extern "C"' ensures C-style linkage
Java_com_example_myndkapp_MainActivity_getNativeGreeting( // Function signature derived from package, class, and method name
        JNIEnv* env, // Pointer to the JNI environment
        jobject /* this */) { // Reference to the calling Java object (MainActivity instance)
    std::string message = "Hello from NDK! This is a native message.";
    return env->NewStringUTF(message.c_str()); // Convert C++ string to JNI string
}

// Let's add another simple function to demonstrate arithmetic
extern "C" JNIEXPORT jint JNICALL
Java_com_example_myndkapp_MainActivity_calculateSumNative(
        JNIEnv* env,
        jobject /* this */,
        jint a, // First integer parameter from Kotlin
        jint b) { // Second integer parameter from Kotlin
    return a + b; // Return the sum
}

Explanation of the C++ code:

  • **#include <jni.h>**: This header provides the interface between your C/C++ code and the Java Virtual Machine (JVM).
  • **extern "C" JNIEXPORT jstring JNICALL**: This is crucial.
  • extern "C": Tells the compiler to use C naming conventions, which is necessary for JNI to find the function.
  • JNIEXPORT: A macro that ensures the function is exported from the shared library.
  • jstring / jint: These are JNI types that correspond to Java's String and int, respectively.
  • JNICALL: A macro that ensures the correct calling convention.
  • **Java_com_example_myndkapp_MainActivity_getNativeGreeting*: This is the naming convention* for JNI functions. It follows the pattern: Java_PackageName_ClassName_MethodName.
  • PackageName: Your Android application's package name (e.g., com_example_myndkapp). Replace underscores with dots in your package name.
  • ClassName: The name of the Kotlin/Java class that will declare and call this native method (e.g., MainActivity).
  • MethodName: The name you will give to the native method in your Kotlin/Java class (e.g., getNativeGreeting).
  • **JNIEnv* env**: A pointer to the JNI environment, which provides functions for interacting with the JVM (e.g., creating new strings, accessing Java objects).
  • **jobject /* this */*: A reference to the calling Java object. We typically don't use it for static native methods, hence the `/ this */` comment.
  • **env->NewStringUTF(message.c_str())**: Converts a C++ std::string to a JNI jstring (UTF-8 encoded) that can be returned to Java/Kotlin.

Step 2: Configure CMake

Now, we need to tell Android Studio how to build our native code. Create a file named CMakeLists.txt in the app module's root directory (next to build.gradle).

# Sets the minimum version of CMake required to build your native library.
# This ensures consistency across different build environments.
cmake_minimum_required(VERSION 3.4.1)

# Declares and names your native library.
# 'SHARED' means it will be built as a dynamic shared library (.so file).
# '${CMAKE_SOURCE_DIR}/src/main/cpp/native-lib.cpp' specifies the source file.
add_library( # Sets the name of the library.
             native-lib

             # Sets the type of library.
             SHARED

             # Specifies the source files for your library.
             src/main/cpp/native-lib.cpp ) # Path to our C++ source

# Searches for a prebuilt static library called 'log'
# that is provided by the Android NDK. This library
# provides logging functions (e.g., __android_log_print)
# that you can use to output messages to logcat.
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 )

# Specifies linked libraries.
# Adds the NDK 'log' library to the build target 'native-lib'.
# This allows 'native-lib' to use the logging functions from 'log-lib'.
target_link_libraries( # Specifies the target library to link against.
                       native-lib

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

Explanation of CMakeLists.txt:

  • **cmake_minimum_required(VERSION 3.4.1)**: Specifies the minimum CMake version.
  • **add_library(native-lib SHARED src/main/cpp/native-lib.cpp)**: This is the core command.
  • native-lib: The name of our shared library (this will be the .so file name).
  • SHARED: Indicates that we are building a dynamic shared library.
  • src/main/cpp/native-lib.cpp: The path to our C++ source file.
  • **find_library(log-lib log)**: Finds the NDK's logging library.
  • **target_link_libraries(native-lib ${log-lib})**: Links our native-lib with the logging library so we can use __android_log_print for debugging.

Step 3: Link CMake to your build.gradle (Module Level)

Now, we need to tell your app/build.gradle file about our native setup.

Open your module-level build.gradle (usually app/build.gradle) and add the externalNativeBuild block within the android block:

android {
    namespace 'com.example.myndkapp'
    compileSdk 34

    defaultConfig {
        applicationId "com.example.myndkapp"
        minSdk 24
        targetSdk 34
        versionCode 1
        versionName "1.0"

        testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"

        // Add this block for NDK configuration
        externalNativeBuild {
            cmake {
                cppFlags "" // You can add C++ specific flags here, e.g., "-std=c++17"
                arguments "-DANDROID_STL=c++_static" // Example: use a static C++ standard library
            }
        }
    }

    buildTypes {
        release {
            minifyEnabled false
            proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
        }
    }
    compileOptions {
        sourceCompatibility JavaVersion.VERSION_1_8
        targetCompatibility JavaVersion.VERSION_1_8
    }
    kotlinOptions {
        jvmTarget = '1.8'
    }

    // This block links your project to the CMakeLists.txt file
    externalNativeBuild {
        cmake {
            path file('CMakeLists.txt') // Specify the path to your CMakeLists.txt
        }
    }
}

Important: After modifying build.gradle, Android Studio will prompt you to "Sync Now." Make sure you sync your project!

Step 4: Declare and Call Native Methods in Kotlin

Finally, let’s call our native functions from our MainActivity.kt.

package com.example.myndkapp

import androidx.appcompat.app.AppCompatActivity
import android.os.Bundle
import android.widget.TextView
import com.example.myndkapp.databinding.ActivityMainBinding

class MainActivity : AppCompatActivity() {

    private lateinit var binding: ActivityMainBinding

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        binding = ActivityMainBinding.inflate(layoutInflater)
        setContentView(binding.root)

        // Example 1: Get greeting from native code
        binding.sampleText.text = getNativeGreeting() // Calls the native method

        // Example 2: Calculate sum using native code
        val num1 = 10
        val num2 = 25
        val sum = calculateSumNative(num1, num2) // Calls the native sum method
        binding.sumResultText.text = "Sum of $num1 and $num2 from NDK: $sum"
    }

    /**
     * A native method that is implemented by the 'native-lib' native library,
     * which is packaged with this application.
     */
    external fun getNativeGreeting(): String // Declare the native function (no body in Kotlin)

    /**
     * Another native method to calculate the sum of two integers.
     */
    external fun calculateSumNative(a: Int, b: Int): Int

    companion object {
        // Used to load the 'native-lib' library on application startup.
        // The name "native-lib" must match the name used in CMakeLists.txt (add_library).
        init {
            System.loadLibrary("native-lib")
        }
    }
}

Explanation of the Kotlin code:

  • **external fun getNativeGreeting(): String*: The external keyword tells Kotlin that this function is implemented in native code. Its signature (name, parameters, return type) must* match the JNI function signature in C++.
  • **companion object { init { System.loadLibrary("native-lib") } }: This static block loads our native shared library (native-lib.so) when the MainActivity class is initialized. The name native-lib here must** match the name you gave in CMakeLists.txt using add_library.
  • The TextView (with id sample_text) will display the greeting from our native code. You'll need to add this TextView to your activity_main.xml layout if you don't have it already.

Step 5: Update activity_main.xml

Ensure your layout file (activity_main.xml) has TextView elements to display the results:

<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".MainActivity">

    <TextView
        android:id="@+id/sample_text"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="Hello World!"
        app:layout_constraintBottom_toTopOf="@+id/sum_result_text"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent"
        app:layout_constraintVertical_chainStyle="packed"/>

    <TextView
        android:id="@+id/sum_result_text"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_marginTop="16dp"
        android:text="Sum will appear here."
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toBottomOf="@+id/sample_text" />

</androidx.constraintlayout.widget.ConstraintLayout>

Run Your Application!

Now, run your Android application on an emulator or a physical device. You should see “Hello from NDK! This is a native message.” and “Sum of 10 and 25 from NDK: 35” displayed on your screen.

Run Your Application!

Run Your Application!

This confirms that your Kotlin code is successfully calling and receiving results from your native C++ code!

Advanced Concepts & Best Practices

Passing Data Types

You’ve seen jstring and jint. JNI provides mappings for almost all primitive and complex Java/Kotlin types:

  • boolean -> jboolean
  • byte -> jbyte
  • char -> jchar
  • short -> jshort
  • long -> jlong
  • float -> jfloat
  • double -> jdouble
  • Object -> jobject
  • String -> jstring
  • Arrays (e.g., int[]) -> jintArray
  • Custom Objects -> jobject (requires more complex JNI calls to access fields/methods)

Error Handling in NDK

Native code doesn’t have the luxury of Java/Kotlin exceptions. You typically handle errors by:

  • Return Codes: Return specific integer codes to indicate success or different error types.
  • JNI Exceptions: You can explicitly throw Java exceptions from native code using JNIEnv functions like ThrowNew.

Debugging Native Code

Android Studio offers excellent debugging capabilities for native code.

  • Set breakpoints in your native-lib.cpp file.
  • Select Debug when running your application.
  • The debugger will pause at your C++ breakpoints, allowing you to inspect variables and step through native code.

Threading with NDK

If your native code performs long-running operations, you should run it on a separate thread to avoid blocking the UI. You can create threads in Kotlin/Java and call native functions from them, or even create native threads directly within your C++ code. Be mindful of JNI thread attachment if creating native threads.

NDK Beyond the Basics: Practical Use Cases

Let’s consider a slightly more involved example: image processing. Imagine you have a complex image filter algorithm written in C++ that you want to apply to an Bitmap in Android.

Scenario: Applying a simple grayscale filter to a bitmap efficiently.

1. C++ Grayscale Function (image-utils.cpp)

#include <jni.h>
#include <android/bitmap.h> // For Android Bitmap functions
#include <android/log.h> // For logging to logcat

#define LOG_TAG "ImageUtilsNative" // Tag for log messages
#define LOGI(...) __android_log_print(ANDROID_LOG_INFO, LOG_TAG, __VA_ARGS__)
#define LOGE(...) __android_log_print(ANDROID_LOG_ERROR, LOG_TAG, __VA_ARGS__)

extern "C" JNIEXPORT void JNICALL
Java_com_example_myndkapp_MainActivity_applyGrayscaleNative(
        JNIEnv* env,
        jobject /* this */,
        jobject bitmap) { // jObject for the Bitmap
    AndroidBitmapInfo info;
    void* pixels;
    int ret;

    // Get bitmap info
    if ((ret = AndroidBitmap_getInfo(env, bitmap, &info)) < 0) {
        LOGE("AndroidBitmap_getInfo() failed! error=%d", ret);
        return;
    }

    // Check if the bitmap is in a format we can work with (e.g., RGBA_8888)
    if (info.format != ANDROID_BITMAP_FORMAT_RGBA_8888) {
        LOGE("Bitmap format is not RGBA_8888! format=%d", info.format);
        return;
    }

    // Lock the bitmap to get access to its pixels
    if ((ret = AndroidBitmap_lockPixels(env, bitmap, &pixels)) < 0) {
        LOGE("AndroidBitmap_lockPixels() failed! error=%d", ret);
        return;
    }

    // Process pixels: apply grayscale
    uint32_t* p = (uint32_t*)pixels;
    int width = info.width;
    int height = info.height;

    LOGI("Applying grayscale filter to bitmap: %dx%d", width, height);

    for (int y = 0; y < height; ++y) {
        for (int x = 0; x < width; ++x) {
            uint32_t pixel = p[y * width + x];

            // Extract ARGB components (assuming little-endian)
            uint8_t alpha = (pixel >> 24) & 0xFF;
            uint8_t red   = (pixel >> 16) & 0xFF;
            uint8_t green = (pixel >> 8)  & 0xFF;
            uint8_t blue  = (pixel >> 0)  & 0xFF;

            // Calculate grayscale value (luminosity method)
            uint8_t gray = (uint8_t)(0.299 * red + 0.587 * green + 0.114 * blue);

            // Recompose pixel with grayscale value
            p[y * width + x] = (alpha << 24) | (gray << 16) | (gray << 8) | gray;
        }
    }

    // Unlock the bitmap
    AndroidBitmap_unlockPixels(env, bitmap);
    LOGI("Grayscale filter applied successfully.");
}

2. Update CMakeLists.txt

You’d need to add image-utils.cpp to your add_library command and link against android library for AndroidBitmap functions:

cmake_minimum_required(VERSION 3.4.1)

add_library( native-lib
             SHARED
             src/main/cpp/native-lib.cpp
             src/main/cpp/image-utils.cpp ) # Add the new source file here

find_library( log-lib log )

# Add the 'android' library for bitmap functions
find_library( android-lib android )

target_link_libraries( native-lib
                       ${log-lib}
                       ${android-lib} ) # Link against android-lib

3. Kotlin MainActivity

package com.example.myndkapp

import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.os.Bundle
import android.widget.ImageView
import androidx.appcompat.app.AppCompatActivity
import com.example.myndkapp.databinding.ActivityMainBinding
import java.io.IOException

class MainActivity : AppCompatActivity() {

    private lateinit var binding: ActivityMainBinding

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        binding = ActivityMainBinding.inflate(layoutInflater)
        setContentView(binding.root)

        // ... (previous native calls)

        // Example 3: Image Processing with NDK
        binding.applyFilterButton.setOnClickListener {
            try {
                // Load an image from assets
                assets.open("sample_image.jpg").use { inputStream ->
                    val originalBitmap = BitmapFactory.decodeStream(inputStream)
                    // Ensure the bitmap is mutable for in-place processing
                    val mutableBitmap = originalBitmap.copy(Bitmap.Config.ARGB_8888, true)

                    // Display original (optional)
                    binding.imageViewOriginal.setImageBitmap(originalBitmap)

                    // Apply grayscale filter using native code
                    applyGrayscaleNative(mutableBitmap)

                    // Display processed image
                    binding.imageViewProcessed.setImageBitmap(mutableBitmap)
                }
            } catch (e: IOException) {
                e.printStackTrace()
                binding.sumResultText.text = "Error loading image: ${e.message}"
            }
        }
    }

    // Declare the new native function
    external fun applyGrayscaleNative(bitmap: Bitmap)

    companion object {
        init {
            System.loadLibrary("native-lib")
        }
    }
}

You’d also need to add an ImageView for original and processed images, and a Button to trigger the filter in activity_main.xml. Don't forget to put a sample_image.jpg in your app/src/main/assets folder.

This example showcases how you can pass complex objects like Bitmap to native code and modify their underlying pixel data for high-performance operations.

Result

Result

Frequently Asked Questions (FAQs)

When should I not use the NDK?

If your task can be efficiently accomplished with Kotlin/Java, stick to it. NDK development adds complexity, increases build times, and can make debugging more challenging. It’s best reserved for performance-critical sections or leveraging existing native libraries.

Can I mix Java/Kotlin and C++ code freely?

Yes, that’s the whole point of JNI! You can call native functions from your Java/Kotlin code, and native code can even call back into Java/Kotlin methods if needed (though that’s more advanced).

What are the performance implications of JNI calls?

There’s a small overhead associated with each JNI call due to the context switch between the JVM and native code. For a few calls, it’s negligible. For very frequent calls (e.g., inside a tight loop), it can become a bottleneck. In such cases, it’s often better to pass larger chunks of data or perform more work within a single native call.

How do I handle string encoding between Kotlin and C++?

JNI jstrings are typically UTF-8 encoded when converted from C++ to Java using NewStringUTF. When receiving a jstring in C++, you can convert it to a C-style string using GetStringUTFChars. Remember to release the memory using ReleaseStringUTFChars when done!

Is using NDK more secure for sensitive data or algorithms?

While native code is harder to decompile and reverse-engineer than Java bytecode, it’s not truly “secure.” A determined attacker can still analyze native binaries. For critical security, consider hardware-backed security features or strong encryption rather than solely relying on NDK obfuscation.

What are your thoughts?

  • Have you used NDK in any of your projects? What was your experience?
  • What kind of performance gains have you observed by moving tasks to native code?
  • Are there any other advanced NDK topics you’d like to explore in a future tutorial?

📘 Master Your Next Technical Interview

Since Java is the foundation of Android development, mastering DSA is essential. I highly recommend “Mastering Data Structures & Algorithms in Java”. It’s a focused roadmap covering 100+ coding challenges to help you ace your technical rounds.


메타데이터
post_id
71ca0ed1fabc
slug
getting-started-with-android-ndk-unlocking-native-power-71ca0ed1fabc
url
https://medium.com/@sivavishnu0705/getting-started-with-android-ndk-unlocking-native-power-71ca0ed1fabc
canonical_url
https://medium.com/@sivavishnu0705/getting-started-with-android-ndk-unlocking-native-power-71ca0ed1fabc
author_url
https://medium.com/@sivavishnu0705
status
ok
fetched_at
2026-06-14 11:28:49