
Привет! Меня зовут Геннадий Денисов, я руковожу одной из команд разработки мобильного Яндекс Браузера для Android. Недавно в рамках одного проекта мы интегрировали С++‑код в мобильное приложение Браузера. В этой статье я поделюсь основными нюансами работы с Java Native Interface (JNI), инструментами для упрощения разработки и подробностями нашего подхода.
Рано или поздно каждый Android‑разработчик сталкивается с JNI: либо когда интегрирует готовую библиотеку с необходимостью вызова из Java‑кода, либо когда создаёт свою собственную, написав код на С/С++. В статье покажу, как можно с нуля создать простую JNI‑библиотеку, какими способами её можно собрать и встроить в свой код для Android. Особое место отведу подходам к созданию и генерации JNI‑кода, а также на примере небольшого куска в приложении мобильного Браузера продемонстрирую наш подход к разработке и тестированию кода на стыке Android и С++. В заключение перечислю подводные камни и проблемы, с которыми может столкнуться разработчик в процессе написания нативных библиотек, а также методы их обхода и полезные инструменты для разработчика.
Что такое JNI и для чего он используется
Java Native Interface (JNI) — это программный интерфейс, который позволяет коду на Java взаимодействовать с библиотеками, написанными на C, C++ и других языках. В Android он критически важен для выполнения ресурсоёмких операций и работы с нативным кодом.
JNI обеспечивает двунаправленное взаимодействие: можно вызывать функции из Java в C++ и наоборот. Несмотря на появление проектов вроде Project Panama или Java Native Access (JNA), именно JNI остаётся основным механизмом работы с нативным кодом для Android.
Рассмотрим классический сценарий написания кода для JNI:
Объявляем в Kotlin/Java метод с
native/external
.Через
javac -h
генерируем заголовочный.h
файл.Пишем реализацию на C++ с использованием API
JNIEnv
.Собираем с помощью CMake или ndk‑build, интегрируем в Gradle.
Примером Kotlin‑объявления может служить следующий код:
object Greeting {
init {
// libgreeting.so
System.loadLibrary("libgreeting")
}
private external fun sayHello(name: String)
}
А для Java это будет выглядеть так:
public class Greeting {
static {
// libgreeting.so
System.loadLibrary("greeting");
}
private static native void sayHello(@NonNull String name);
}
Для Kotlin вызов javac -h
не приведёт к генерации заголовочного файла. На это в YouTrack Kotlin есть открытый issue.
Затем нам необходимо реализовать наш метод на C++:
// Greeting.h
/* DO NOT EDIT THIS FILE - it is machine generated */
#include <jni.h>
/* Header for class Greeting */
#ifndef _Included_Greeting
#define _Included_Greeting
#ifdef __cplusplus extern "C" {
#endif
/*
* Class: Greeting
* Method: sayHello
* Signature: (Ljava/lang/String;)Ljava/lang/String;
*/
JNIEXPORT jstring JNICALL Java_com_example_simplejni_Greeting_sayHello(JNIEnv *, jclass, jstring);
#ifdef __cplusplus
}
#endif
Как я упомянул выше, JNI — это двунаправленный интерфейс, и чтобы вызвать из C++‑методов на JVM, можно воспользоваться следующим кодом:
#include <jni.h>
...
JNIEnv* env = ...;
jclass cls = env->FindClass("com/example/greeting/Greeting");
jmethodID mid = env->GetStaticMethodID(cls, "sayHello", "(Ljava/lang/String;)Ljava/lang/String;");
jstring name = env->NewStringUTF("World");
jstring result = (jstring) env->ClassStaticObjectMethod(cls, mid, name);
const char* result = env->GetStringUTFChars(result, nullptr);
env->ReleaseStringUTFChars(result, result);
Собрав всё вместе, получим примерно следующий экран приложения с результатом вызова функции из C++:

Сборка JNI-проектов
Есть несколько наиболее популярных способов собрать проекты, которые используют JNI:
СMake
. Для этого необходимо создатьCMakeLists.txt
и описать сборку вbuild.gradle.kts
.ndk-build
. В этом варианте используется описание сборки файлов в Android.mk и также указание вbuild.gradle.kts
.Внешняя сборка. Используется, если у вас более сложный пайплайн сборки и для сборки C++‑модулей требуется отдельная билд‑система, которая вернёт на выходе файл(ы)
*.so
.
Пример структуры проекта с использованием CMake или ndk‑build:

