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


    1. Gargoni
      23.09.2025 07:29

      Не стоит полагаться на GC для очистки нативных ресурсов, а лучше сделать явный метод Dispose. Ещё не каждый нативный поток может делать вызовы в java, нужно его перед этим зарегистрировать.


      1. Alexufo
        23.09.2025 07:29

        Я имел ввиду, что G1GC в моем случае сам не включается. Когда все в нативной памяти очищается, память контейнера все равно прет, пока в этот цикл не вставишь ручками System.gc() и все ровненько, но мне не очень нравится принудительная сборка в цикле, другого способа не нашел.


  1. sandersru
    23.09.2025 07:29

    Без упреков, но странно видеть JNI из 1997 года. Казалось бы после выхода Project Panama вся мобильная разработка должна была туда ломануться, вместо устаревшей технологии.

    Туда ломанулись, но не дошли до чего то готового к "боевому использованию" или мобильный мир еще просто не готов?


    1. vanxant
      23.09.2025 07:29

      Не знаком с этой вашей Панамой, JNI использовал в 2008, будучи разрабом на плюсах.

      Просто интересно, что там можно улучшить? Вы в любом случае пытаетесь скрестить ужа и ежа, и на выходе будет неизбежное корявое нечто (по мнению и ужа, и ежа). Попытки улучшить жизнь одной стороне неизбежно приведут к тому, что вот вам граждане кофейные результат в виде std::map<void*,std::vector<std::string>>, а дальше развлекайтесь с перекладыванием байтов в вашу обёртку как хотите.


      1. sandersru
        23.09.2025 07:29

        Улучшить можно все.

        И появлением JNA, и аннотаций в graalvm для прямого доступа к функциям нативных библиотек (и даже регистрам) а теперь и Panama, ну вас в гугле и на хабре забанили, читайте?

        А кофейные результаты и страшные структуры оставьте хреновым проектировщикам.


  1. vic_1
    23.09.2025 07:29

    А в андроид нет поддержки FFM API? Начиная с 17 java уже не надо париться с jni


  1. eugeneyp
    23.09.2025 07:29

    JNI может использоваться также для внедрения JVM в свой процесс. Т.е. можно писать игру на С++, и внутрь вставлять поддержку макросов на Java (но сейчас эту нишу занимает Lua).

    Также есть ограничение использования System.loadLibrary для связывания нативных методов и Java классов. Это связывание происходит только один раз при первой загрузке DLL/SO библиотеки. Т.е. если один и тот-же Java class грузить разными classloader то связывание будет только для первого класса.
    Как результат драйвера JDBC которые используют нативные вызовы надо класть в каталог контейнера (Tomcat и т.д.). Pure java JDBC Driver можно класть внутрь war/ear и т.д. файлов. Такая проблема отсутствует если использовать RegisterNatives.