Development of mobile applications for Android is traditionally associated with Java or Kotlin, but what to do if you need maximum performance, working with low-level APIs or migrating legacy code? This is where language comes to the rescue. Despite the fact that Google does not position it as the main tool for Android development, its capabilities C/C++. Although Google does not position it as the main tool for Android development, the capabilities Native Development Kit (NDK) and Java Native Interface (JNI) allow you to create productive modules that are integrated into standard Android applications.
This article will not just tell you how to write "Hello World" in C for Android, but will immerse you in real scenarios: from setting up the environment to optimization code for ARMprocessors. We'll look at when to use native code (and when not), how to avoid pitfalls, and why some games in JNI, and why some games Google Play (for example, PUBG Mobile or Genshin Impact) actively use C++ for graphics rendering. Are you ready to dive into the world of pointers Makefile i cmake? Then let's begin!
Why C for Android: the pros and cons of native development
Before rushing to install tools, let's honestly evaluate when using C is justified and when it's just unnecessary complexity. Native code in Android applications makes sense in three key cases:
- ๐ Performance: Processing large amounts of data (for example, video or 3D graphics), where Java/Kotlin lose in speed due to the garbage collector and the virtual machine.
- ๐ง Legacy code port: You already have a ready-made library in C/C++ (for example, computer vision algorithms from OpenCV), and you want to integrate it into a mobile application.
- ๐ Security: Critical operations (encryption, working with hardware keys) are sometimes transferred to native code to increase complexity reverse engineering.
However, there is a reverse side to the coin:
- โ ๏ธ Difficulty of debugging: Debugging memory segmentation in native code on Android is like looking for a needle in a haystack blindfolded. Tools like Android Studio Profiler are powerless here.
- ๐ฆ Increasing the size of the APK: Native libraries are compiled for several architectures (
armeabi-v7a,arm64-v8a,x86), which increases the final size of the application. - ๐ Compatibility issues: Code running on Samsung Galaxy S23 (ARMv9) may crash on the old Xiaomi Redmi 4A (ARMv7) due to processor instruction mismatch.
Key takeaway: If your application does not require maximum performance or specific libraries, stay with Java/Kotlin. Native code is a tool for narrow tasks, not a silver bullet. For example, WhatsApp uses C++ to encrypt messages, but 90% of the application logic is written in Java.
Preparing the environment: what you need to install
To start developing in C for Android, you will need:
- Android Studio (latest stable version). This is the main IDE that supports NDK out-of-the-box.
- NDK (Native Development Kit). It can be downloaded via
SDK Managerin Android Studio or from the official website. - CMake (or ndk-build). CMake - a modern standard for building native projects in Android, but if you already have
Makefileyou can usendk-build. - Java JDK 11+. The native code interacts with the Java-part via JNI, so the JDK is required.
After installing the components, check their versions in the terminal:
# Checking the NDK version$ $ANDROID_NDK_HOME/ndk-build --version
Checking CMake
$ cmake --version
โ๏ธ Checking the environment before starting
Attention โ ๏ธ: Versions NDK and CMake must be compatible with your version Android Studio. data-i="105">may not work with NDK r25 may not work with Android Studio 4.0. Always check the requirements in documentation.
Creating the first project with native code
Let's start simple: add a native module to a standard Android application. Follow the steps:
- V Android Studio create a new project with an empty one Activity (
Empty Activity). - Go to
File โ New โ New C++ Class. Specify the class name (for examplenative-lib) and select the type.cpp. - Android Studio will automatically generate the files
native-lib.cpp,CMakeLists.txtand updatebuild.gradle.
Now let's look at the generated code in native-lib.cpp:
#include <jni.h>#include <string>
extern "C" JNIEXPORT jstring JNICALL
Java_com_example_myapp_MainActivity_stringFromJNI(
JNIEnv* env,
jobject / this /) {
std::string hello = "Hello from C++";
return env->NewStringUTF(hello.c_str());
}
This code demonstrates the basic working c JNI:
- Function
stringFromJNIis called from Javacode. JNIEnv*โ pointer to a structure that provides access to JNI functions (for example,NewStringUTFto create Java-strings from C++-strings).jobjectโ a reference to the object Javafrom which the native method was called.
To call this method from Java, add in MainActivity.java:
public class MainActivity extends AppCompatActivity {static {
System.loadLibrary("native-lib"); // Loading the native library
}
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
TextView tv = findViewById(R.id.sample_text);
tv.setText(stringFromJNI()); // Call the native method
}
public native String stringFromJNI(); // Declaration of a native method
}
If an error occurs during assembly undefined reference to 'function'check that the function name in .cpp completely matches what was generated JNI (including the package namespace). Use the utility javah to generate correct headers.
Build configuration: CMake vs ndk-build
Android supports two main ways to build native code: CMake and ndk-build. Let's look at their features and settings.
1. CMake (recommended method)
CMakeLists.txt is a configuration file that describes how to build your native code. Example of a basic configuration:
cmake_minimum_required(VERSION 3.10.2)project("my-app")
add_library( # Library name
native-lib
SHARED # Library type: SHARED (dynamic) or STATIC (static)
native-lib.cpp ) # Source files
find_library( # Finds a pre-built library
log-lib
log ) # Library for logging (liblog.so)
target_link_libraries( # Links library
native-lib
${log-lib} )
In build.gradle (module app) add a block for CMake:
android {...
externalNativeBuild {
cmake {
path "src/main/cpp/CMakeLists.txt"
version "3.10.2"
}
}
}
2. ndk-build (obsolete, but still in use)
If you already have Makefile, you can use ndk-build. To do this:
- Create a file
Android.mkin the directoryjni/:
LOCAL_PATH := $(call my-dir)include $(CLEAR_VARS)
LOCAL_MODULE := native-lib
LOCAL_SRC_FILES := native-lib.cpp
LOCAL_LDLIBS := -llog
include $(BUILD_SHARED_LIBRARY)
- Update
build.gradle:
android {...
externalNativeBuild {
ndkBuild {
path "src/main/jni/Android.mk"
}
}
}
Attention โ ๏ธ: C Android NDK r23 support ndk-build declared obsolete. Google recommends migrating to CMake, but older projects can still use ndk-build.
| Parameter | CMake | ndk-build |
|---|---|---|
| Support in new NDK | โ Full | โ ๏ธ Outdated |
| Integration with Android Studio | โ Automatic | โ ๏ธ Requires manual configuration |
| Configuration complexity | ๐ก Medium | ๐ด High (for complex projects) |
| Support for modern C++ standards | โ C++17, C++20 | โ Limited |
Working with JNI: data transfer between Java and C
Java Native Interface (JNI) is a bridge between Java/Kotlin and native code. It allows you to:
- ๐ค Pass primitives (
int,float) and objects (String, arrays) from Java to C. - ๐ฅ Return results from C back to Java.
- ๐ Call Javamethods from native code (for example, for callbacks).
Let's look at practical examples:
1. Passing primitives
Suppose you need to pass two numbers to native code, add them and return the result. B Java:
public native int addNumbers(int a, int b);
B native-lib.cpp:
extern "C" JNIEXPORT jint JNICALLJava_com_example_myapp_MainActivity_addNumbers(
JNIEnv* env,
jobject / this /,
jint a,
jint b) {
return a + b;
}
2. Working with Strings
To pass a string from Java to C and back, use jstring:
// Javapublic native String modifyString(String input);
// C++
extern "C" JNIEXPORT jstring JNICALL
Java_com_example_myapp_MainActivity_modifyString(
JNIEnv* env,
jobject / this /,
jstring input) {
const char *nativeString = env->GetStringUTFChars(input, nullptr);
std::string modified = "Modified: " + std::string(nativeString);
env->ReleaseStringUTFChars(input, nativeString); // Free up memory!
return env->NewStringUTF(modified.c_str());
}
A critical mistake of many beginners: not freeing memory after the call GetStringUTFChars. This leads to memory leaks that are difficult to catch!
3. Passing arrays
To work with arrays (for example, processing image pixels), use jintArray, jfloatArray etc.:
// Javapublic native void processArray(int[] array);
// C++
extern "C" JNIEXPORT void JNICALL
Java_com_example_myapp_MainActivity_processArray(
JNIEnv* env,
jobject / this /,
jintArray array) {
jsize length = env->GetArrayLength(array);
jint *body = env->GetIntArrayElements(array, nullptr);
for (int i = 0; i < length; i++) {
body[i] *= 2; // Double each element
}
env->ReleaseIntArrayElements(array, body, 0); // Save the changes
}
What will happen if you do not call ReleaseIntArrayElements?
If you do not release the array using ReleaseIntArrayElements, then the changes will not be saved to the original Javaarray, and a memory leak will occur. In some cases, this can lead to the application crashing with an error ANR (Application Not Responding).
Debugging native code: tools and techniques
Debugging native code on Android is a separate art. Here are the key tools and techniques:
1. Logging using __android_log_print
Analog Log.d() for native code. Add to native-lib.cpp:
#include <android/log.h>#define LOG_TAG "MyNativeLib"
#define LOGD(...) __android_log_print(ANDROID_LOG_DEBUG, LOG_TAG, __VA_ARGS__)
extern "C" JNIEXPORT void JNICALL
Java_com_example_myapp_MainActivity_logMessage(
JNIEnv* env,
jobject / this /) {
LOGD("This message will appear in Logcat!");
}
To see the logs, open Logcat in Android Studio and filter by tag MyNativeLib.
2. Debugging via LLDB
Android Studio supports debugging native code using LLDB:
- Set breakpoints in
.cppfiles. - Run the application in debug mode (
Debug). - When execution reaches native code, the debugger will automatically connect.
Attention โ ๏ธ: Debugging native code only works on ARMdevices or emulators with support ARM. On x86emulators, problems with debug symbols may occur.
3. Checking for memory leaks
To search for leaks, use Valgrind or the built-in NDK tool addr2line. For example, to analyze a memory dump:
$ adb pull /data/tombstones/tombstone_06
$ $ANDROID_NDK_HOME/toolchains/llvm/prebuilt/linux-x86_64/bin/llvm-addr2line -e libnative-lib.so -a -f -C 0000007123456789
| Tool | Purpose | Difficulty |
|---|---|---|
__android_log_print |
Logging in Logcat |
โญ |
| LLDB | Step-by-step debugging | โญโญโญ |
addr2line |
Analyzing memory dumps | โญโญโญโญ |
| Valgrind | Searching for memory leaks | โญโญโญโญโญ |
Optimizing native code performance
Native code itself is faster Java, but it can be optimized even more. Here are the key techniques:
1. Use NEON instructions for ARM
Processors ARM support NEON โa set of instructions for parallel processing of data (SIMD). For example, to speed up image processing:
#include <arm_neon.h>void processPixels(uint8_t* pixels, int width, int height) {
uint8x16_t vec = vld1q_u8(pixels); // Load 16 bytes in one operation
// ... vector processing
vst1q_u8(pixels, vec); // Save the result
}
2. Minimize transitions between Java and C
Each native method call from Java has overhead. Combine logic into large blocks. For example:
- โ Bad: Call the native method to process each pixel of the image.
- โ Good: Pass the entire array of pixels to the native code and process it there.
3. Compile for a specific architecture
Default NDK compiles code for all supported architectures (armeabi-v7a, arm64-v8a, x86). If you know for sure that your application will only run on ARM64, limit the build:
android {defaultConfig {
ndk {
abiFilters 'arm64-v8a' // ARM64 only
}
}
}
Optimization for a specific architecture can give a performance increase of up to 30%, but will reduce compatibility. Always test on target devices!
Publishing an application with native code on Google Play
Before publishing an application with native code on Google Play consider the following points:
1. APK/AAB size
Native libraries increase the size of the application. To reduce it:
- ๐๏ธ Use
strip --strip-unneededto remove debug symbols. - ๐ฆ Publish Android App Bundle (AAB) instead APK. Google Play will deliver only the ones needed to users architecture.
- ๐ Check dependencies: sometimes libraries (for example, OpenCV) drag along unnecessary
.sofiles.
2. Compatibility with 64-bit architectures
C August 1, 2019 Google Play requires that all applications support 64-bit processors. Make sure your build.gradle includes arm64-v8a:
android {defaultConfig {
ndk {
abiFilters 'armeabi-v7a', 'arm64-v8a', 'x86_64'
}
}
}
3. Security
Native code is more difficult to decompile, but not impossible. To protect:
- ๐ Use ProGuard/R8 for obfuscation Javacode.
- ๐ก๏ธ Use Obfuscator-LLVM for native code (for example, Ollvm).
- ๐ Store critical data (API keys) in Android Keystore, and not in native code.
Attention โ ๏ธ: Rules Google Play may change. Before publishing, check the current requirements in documentation.
FAQ: Frequently asked questions about C development for Android
Is it possible to write an entire application in C, without Java/Kotlin?
Technically yes, but it is extremely inconvenient. The Android system expects the application to have an entry point at Java/Kotlin (for example, Activity). Native code can perform the main logic, but without Java-wrapper the application will not start. The exception is games on engines like Unity or Unreal Engine, where Java-part is minimal.
What compiler is used in the NDK: GCC or Clang?
C NDK r17 (2018) GCC has been deleted and is now used only Clang. This means that older projects that depend on extensions GCCmay require some work. Clang is stricter on C++ standards, but provides better performance and support for modern features (for example, Is it possible to use C++17 or C++20 in the Android NDK? Yes, and newer are supported by default. C++17).
Is it possible to use C++17 or C++20 in Android NDK?
Yes, NDK r21 and newer support C++17 default. For C++20 will require manual configuration CMakeLists.txt:
set(CMAKE_CXX_STANDARD 20)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
However, keep in mind that not all features C++20 may be available due to restrictions Clang in NDK.
How to debug native code on real device?
For debugging on a real device:
- Enable
USB debuggingin the developer settings. - Connect the device to the PC and select it in Android Studio as a launch target.
- Set breakpoints in
.cppfiles. - Run the application in
Debug.
If the debugger does not connect, check that the debugger is installed on the device NDK symbols (ndk-syms).
What popular applications use native code?
Many high-performance applications use native code:
- ๐ฎ PUBG Mobile, Call of Duty Mobile โ graphics rendering in C++ (engine Unity).
- ๐ท Snapseed, Lightroom โ image processing with using OpenCV and own algorithms in C++.
- ๐ Signal, Telegram โ cryptographic operations (for example, NaCl in Signal).
- ๐ต Spotify โ audio decoding (library libspotify in C).
However, even in these applications, the native code covers only the critical parts, and the interface and business logic remain on Java/Kotlin.