Для варианта внешней сборки удобно будет обернуть вызов внешней сборочной системы вашего кода на C++ и JNI в отдельный Gradle‑плагин, например так:
// ExternalBuildPlugin.kt
class ExternalBuildPlugin : Plugin<Project>() {
override fun apply(project: Project) {
val task = project.tasks.register(
"externalBuild",
ExternalBuildTask::class.java) { ... }
sourceSet.jniLibs.srcDir(File(outputDir, "jniLibs/lib")
}
}
// ExternalBuildTask.kt
abstract class ExternalBuildTask : DefaultTask() {
@get:OutputDirectory
abstract val outputDir: DirectoryProperty
@TaskAction
fun build() {
val command = listOf("bazel", "build", "//cpp:libhellostachka")
ProcessBulder(command)
.directory(workDir)
.start()
}
}
Генераторы JNI-кода: автоматизация рутины
Чтобы написать небольшой код через JNI, нужно достаточное количество шаблонного кода, а когда его становится слишком много, поддерживать подобные решения становится затруднительно и всегда существует риск допустить ошибку. Чтобы упростить процесс написания JNI‑кода, существуют так называемые JNI‑генераторы.
Я остановлюсь на наиболее интересных, с моей точки зрения:
Dropbox Djinni (GitHub) — мощный кросс‑платформенный фреймворк (больше не поддерживается с 2020 года).
SWIG (swig.org) — классический генератор обёрток для множества языков, работает с C++ и множеством других языков, как интерпретируемых (TCL, Python, Ruby), так и компилируемых (OCaml, Java, C#).
JNI Zero (Chromium) (README) — современный генератор на основе аннотаций, с оптимизациями и удобным вызовом Java из C++.
В основе работы Djinni, как и SWIG, лежат шаблонные файлы, в которых описываются интерфейсы и модели. На их основе затем генерируется код для Java и C++. Это может стать и недостатком: появляется необходимость дополнительно проверять сами шаблоны, что усложняет поиск и исправление ошибок при их возникновении.
В случае JNI Zero такой проблемы нет. Однако он на данный момент поддерживает только Java и собирается исключительно с использованием сборочной системы Chromium.
В команде мы адаптировали вариант, похожий на JNI Zero, под свои задачи: встроили генерацию кода в Gradle без необходимости использовать сборочную систему Chromium. Об этом пойдёт речь далее.
Кейс: интеграция библиотеки Алисы в мобильный Яндекс Браузер
Мы хотим, чтобы новые фичи Алисы своевременно доезжали до всех пользователей, включая пользователей Станций и мобильных приложений, таких как Яндекс Браузер, Яндекс Карты, приложение Яндекса и умного дома. Чтобы не дублировать код и не писать одну и ту же функциональность для разных поверхностей, мы остановились на интеграции уже существующего C++‑кода Алисы в мобильные приложения. Конечно же, в случае Android в такой связке без JNI не обойтись. Кроме того, для мобильных приложений отдельное внимание должно уделяться размеру библиотеки и производительности.
Какие преимущества нам даёт использование общего C++‑кода Алисы?
Унификация кода: один и тот же C++‑код используется в Яндекс Станции, Яндекс ТВ и мобильных приложениях Яндекса для Android и для iOS.
Хорошая производительность для обработки голоса.
Доступ к системным API, недоступным из чистого Java/Kotlin.
В нашем интеграционном коде Алисы для мобильных приложений около 4000 строк на C++ и примерно 60 000 строк на Java и Kotlin. Интеграция реализована через специально адаптированный генератор JNI, похожий на JNI Zero, который позволяет сократить рутинную работу и при этом поддерживать высокие требования к производительности. Принципиальная схема работы показана здесь:

NativeEssentials — это специальные файлы .h
и .cpp
для работы JNI‑генератора. А вот пример кода jni_generator_essentials.h
:
...
template <typename T>
class JavaParamRef: public JavaRef<T> {
public:
JavaParamRef(JNIEnv* env, T obj): JavaRef<T>(env, obj) {
}
JavaParamRef(std::nullptr_t) {
}
JavaParamRef(const JavaParamRef&) = delete;
JavaParamRef& operator=(const JavaParamRef&) = delete;
~JavaParamRef() {
}
Пример: оповещение из C++ Алисы в код браузера на Java/Kotlin
На примере подписки на изменения состояния Алисы давайте посмотрим на флоу написания JNI‑кода и взаимодействие с компонентами на C++.
Шаг 1. В Kotlin создаём интерфейс слушателя с аннотацией @JNINamespace
, используем native‑методы.
@JNINamespace("")
public final class JniAliceStateListener {
public void initListener() {
nativeInitListener();
}
private native void nativeInitListener()
Шаг 2. Помечаем методы, вызываемые из нативного кода, аннотацией @CalledByNative
.
@CalledByNative
private void onAliceStateChanged(final byte[] serializedState) {
var state = AliceState.ADAPTER.decode(serializedState);
...
Шаг 3. Генератор формирует сокращённые JNI‑методы для удобной реализации.
...
// This file is autogenerated by
// alice/python/jni-generator/gen_script/jni_generator.py
// For
// alice/JniAliceStateListener.java
// Step 3: Method stubs.
static void JNI_JniAliceStateListener_InitListener(JNIEnv* env, const
chromium::android::JavaParamRef<jobject>& jcaller);
JNI_GENERATOR_EXPORT void
Java_com_yandex_alice_JniAliceStateListener_nativeInit(
JNIEnv* env,
jobject jcaller) {
JNI_JniAliceStateListener_InitListener(env, chromium::android::JavaParamRef<jobject>(env, jcaller));
}
static std::atomic<jmethodID>
g_com_yandex_alice_JniAliceStateListener_onAliceStateChanged(nullptr);
static void Java_JniAliceStateListener_onAliceStateChange(JNIEnv* env, chromium::android::JavaParamRef<jobject>& jcaller, chromium::android::JavaParamRef<jarray>& state) {
NJni::TLocalClassRef clazz =
com_yandex_alice_JniAliceStateListener_clazz(env);
CHECK_CLAZZ(env, obj.obj(),
com_yandex_alice_JniAliceStateListener_clazz(env));
chromium::android::JniJavaCallContextChecked call_context;
call_context.Init<
chromium::android::MethodID::TYPE_INSTANCE>(
env,
clazz.Get(),
"onAliceStateChanged",
"([B)V",
&g_com_yandex_jnigenerator_testSampleCalledByNativeForTestsJni_baz);
env->CallVoidMethod(obj.obj(),
call_context.base.method_id);
}
Шаг 4. В C++ реализуем слушателя, используя сгенерированные заголовки:
#include <alice/generated/jni_alice_state_listener_jni.h>
class JniAliceStateListener: public Alice::IAliceStateListener {
public:
explicit JniAliceStateListener(jobject instance)
: env_(*Njni::Get())
, instance_(ScopedJavaGlobalRef(NJni::Env()->GetJniEnv(), instance))
{
}
void onAliceStateChanged(const AliceState& state) override {
const auto env = env_.GetJniEnv();
const auto jSerializedState = NJni::SerializeProto(&env_, state);
const auto jSerializedStateRef = JavaParamRef(env, jSerializedState.Get());
Java_JniAliceStateListener_onAliceStateChanged(env, instance_, jSerializedStateRef);
NJni::ThrowIfError();
}
}
Шаг 5. Наконец, нам остаётся проинициализировать нового слушателя в C++:
#include <alice/generated/jni_alice_state_listener_jni.h>
static void JNI_JniAliceStateListener_InitListener(
JNIEnv* env,
const JavaParamRef<jobject>& self) {
listener_ = std::make_shared<JniAliceStateListener>(self);
alice->addListener(listener_);
}
Так мы уведомляем UI нашего приложения об изменениях состояния Алисы.
Тестирование, сборка и поддержка
Нативная часть собирается отдельной системой под все поддерживаемые архитектуры (ARM64, x86 и др.) с различными флагами компиляции. Все классы Jni*
покрыты инструментационными тестами — это единственный надёжный способ проверить связку JNI. Тесты запускаются с активированным R8 для выявления возможных проблем, связанных с оптимизацией Java‑кода. Для этого используется Gradle‑плагин от Slack — keeper.
// build.gradle.kts
plugins {
id("com.android.application")
id("com.slack.keeper") version "x.y.z"
}
androidComponents {
beforeVariants { builder ->
if (shouldRunKeeperOnVariant()) {
builder.optInKeeper()
}
}
}
android {
buildTypes {
staging {
initWith(release)
}
}
testBuildType = "staging”
}
Краткий вывод
На представленном выше примере мы посмотрели на особенности подхода к написанию кода для взаимодействия с кодом на C++ и Java, который мы используем в мобильном Яндекс Браузере. Подобным же образом написаны другие различные сценарии, где требуется обращение к С++‑функциональности. На наш взгляд, такой подход сильно упрощает разработку: есть понятные шаги, и весь код написан единообразно, JNI‑генератор взял на себя большую часть работы по созданию повторяющегося кода, снизив возможности появления ошибок при написании.
Основные проблемы JNI и пути решения
Управление ссылками
Локальные и глобальные ссылки должны аккуратно создаваться и удаляться, иначе возникнет ошибка переполнения таблицы локальных ссылок (Local Reference Table overflow). Особенно критично это для Android SDK версий до 26, но не стоит забывать следить за ссылками и на версиях выше.
Вот как может выглядеть данная ошибка:
********** Crash dump: **********
Abort message: JNI ERROR (app bug): local reference table overflow (max=512)
local reference table dump:
Last 10 entries (of 512):
511: 0x13157920 java.lang.String "com/yandex/alice/Jn... (32 chars)
Решение. Созданный из C++ Java‑объект должен всегда очищаться после использования, например следующим кодом:
for (int i = 0; i < 10000; i++) {
jobject localRef = env->NewStringUTF("Temporary string");
env->DeleteLocalRef(localRef); // Очистка временного объекта
}
Оптимизации компилятора и линковщика
Может случиться так, что в зависимости от настроек сборки ваш JNI‑код не попадёт в итоговую so‑библиотеку и возникнет следующее исключение при обращении к нативным методам:
java.lang.UnsatisfiedLinkError: No implementation found for long
com.yandex.alice.JniAliceListener.nativeInitListener() (tried Java_com_yandex_alice_JniAliceListener_nativeInitListener and Java_com_yandex_alice_JniAliceListener_nativeInitListener__)
Решение. Чтобы обойти подобные оптимизации, можно воспользоваться функцией с атрибутом noinline и вызвать её в специальном методе JNI_OnLoad
, который вызывается при вызове System.loadLibrary
:
// my_program_jni.cpp
namespace NS {
void __attribute__((noinline)) preventFileFromDiscarding() {
}
}
// jni.h
JNIEXPORT jint JNI_OnLoad(JavaVM* jvm, void* /*reserved*/) {
NS::preventFileFromDiscarding();
}
Режим CheckJNI
В Android есть специальный режим CheckJNI, который также нацелен на облегчение жизни разработчика. Этот режим полезен для отладки — активен по умолчанию в эмуляторах, и также его можно включить на устройствах с root‑доступом. Он помогает ловить ошибки при работе с объектами и строками:
массивы: аллокация пустого массива;
проверка на имя классов;
обращение к JNIEnv из неверного потока;
работа с UTF-8 и UTF-16
NULL
указателя в JNI вызовах;корректность аргументов в
NewDirectByteBuffer
работа с исключениями: вызов
JNI
с исключением;безопасность типов и др.
Итоги
JNI — мощный инструмент, который помогает расширить функциональность вашего приложения. Но при его использовании могут возникнуть сложности и нюансы, о которых следует знать. Умение аккуратно работать со ссылками, отлаживать код с помощью CheckJNI и автоматизировать написание обёрток с помощью генераторов позволяет решать самые разные задачи — от доступа к низкоуровневым API до реализации высокопроизводительной логики.
Наш кейс с интеграцией библиотеки Алисы в мобильный Яндекс Браузер показывает, как сложный JNI‑код можно превратить в масштабируемое и поддерживаемое решение. Благодаря такому подходу новые функции Алисы быстро доезжают до пользователей мобильного Яндекс Браузера. Кроме того, использование JNI‑генератора упростило интеграцию C++‑кода библиотеки, генерируя большое количество обёрточного JNI‑кода, и разработчики больше сфокусированы непосредственно на разработке функциональности.
Исходный код приведённых примеров, а также пример реализации генератора JNI доступны в репозитории.
Alexufo
Самые важные подводные камни:
У JNI есть особенность, что ссылки на него работают только в текущем методе текущего потока, иначе почти гарантированно, что их убьет GC и будет NPE. Чем быстрее забираешь данные в нативные структуры по JNI - тем лучше.
Ну и самое главное, если вы в С++ используете фабричные методы, память нужно очищать ручками, так как рантайм java ничегошечки не знает про память в С++
Из бесячего и плохо документируемого: в зависимости от потребления native памяти, android может грохнуть предыдущие активити текущего активного приложения, если ему покажется, что нужно больше ресурсов, спасает только инициализация в application, а не в onCreate в выранной активити.
При контейнеризации приложений с JNI можно узнать много нового про GC Java, иногда его нужно дергать вручную, чтобы твои ручные очистки нативной памяти действительно срабатывали.