Привет! Меня зовут Геннадий Денисов, я руковожу одной из команд разработки мобильного Яндекс Браузера для 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:

  1. Объявляем в Kotlin/Java метод с native/external.

  2. Через javac -h генерируем заголовочный .h файл.

  3. Пишем реализацию на C++ с использованием API JNIEnv.

  4. Собираем с помощью 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 доступны в репозитории.

Полезные ссылки

Комментарии (0)


  1. Alexufo
    23.09.2025 07:29

    Самые важные подводные камни:
    У JNI есть особенность, что ссылки на него работают только в текущем методе текущего потока, иначе почти гарантированно, что их убьет GC и будет NPE. Чем быстрее забираешь данные в нативные структуры по JNI - тем лучше.

    Ну и самое главное, если вы в С++ используете фабричные методы, память нужно очищать ручками, так как рантайм java ничегошечки не знает про память в С++

    Из бесячего и плохо документируемого: в зависимости от потребления native памяти, android может грохнуть предыдущие активити текущего активного приложения, если ему покажется, что нужно больше ресурсов, спасает только инициализация в application, а не в onCreate в выранной активити.

    При контейнеризации приложений с JNI можно узнать много нового про GC Java, иногда его нужно дергать вручную, чтобы твои ручные очистки нативной памяти действительно срабатывали.