Creating mobile products in the C programming language often seems to be the lot of enthusiasts or highly specialized engineers accustomed to working with embeddedsystems. However, the reality is that native development remains a powerful tool for solving problems that require maximum performance and direct access to hardware resources. If you're wondering how to write an Android app in C, you'll be diving into an ecosystem that serves as a bridge between low-level code and the high-level Dalvik or ART runtime. Unlike standard development in C, where the virtual machine takes on memory and security management, working with native code requires much more discipline from the programmer. Android NDK, which serves as a bridge between low-level code and the high-level Dalvik or ART runtime.
Unlike standard development on Kotlin or Java, where the virtual machine takes over memory and security management, working with native code requires much more discipline from the programmer. Manual memory management and working with pointers opens the door to optimizing computationally complex algorithms, graphics processing in games, or working with cryptography. Is the game worth the candle? For most business applications, the answer will be no, but for specific tasks, using C is the only solution.
This guide will walk you through all the stages of setting up the environment, writing the first module and integrating it into an APK package. We'll walk you through the key concepts Java Native Interface, we will look at common mistakes and discuss architectural features that must be taken into account when creating hybrid applications. Are you ready to give up the comfort of a garbage collector for the sake of absolute control over the hardware?
Preparing the environment and installing Android NDK
The first step towards native development is installing the necessary tools. The standard environment Android Studio itself does not contain all the components for working with C++, so you will need to additionally download Android Native Development Kit. This is done through the built-in package manager SDK Manager, where in the tab SDK Tools you need to check the item NDK (Side by side) and CMake. Without the last component, building native libraries will be impossible, since it is responsible for generating assembly files.
After installing the components, you need to create a new project or open an existing one. In the configuration file build.gradle of the module level, you should add a block externalNativeBuildindicating the path to the script CMakeLists.txt. This file is similar to build.gradle, but for native code: it determines which source files to compile, which libraries to link and which compiler flags to use. Syntax errors CMake often cause a failed build, so check the file paths carefully.
โ ๏ธ Attention: NDK and CMake versions must be compatible with the version Android Studio and target API level (targetSdkVersion). Using an old version of the NDK may lead to linking errors with modern Android system libraries.
To get started, create a file CMakeLists.txt in the folder app/src/main/cpp. In it you need to specify the minimum version of CMake, the project name and add the executable file or library. An example of a basic configuration is as follows:
cmake_minimum_required(VERSION 3.4.1)project(MyNativeApp)
add_library(native-lib SHARED src/main/cpp/native-lib.cpp)
find_library(log-lib log)
target_link_libraries(native-lib ${log-lib})
The architecture of interaction between Java and C via JNI
The main mechanism that allows Java or Kotlin code to call functions in C is Java Native Interface (JNI). This technology provides a set of rules and conventions by which two different runtimes exchange data. When you declare a method as native in a Java class, the system expects that the corresponding implementation will be found in the loaded native library with a strictly defined name.
The linking process occurs automatically if you follow the function naming rules. A C function must be prefixed by Java_followed by the full package and class name, separated by underscores, and the method name. For example, for a method getStringFromNative in a class com.example.app.MainActivity the function signature in C will look like Java_com_example_app_MainActivity_getStringFromNative. Violating this format will result in an error UnsatisfiedLinkError at runtime.
How to avoid naming errors in JNI?
Instead of manually writing long function names, use a utility javah (for older versions) or simply compile the project with an error so that the IDE suggested the correct function name in the assembly logs. This will save time and eliminate typos in package names.
Transferring data between environments requires special attention to types. Java primitive types (int, boolean, float) map directly to C types (jint, jboolean, jfloat). However, working with strings and arrays is more complicated: a string String in Java is represented as jstring in C, and to work with it you need to explicitly convert it to an array of UTF-8 characters using the GetStringUTFCharsfunction. Don't forget to free the memory by calling ReleaseStringUTFChars after completion of work, otherwise a memory leak will occur.
- ๐ Direct call: The Java code calls a native function that performs calculations and returns the result back.
- ๐ Callback: Native code can initiate a call to a Java method using the JNI interface to access objects and classes.
- ๐ฆ Passing objects: Complex data structures are passed through class fields or arrays that require element-by-element access.
Writing the first native method
Consider a practical example of creating a function that takes two integers from a Kotlin application and returns their sum calculated in the language C. First, in the activity class, declare a method with a modifier external (for Kotlin) or native (for Java). Then implement the logic in a C file included via CMake.
In the body of the function, you can perform any calculations available in the standard C library. This could be complex mathematics, working with binary data, or calling OS system functions if they are available through the NDK. The result is converted to a Java-compatible type and returned to the caller. The performance of such a call is higher than that of pure Java code, especially if heavy operations are performed inside the loop.
โ๏ธ Algorithm for creating a native method
C code example for adding numbers:
#includeJNIEXPORT jint JNICALL
Java_com_example_myapp_MainActivity_addNumbers(JNIEnv env, jobject / this */, jint a, jint b) {
return a + b;
}
After compilation, the library will be packaged in APK as files .so (shared object) for each supported processor architecture (armeabi-v7a, arm64-v8a, x86_64). When the application starts, the system will dynamically load the required version of the library. Make sure that ABI filters are configured correctly so that you don't bloat the application size with unnecessary architectures if they are not required. build.gradle ABI filters are correctly configured so as not to inflate the application size with unnecessary architectures if they are not required.
Memory management and error handling
The most critical part of C development is manual memory management. In the Java environment, the garbage collector automatically frees unused objects, but in native code, the developer is solely responsible for allocating and freeing resources. The use of functions like malloc must be accompanied by a mandatory call free. Forgotten free leads to gradual memory exhaustion and application crash, and double freeing or accessing already freed memory causes undefined behavior and process crash.
Particular attention should be paid to local and global references in JNI. Local references (Local Reference) are valid only within the execution of the current native method and are automatically deleted after its completion. If you create a lot of objects in a loop inside a native function, you must manually remove local references using DeleteLocalRefto avoid filling up the reference table. Global references (Global Reference) live until you explicitly call DeleteGlobalRef, and are used to store Java objects that need to be accessed from different threads or after a function returns.
โ ๏ธ Warning: Never store pointers to Java objects obtained through
GetObjectFieldor similar methods, without creating a global link. Once control returns to the JVM, the object may be garbage collected and your pointer will become dangling.
Use profiling tools such as Android Profiler or Valgrind (via an emulator) to track memory leaks in native code. They show allocations and releases in real time.
Exception handling also differs from the usual try-catch model. JNI does not have a C++ style exception mechanism. If an exception occurs in Java code during a call through JNI, it does not automatically abort the execution of the C code. It is necessary to explicitly check for the presence of an exception using the ExceptionCheck or ExceptionOccurred method after each call to a Java method from native code. Ignoring this step may result in the application continuing to operate in an incorrect state.
Debugging and profiling native code
Debugging C code in Android Studio has become much more convenient with the advent of the built-in LLDB debugger. You can set breakpoints directly in files .cpp, view variable values, call stacks and processor registers. To start a debugging session, you must select a configuration Native Debug in the Run/Debug Configurations settings. This allows you to step through your code, identifying logic errors and memory access problems.
Use the tool Sysprof or Android Studio's built-in profiler to analyze performance. It allows you to see how much time is spent executing native functions compared to Java code. It often turns out that (the bottleneck) is not the C algorithm itself, but the frequent jumps across the JNI boundary. Each call to a native function has overhead, so it is recommended to minimize the number of context switches by passing data in large blocks (for example, arrays), rather than one element at a time.
| Tool | Purpose | Difficulty of use |
|---|---|---|
| LLDB Debugger | Step-by-step debugging, breakpoints | Medium |
| Android Profiler | CPU and memory analysis | Low |
| Logcat | View logs (__android_log_print) | Low |
| Valgrind | Memory leak detection | High |
Logging in native code is carried out through the library android/log.h. The function __android_log_print allows you to output messages of different levels (INFO, ERROR, DEBUG) to the system log, which is visible in Logcat. This is an indispensable tool for quickly diagnosing problems when connecting a full-fledged debugger is impossible or inconvenient.
When to use C instead of Kotlin or Java
Despite the power of the C language, its use in mobile development should be justified. Rewriting all of an application's business logic in C rarely pays dividends and makes the code more difficult to maintain. Modern ART compilers can effectively optimize Java and Kotlin code, often achieving performance close to native for typical tasks. The use of NDK is justified only in specific scenarios where every millisecond is critical or access to low-level APIs is required.
The main areas of application of C in Android are the development of game engines (for example, based on Unreal Engine or proprietary solutions), real-time audio and video processing, cryptographic calculations and work with device drivers. Also, native code is often used to port existing C/C++ libraries that have already proven their effectiveness on other platforms. In such cases, using JNI allows you to reuse years of work without having to completely rewrite the code in Java.
โ ๏ธ Attention: NDK interfaces and system libraries may change between Android versions. Code that works on Android 10 may require tweaking for Android 14. Always test your app on different versions of the OS and check Google's official documentation when updating a targeted version of the API.
Use C only for computationally complex tasks or reusing existing libraries. For UI and business logic, Kotlin remains the best choice.
In addition, it is worth considering the size of the application. Including an NDK increases the size of the APK since it is necessary to include compiled libraries for multiple processor architectures. If the application is simple, this overhead may not be justified. When used App Bundles, Google Play automatically sends the user only the required architecture, which partially solves the size problem, but the complexity of assembly and support remains high.
Frequently asked questions (FAQ)
Is it possible to write the entire application interface (UI) in C?
It is technically possible to render graphics directly through OpenGL ES or Vulkan using C, but this is extremely inefficient for standard applications. You will lose access to all pre-built Android widgets, responsiveness, and layout tools. The UI should be written in Kotlin/Java, and C should be used only for backend logic.
How difficult is it to find a developer with knowledge of Android NDK?
Much more difficult. There are few specialists on the market who deeply understand JNI, memory management in C++ and the specifics of Android OS at the same time. Their work is usually paid higher than that of standard Android developers.
Does using C affect the security of the application?
Yes, and often in a negative way. Memory management errors (buffer overflow, use-after-free) in C code are a common cause of arbitrary code execution vulnerabilities. Java/Kotlin are protected from most of these errors by the virtual machine.
Do you need to know C++ to work with NDK?
Not necessary, you can use pure C. However, most of the examples and libraries in the NDK ecosystem are written in C++, as it provides more convenient abstractions. Knowledge of C++ greatly expands a developer's capabilities.
Is it possible to use third-party C libraries in Android?
Yes, this is one of the main reasons for using NDK. You can compile any open C/C++ library (for example, FFmpeg, OpenSSL, SQLite) and connect it to your project via CMake, gaining access to their functionality from Java code.