Android разработка на C: полное руководство по NDK

Ошибка java.lang.UnsatisfiedLinkError при запуске приложения с нативной библиотекой — первое, с чем сталкивается почти каждый, кто начинает Android-разработку на C: имя функции в Java-обёртке не совпадает с сигнатурой в C-коде, либо библиотека не собрана под нужную архитектуру процессора. Проверка начинается с простого действия — сравнить объявление метода native в Java/Kotlin-классе с именем функции JNIEXPORT в исходнике на C.

Разработка под Android на языке C ведётся через Android NDK (Native Development Kit) — набор инструментов, который позволяет компилировать нативный код в библиотеки .so и подключать их к приложению. Ниже разберём, когда нативный код оправдан, как настроить окружение, собрать первую библиотеку и избежать типичных ошибок сборки и линковки.

Когда нужен C в Android-приложении

Нативный код не заменяет Java или Kotlin, а дополняет их. Приложение по-прежнему собирается как обычный APK или AAB, а C-код живёт внутри в виде разделяемых библиотек, которые загружаются вызовом System.loadLibrary().

Основные сценарии, где C даёт реальное преимущество:

  • 🔧 Высокопроизводительные вычисления — обработка аудио и видео, физика в играх, DSP-алгоритмы, где критична задержка.
  • 📦 Перенос существующих библиотек — зрелые C/C++ проекты (кодеки, движки, криптография) проще портировать, чем переписывать.
  • 🎮 Игровые движки — рендеринг через OpenGL ES или Vulkan традиционно пишется на C/C++.
  • 🔐 Защита кода — нативный бинарник сложнее декомпилировать, чем байт-код Java, что иногда используют для чувствительной логики.

Если задача — обычный интерфейс, сетевые запросы или работа с базой данных, нативный код только усложнит проект. Вызов через JNI сам по себе имеет накладные расходы, поэтому частые мелкие вызовы из Java в C могут оказаться медленнее чистого Kotlin-кода.

Установка NDK и настройка окружения

NDK устанавливается через SDK Manager в Android Studio: откройте Tools → SDK Manager → вкладка SDK Tools и отметьте NDK (Side by side) и CMake. После установки файлы появятся в каталоге SDK, в подпапке ndk. Точные названия пунктов могут немного отличаться между версиями Android Studio — ориентируйтесь на наличие NDK и CMake в списке инструментов.

Для сборки нативной части используется одна из двух систем: CMake (рекомендуемый вариант, интегрирован в шаблоны проектов) или ndk-build с файлами Android.mk. Новые проекты разумно начинать на CMake — он лучше документирован и привычен тем, кто уже писал на C вне Android.

В файле build.gradle модуля приложения нативная сборка подключается блоком:

android {

externalNativeBuild {

cmake {

path = file("src/main/cpp/CMakeLists.txt")

}

}

}

Структура проекта и первая библиотека

Типовая раскладка нативной части: исходники лежат в app/src/main/cpp/, там же находится CMakeLists.txt. В нём описывается библиотека, её исходные файлы и зависимости:

cmake_minimum_required(VERSION 3.22.1)

project(nativeapp C)

add_library(native-lib SHARED native-lib.c)

find_library(log-lib log)

target_link_libraries(native-lib ${log-lib})

Со стороны Java объявляется класс с нативным методом, а сама библиотека загружается в статическом блоке:

public class NativeBridge {

static {

System.loadLibrary("native-lib");

}

public native String stringFromJNI();

}

На стороне C функция должна следовать соглашению об именовании JNI: Java_ + полное имя класса + имя метода, где точки заменяются подчёркиваниями. Несовпадение имени функции с этой схемой — самая частая причина UnsatisfiedLinkError у новичков.

☑️ Проверка перед первой сборкой

Выполнено: 0 / 5

JNI: обмен данными между Java и C

JNI (Java Native Interface) — мост между виртуальной машиной и нативным кодом. Каждая JNI-функция получает указатель JNIEnv, через который выполняются все операции с Java-объектами: создание строк, вызов методов, доступ к полям.

Пример функции, возвращающей строку:

#include <jni.h>

JNIEXPORT jstring JNICALL

Java_com_example_app_NativeBridge_stringFromJNI(JNIEnv *env, jobject thiz) {

return (*env)->NewStringUTF(env, "Hello from C");

}

При работе с JNI важно помнить о нескольких правилах:

  • 🧵 Потоки — указатель JNIEnv действителен только в потоке, где он получен; для фоновых потоков его нужно получать через AttachCurrentThread.
  • 🧹 Локальные ссылки — объекты, созданные через JNI, накапливаются в таблице локальных ссылок; в длинных циклах их нужно освобождать через DeleteLocalRef.
  • 📋 Копирование данных — строки и массивы, полученные через GetStringUTFChars или GetByteArrayElements, обязательно освобождаются парными вызовами Release....
