Ошибка 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 у новичков.
☑️ Проверка перед первой сборкой
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 не вызывает немедленного падения — приложение аварийно завершится позже с ошибкой переполнения таблицы ссылок, и найти источник будет сложно. Освобождайте ссылки сразу после использования, особенно внутри циклов.
Сборка под разные архитектуры
Android-устройства используют разные ABI: arm64-v8a (большинство современных смартфонов), armeabi-v7a (старые 32-битные устройства), x86_64 (эмуляторы и часть планшетов). Нативная библиотека должна быть скомпилирована под каждую поддерживаемую архитектуру — иначе на части устройств приложение упадёт при загрузке .so.
Набор ABI задаётся в build.gradle:
defaultConfig {
ndk {
abiFilters "arm64-v8a", "x86_64"
}
}
Сравнение основных ABI:
| ABI | Разрядность | Где встречается |
|---|---|---|
| arm64-v8a | 64 бита | Современные смартфоны и планшеты |
| armeabi-v7a | 32 бита | Старые и бюджетные устройства |
| x86_64 | 64 бита | Эмуляторы, некоторые устройства |
| x86 | 32 бита | Устаревшие эмуляторы |
Какие 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-вызовы могут свести преимущество к нулю или даже замедлить работу.