⚠️ Внимание: утечка локальных ссылок JNI не вызывает немедленного падения — приложение аварийно завершится позже с ошибкой переполнения таблицы ссылок, и найти источник будет сложно. Освобождайте ссылки сразу после использования, особенно внутри циклов.
📊 Для чего вы используете C в Android-проекте?
Портирование готовой C-библиотеки
Игры и графика
Обработка аудио/видео
Изучаю NDK из интереса

Сборка под разные архитектуры

Android-устройства используют разные ABI: arm64-v8a (большинство современных смартфонов), armeabi-v7a (старые 32-битные устройства), x86_64 (эмуляторы и часть планшетов). Нативная библиотека должна быть скомпилирована под каждую поддерживаемую архитектуру — иначе на части устройств приложение упадёт при загрузке .so.

Набор ABI задаётся в build.gradle:

defaultConfig {

ndk {

abiFilters "arm64-v8a", "x86_64"

}

}

Сравнение основных ABI:

ABIРазрядностьГде встречается
arm64-v8a64 битаСовременные смартфоны и планшеты
armeabi-v7a32 битаСтарые и бюджетные устройства
x86_6464 битаЭмуляторы, некоторые устройства
x8632 битаУстаревшие эмуляторы

Какие ABI включать — зависит от целевой аудитории. Требования Google Play к поддержке 64-битных архитектур со временем ужесточались, поэтому перед публикацией сверьтесь с актуальными правилами консоли разработчика.

Как проверить, какие .so попали в APK

Откройте собранный APK в Android Studio через Build → Analyze APK и разверните папку lib — внутри будут подпапки по именам ABI с библиотеками. Если какой-то архитектуры нет, приложение на таких устройствах не запустится.

Отладка нативного кода

Отладка C-кода в Android Studio работает через LLDB. Чтобы отладчик подключился к нативной части, в конфигурации запуска на вкладке Debugger выберите тип Dual или Native. После этого точки останова в C-файлах срабатывают так же, как в Java-коде.

Для диагностики падений нативного кода используется logcat: при краше в .so система выводит tombstone — стек вызовов с адресами. Адреса можно преобразовать в строки исходного кода утилитой ndk-stack или llvm-symbolizer из состава NDK, если библиотека собрана с отладочными символами.

Логирование из C-кода выполняется через библиотеку liblog:

#include <android/log.h>

__android_log_print(ANDROID_LOG_INFO, "MyApp", "value = %d", value);

⚠️ Внимание: релизные сборки с обфускацией и вырезанными символами делают нативные краш-логи почти нечитаемыми. Храните unstripped-версии библиотек для каждой опубликованной сборки — без них разбор падений пользователей превратится в гадание.

Типичные ошибки и их решение

Разберём проблемы, которые чаще всего возникают при разработке на C под Android.

UnsatisfiedLinkError — библиотека не найдена или функция не экспортирована. Проверьте имя в loadLibrary, наличие JNIEXPORT и корректность JNI-сигнатуры. Если используется C++, без extern "C" имена функций искажаются компилятором, и JNI их не находит.

Ошибки линковки вида undefined reference — в CMakeLists.txt не подключена нужная системная библиотека (например, log, android, OpenSLES). Добавьте её через find_library и target_link_libraries.

Падение только на реальном устройстве, хотя эмулятор работает, — возможная причина в отсутствии сборки под ABI устройства или в различиях поведения 32- и 64-битного кода. Проверьте список ABI в проекте и содержимое APK через анализатор.

Часто задаваемые вопросы

Можно ли написать Android-приложение целиком на C?

Полностью обойтись без Java/Kotlin практически нельзя: точка входа приложения, жизненный цикл Activity и системные API требуют Java-обёртки. Существует NativeActivity, позволяющая писать приложение преимущественно на C/C++, но даже в этом случае часть интеграций проще делать через JNI.

Чем C отличается от C++ в контексте NDK?

NDK поддерживает оба языка. C++ чаще используют в игровых движках и крупных библиотеках, C — в компактных модулях и при портировании классических Unix-библиотек. Для C++ важно не забывать extern "C" при экспорте JNI-функций.

Нужен ли отдельный компилятор для сборки нативного кода?

Нет, NDK уже содержит тулчейн на базе Clang для всех поддерживаемых архитектур. CMake автоматически подхватывает его через toolchain-файл, который передаёт Gradle при сборке.

Как вызвать Java-метод из C-кода?

Через JNI: получите класс функцией FindClass, идентификатор метода через GetMethodID, затем вызовите его функцией вида CallVoidMethod. Учтите, что такой вызов возможен только в потоке, присоединённом к виртуальной машине.

Ускорит ли нативный код любое приложение?

Нет. Выигрыш заметен в вычислительно тяжёлых задачах — обработке сигналов, графике, сжатии данных. Для типовой бизнес-логики накладные расходы на JNI-вызовы могут свести преимущество к нулю или даже замедлить работу.