Кратко расскажу о себе и о том, зачем возникла необходимость в подобном. Я более десяти лет пишу приложения под Android, около пяти лет под IOS, и сейчас переношу свои наработки под десктопы. Приложения мои предназначены для сисадминов, это SSH клиент, сетевые сканеры, утилиты. В общем, самое сложное — не сам интерфейс, а то, что спрятано под капотом. Все мои приложения состоят из двух частей: общего для всех систем ядра на С++ и платформозависимого интерфейса, написанного на Java/Swift/C++ в зависимости от системы.
Часть первая. Пишем обертку простого класса.
Итак, у нас есть некий CPP класс. Пускай его код будет таким:
class NativeClass { public: NativeClass() {} ~NativeClass() {} void fun() { __android_log_write(ANDROID_LOG_ERROR, log_tag, "Hello from native"); } };
Через JNI мы можем вызывать только С‑функции, то есть не объектный код. Так как же нам работать с ООП? Главная проблема — не столько вызовы, сколько хранение адреса объекта нативного класса. Лично для себя я нашел решение — хранить его в Java классе, как long. Функция инициализации имеет следующий вид:
JNIEXPORT jlong JNICALL Java_com_myprog_example_Native_initNativeClass(JNIEnv* env, jobject thiz) { return (jlong) new NativeClass(); }
C‑функции для работы с классом же принимают jlong одним из параметров:
JNIEXPORT void JNICALL Java_com_myprog_example_Native_NativeClassFun(JNIEnv* env, jobject thiz, jlong cppClass) { ((NativeClass*) cppClass)->fun(); }
Функция очистки очевидна, но приведу ее для полноты картины:
JNIEXPORT void JNICALL Java_com_myprog_example_Native_destroyNativeClass(JNIEnv* env, jobject thiz, jlong cppClass) { delete (NativeClass*) cppClass; }
В java мы делаем класс с нэйтив-импортами:
public class Native { static { System.loadLibrary(“Native”); public static native long initNativeClass(); public static native void destroyNativeClass(long cppClass); public static native void NativeClassFun(long cppClass); }
И класс‑обертку, которая хранит объект нативного класса и вызывает импортированные функции:
public class NativeClass { long cppClass; NativeClass() { this.cppClass = Native.initNativeClass(); } @Override protected void finalize() { free(); } private void free() { Native.destroyNativeClass(cppClass); } public void fun() { Native.NativeClassFun(cppClass); } }
Отлично! Мы получили Java‑класс, который полностью реализует взаимодействие с нативным классом: мы можем создавать любое кол‑во объектов, работать с ними и удалять.
Подытожим:
Мы переводим объектный код в функциональный и экспортируем функции в нэйтиве. Адрес объекта храним в jlong.
Одним из параметров функций, которые вызывают методы класса, ВСЕГДА будет long cppClass, тот самый адрес.
Удаление нативного объекта приурочено к очистке Java сборщиком мусора - вызову finalize, но вы можете изменить эту логику: сделать free public методом и удалять нативный объект вручную.
Часть вторая. Каллбэки из нэйтива.
Нативный код должен не просто делать что‑то внутри себя, но и возвращать результат. Когда это функция, которая отработала и сразу вернула значение вызывающему коду Java, все понятно, но что если в нэйтиве у нас работает, скажем, thread, который должен выдавать результат оболочке в процессе работы? Первый пример из моей практики, который приходит в голову — сканер сети. Пингует хосты различными методами, а при обнаружении — вызывает код оболочки, чтобы отобразить результат. Да, можно подождать завершения сканирования, а затем вернуть готовую коллекцию с результатами, но для реального приложения такой подход не годится, поэтому этот вариант мы сразу отбросим в пользу динамического отображения.
Я предлагаю доработать наш нативный класс, создав в нем имитацию полезной деятельности: вывод неких сообщений с интервалом из треда, которые мы попытаемся получить в Java оболочке. Также я предлагаю сразу же предусмотреть интерфейс‑листенер, и делать вызовы с передачей строк сразу из него.
class NativeClass { public: class Listener { public: virtual void print(const char* str) = 0; virtual ~Listener() {} }; private: bool started = false; Listener* listener = nullptr; static void worker(NativeClass* nc) { while(nc->started) { if(nc->listener != nullptr) { nc->listener->print("From native"); } sleep(1); } } public: NativeClass() {} ~NativeClass() { if(listener != nullptr) delete listener; } void setListener(Listener* listener) { this->listener = listener; } void scan() { started = true; std::thread thread = std::thread(worker, this); thread.join(); started = false; } void stop() { started = false; } };
Кратко пробежимся по тому, что имеем. Абстрактный класс Listener с передопределяемым методом print. Метод scan() запускает наше условное сканирование: тред, в котором с интервалом в секунду будет вызываться каллбэк. Не стоит брать этот код за основу реального проекта: как минимум придется позаботиться о том, чтобы избежать ситуаций, когда объект удаляется, а тред не закончил работу, к тому же при реальном сканировании нам, скорее всего, понадобится более одного треда, да и запускать эти потоки каждый раз при вызове сканирования — слишком затратно (при условии что вызываться скан будет более одного раза), пул потоков нам в помощь... Но не будем слишком критичны: сегодня наша задача разобрать концепцию, а не создать релизный вариант кода для загрузки на маркеты.
Возьмем на заметку, что вызов scan() подвешивает вызывающий поток.
Далее мы создаем экспортируемые функции, по аналогии с примером в первой части и Java обертку класса (часть с импортами я намеренно опущу чтобы не перегружать статью очевидным кодом).
Начнем с интерфейса NativeListener:
public interface NativeListener { void print(String str); } Далее сам класс-обертка нашего "сканера": public class NativeClass { long cppClass; NativeClass() { this.cppClass = Native.initNativeClass(); } protected void finalize() { free(); } private void free() { Native.destroyNativeClass(cppClass); } public void scan() { Native.NativeClassScan(cppClass); } public void stop() { Native.NativeClassStop(cppClass); } public void setListener(NativeListener listener) { Native.NativeClassSetListener(cppClass, listener); } }
Все предельно ясно, но только до момента, где начинается работа с листенером — setListener и все что касается обратных вызовов из нэйтива. Именно на реализацию этого функционала мы потратим больше всего времени.
В этой части статьи я приведу готовый код, коротко покажу, как его использовать и что улучшить для использования в реальном проекте, а в третьей части мы немножечко разберемся, что здесь происходит.
Важный момент: код требует использования ThreadPool, чтобы вызовы всегда происходили из ОДНОГО потока. Чтобы не городить огород и не прикручивать библиотеки, я сделаю код вызова абстрактным: ThreadPool::execute({наш код}). Также я намеренно не буду использовать список захвата или передавать вызову execute структуру с параметрами.
class Listener : public NativeClass::Listener { private: JavaVM* jvm; JNIEnv* env; jweak listenerRef; jmethodID printMethod; public: Listener(JavaVM* jvm, JNIEnv* env, jobject listener) { this->jvm=jvm; // создаю глобальную ссылку из того же потока, где имею локальную ссылку listenerRef=env->NewWeakGlobalRef(listener); // напоминаю, наш ThreadPool имеет ОДИН поток ThreadPool::execute({ // привязываемся к потоку, откуда будем делать вызовы в Java. jvm->AttachCurrentThread(&env, NULL); if(env != NULL) { jobject localRef=env->NewLocalRef(listenerRef); if(localRef != NULL) { jclass clazz =env->GetObjectClass(localRef); printMethod=env->GetMethodID(clazz, "print", "(Ljava/lang/String;)V"); // удаление локальной ссылки env->DeleteLocalRef(localRef); } } }); } ~Listener() { ThreadPool::execute({ env->DeleteWeakGlobalRef(listenerRef); jvm->DetachCurrentThread(); }); } void print(const char* msg) { ThreadPool::execute({ jobject localRef=listener->env->NewLocalRef(listenerRef); if(localRef != NULL) { jstring jMsg=env->NewStringUTF(msg); env->CallVoidMethod(localRef, printMethod, jMsg); // удаление стрингов env->DeleteLocalRef(jMsg); // удаление локальной ссылки env->DeleteLocalRef(localRef); } else { // объект был удален сборщиком мусора } }); } };
Код экспортируемой функции setListener:
JNIEXPORT void JNICALL Java_com_myprog_example_Native_NativeClassSetListener(JNIEnv* env, jobject thiz, jlong cppClass, jobject listener) { ((NativeClass*) cppClass)->setListener(new Listener(jvm, env, listener)); }
Обратите внимание, что нам потребуется объект jvm, получить который можно из JNI_OnLoad(JavaVM* vm, void* reserved) при загрузке нашей JNI либы.
И напоследок наш импорт функции setListener в Java классе Native:
public static native void NativeClassSetListener(long cppClass, NativeListener listener);
Код UI на Java я приводить в данной статье не буду. Приблизительный код для запуска нашего "сканера" будет таким:
NativeClass nc = new NativeClass(); nc.setListener(new NativeListener() { @Override void print(String msg) {...} }); nc.start();
О чем следует помнить:
Для реализации нам потребуется ThreadPool с одним потоком. Чуть подробнее о том, почему именно так, я расскажу в следующей части.
Предусмотреть удаление листенера в нэйтиве так, чтобы очередь ThreadPool исполнялась до конца.
В Java коде нужно предусмотреть хранение листенера, чтобы он не был удален сборщиком мусора. В коде нативного класса листенера мы используем слабую ссылку, создавая сильную локальную ссылку только на время создания/удаления/вызовов. Как вариант — хранить его вместе с NativeClass глобальной переменной в классе вызывающего кода (напомню, что Java пример выше — не руководство к действию, а лишь демонстрация).
Данная реализация Listener возвращает исполнение вызывающему нативному коду НЕ ДОЖИДАЯСЬ отработки Java‑функции. Если логика вашей программы требует гарантии завершения вызова перед возвратом либо возврата значения от Java вызова, вам придется позаботиться о синхронизации и добавлении condition variable. Т.е. подвешивать поток сразу после вызова ThreadPool::execute() и вызывать notify в самом конце кода, который отправляется в очередь ThreadPool.
В нашей реализации NativeClass вызов start() подвешивает вызывающий поток, поэтому делать его следует не из UI thread.
Часть третья. Разбираемся.
Учебный пример — это здорово, однако в реальном проекте вам наверняка потребуется реализовать листенеры с собственным набором функций. Вот ссылки на те страницы документации, к которым мы будем обращаться далее.
https://docs.oracle.com/en/java/javase/11/docs/specs/jni/types.html
https://docs.oracle.com/javase/7/docs/technotes/guides/jni/spec/functions.html
На первой странице мы можем видеть типы и их сигнатуры. На второй — функции JNI.
Теперь пробежимся по коду Listener.
Начнем с конструктора:
Создаем глобальную слабую ссылку на Java объект NativeListener, который принимаем параметром (см. NewWeakGlobalRef)
Отправляемся в наш единый поток, где будут происходить ВСЕ взаимодействия с Java.
Привязываемся к текущему треду — как раз тому, что крутится в нашем ThreadPool. Без вызова AttachCurrentThread мы не сможем корректно делать обратные вызовы. В ранних версиях я экспериментировал с вызовами AttachCurrentThread и DetachCurrentThread на каждый вызов каллбэка, но при множестве потоков/множестве таких вызовов это приводит к ошибке на отдельных устройствах (именно на отдельных, на некоторых это прекрасно работает). В итоге я остановился на реализации с единым тредом (один ThreadPool для одного Listener).
Создаем локальную ссылку и проверяем, создалась ли она (учитываем, что java объект мог быть удален сборщиком мусора при неправильно написанном Java коде). Во‑первых это проверка, во‑вторых — гарантия, что сборщик мусора не удалит java‑листенер пока мы делаем полезные действия.
Получаем jclass из нашего jobject: вызов GetObjectClass.
Из jobject получаем jmethodID для всех методов нашего Java листенера. В данном случае это void print(String). Именно здесь нам потребуются сигнатуры типов (см. третий параметр вызова GetMethodID), а страницу документации, где их найти, я показал выше.
Напоследок удаляем локальную ссылку.
Рассмотрим деструктор:
Отправляемся в наш единый поток.
Удаляем глобальную ссылку.
Открепляемся от потока.
Метод print:
Отправляемся в наш единый поток.
Создаем локальную ссылку
Получаем jstring из const char*
Вызываем java метод
Удаляем jstring переменную
Удаляем локальную ссылку объекта листенера.
Что потребуется вам для доработки кода под свои нужды? Во‑первых, получение jmethodID для всех методов. Ссылка с литерами типов JNI в помощь. Во‑вторых — если вызов в Java возвращает значение, вам потребуются вызовы Call[X]Method, где X — нужный тип. Список JNI функций вы найдете по второй ссылке, что я приводил выше.
Надеюсь, эта статья была полезной для вас. Если кого‑то заинтересует, могу написать о том, как аналогичным образом подружить С++ со Swift под IOS. Хотя там все намного проще, но нюансы все равно есть.
Ну а если остались вопросы, спрашивайте. При необходимости я дополню статью.
Комментарии (16)

denis_iii
02.04.2026 15:20Подход интересный, но на десктопе, наверное, уже проще Panama FFM использовать (Java 17+). Там можно передавать java-функции, как ссылки для upcall из нативного кода через MethodHandle. Классический пример с qsort с компаратором реализованный на java.

first_row Автор
02.04.2026 15:20За десктоп не скажу, этот подход для Android использую, где UI на java - не выбор, а данность. Для десктопных версий своих приложений пишу UI на C++, под gtk4.

SserjIrk
02.04.2026 15:20Может имеет смысл рассмотреть переход на flutter.
Там и биндинги к нативу присать проще и GUI для всех систем на одном языке получится.
first_row Автор
02.04.2026 15:20Я противник всяких фреймворков и тяжеловесных библиотек. Во-первых, все мои приложения весят считанные мегабайты, и я пребываю в откровенном недоумении когда вижу условный блокнот весом в 100мб. Во-вторых, я крайне не люблю брать сторонние библиотеки: это зависимость от чужого проекта, как от его дальнейшей поддержки, так и от лицензии и планов разработчика, которые могут измениться (допустим, решат брать плату за использование или кто-то их выкупит), исключением я считаю библиотеки криптопротоколов и тому прочее, что прямым образом связано с безопасностью. В-третьих (дополняя первый пункт), я сторонник минимализма: эффективно использовать память и дисковое пространство. В-четвертых, я уже создал собственные фреймворки для быстрого построения интерфейса под Java, Swift, gtk с одинаковым насколько это возможно API. Они хорошо протестированы и прекрасно работают, как и решение из статьи. Да, на это ушло время когда-то, зато теперь работать легко.
Опять же, я понимаю, что описанные выше пункты довольно дискуссионны, и многие компании прикручивают к своим проектам сотни библиотек ради одного единственного вызова и используют всевозможные фреймворки. Но, учитывая, что я занимаюсь собственными проектами, могу позволить себе путь, соответствующий моей идеологии :) Тем более, что именно за этим я и занялся программированием: чтобы решать интересные задачи, используя минимум.
Насчет эффективного использования дискового пространства и сторонних либ наброшу еще. Когда-то в своем сканере сети я реализовал функцию получения Netbios имен буквально в 20 строчек. На связь со мной вышел человек, который писал похожее приложение, чтобы узнать, как я это сделал, и по итогу... прикрутил библиотеку с полной поддержкой Samba протокола лишь ради этой функции. В дальнейшем, с доливанием собственной DNS либы для моих сетевых утилит, я усовершенствовал и эту функцию, тк там используется как раз DNS протокол, но вся моя собственная библиотека - это ~700 строчек.

SserjIrk
02.04.2026 15:20Беда в том что FFM пока нет в Andorid. В бета android 17 подтянули до java25, но на приемлемый процент пользователей это раскатится лет через 5 минимум.
А делать две обертки на одном и том же языке совсем не разумно.
SserjIrk
Не очень понятно. Чем такой подход лучше общепринятого.
На стороне java:
static HashMap<Long, Listener> listeners = new HashMap<>();
static void nativeEvent(long handle, String msg) {
Listener listener = listeners.get(handle);
if (listener == null){
return;
}
listener.print(msg);
}
Не нужно в нативе хранить ссылки на listener. Static функция всегда имеет один и тот же jmethodID, так что можно кэшировать и использовать везде.Нативу вообще ничего не нужно знать о всяких листенерах и живы они или нет.first_row Автор
У меня в нэйтиве много классов с различными листенерами, с разным набором параметров и возвращаемых значений. В вашем примере придется делать отдельный NativeEvent для каждой функции (которых даже в одном отдельном листенере может быть больше одной). Да и самих листенеров может быть много - какие-то из них необязательные и тд. В общем, если листенер один с одной единственной функцией - ваше решение, пожалуй, и правда будет рациональнее. Если нативных классов много и у каждого свой листенер со своим набором функций - моя реализация кажется мне удобнее.
Код этот создавал много лет назад. Помнится по-началу пилил что-то подобное вашему примеру, но в итоге пришел к решению в статье. Возможно, были и другие причины помимо описанной - сейчас уже точно не вспомню.
Спасибо за вопрос, возможно позже дополню статью пояснениями.
SserjIrk
Так как в статье упоминается android то "общепринятым" подразумевается подход google.
Если посмотреть исходники фреймворка то там так и есть.
static методы в которых первым идет int с типом коллбека и обычно куча еще параметров, который из натива чаще всего приходят как null.
Ну и потом портянки switch-case :)
first_row Автор
Не хочу оспаривать методы Google, но для собственных проектов мой вариант кажется мне красивее и удобнее, чем "портянки switch-case" и функция с кучей параметров. С точки зрения масштабирования - тоже. В проект могут добавляться новые классы с новыми листенерами, всякий раз менять кол-во параметров static функции и переписывать соответствующие участки кода? Не, я слишком ленив, да простит меня Гугл
Опять же, возвращаемое значение... Возвращать как Object, а потом плясать с бубном ради получения простого типа? Зачем, если большинство функций возвращают int, а одна-две - скажем, коллекцию или строку... в них как раз можно и заморочиться, а большую часть кода оставить простой и чистой
first_row Автор
Запоздало вспомнил еще одно преимущество моего метода: легкость переноса на другие системы. К примеру, С++ связывается со Swift без плясок с JNI, но общая структура кода остается похожей. Я просто беру, немного правлю нативный код, немного - код оболочки (да, перевод на другой язык, но логика сохраняется полностью), и вуаля - у меня уже аналогичные обертки под IOS. С протестированной логикой, что тоже весомый фактор по сравнению с тем, чтобы на каждой системе городить собственный огород и затем заново тестировать.
Чем ловить баги отдельно для каждой системы, лучше сделать код общим, насколько это возможно, такой у меня подход.
SserjIrk
Не нужно менять существующие функции.
Создаете новый класс и нативную обработку прямо в нем делаете.
Static поля и методы инициализируются в момент загрузки класса.
Т.е. даже если не создавали ни одного листенера и класс еще не загружался при поиске метода из натива все равно будет загружен класс и инициалированы static поле и метод.
Единственный минус что GC не может их удалить, пока ссылка лежит в HashMap.
Так что этот момент нужно обработаывать самому.
Все в одном месте и натив ничего не знает про листенеры кроме того что их класс может получать nativeEvent:
сlass Listener() {
private static HashMap<Long, Listener> listeners = new HashMap<>();
private static void nativeEvent(long handle, String msg){...}
private final long handle;
public Lisener(long handle) {
this.handle = handle;
listeners.put(handle, this);
}
public void cancel(){
listeners.remove(handle);
}
}
first_row Автор
Насчет переписывания: в ios у меня схожая реализация оберток, только проще, без игры с jni. В итоге при переносе достаточно почистить готовый код, логика (набор классов и методов для оберток, как в нативной части, так и в оболочке) остается прежний. Лично я считаю это преимуществом.
И если кол-во параметров можно сделать с запасом чтобы не переписывать при расширении, то вопрос с возвращаемыми значениями остается открытым. Скажем, у меня большинство каллбэков либо void, либо простые типы. Но есть и коллекции, и String. Возвращать Object для всех и получать из него простой тип в вызывающем коде каждого каллбэка - усложнение кода, как по мне
first_row Автор
Подытоживая, о вашем методе: общий вызывающий код, с единым jmethodid, НО прослойка с необходимостью поиска, switch case, наследования всех листенеров от единого и приведение к наследникам в коде вызова. Плюсом возвращаемое значение Object, необходимость доп манипуляций для получения простого типа. Параметры, кстати, тоже Object, что требует конвертации всякий раз даже если передаются простые типы.
О моем методе: вызывающий код и инициализация отдельная для каждого листенера, что можно назвать недостатком, но зато имеем непосредственный вызов методов листенера, никаких прослоек, не нужно наследоваться от общего класса листенера в java, логика полностью повторяет привычную и легко переносится на другие системы, никаких лишних преобразований типов.
SserjIrk
Я не очень понимаю что за проблема с "преобразованием и возвращаемым значением". Для каждого вызова можно сделать свои static функции которые будет получать или возвращать то что нужно.
Но главное в этом всем одно - ваш подход это только если вы сами автор и натива и java кода. И можете сразу все править для точного соответствия классов и натива.
Если ваш натив предполагает быть библиотекой для широкого использования или даже просто использоваться в нескольких собственных проктах то это породит кучу прослоек под каждый чих.
first_row Автор
Мой натив используется в нескольких собственных проектах, общая библиотека, чтобы не переписывать имена функций, просто создаю пакет и класс с импортами вида com.myprog.native, при пакете приложения, скажем, com.myprog.myappname, никаких проблем нет.
Но даже если имена бы менялись - при использовании обертки Java для cpp класса (начало статьи, без всчких каллбэков) все имена функций итак зависят от пакета, поэтому зависимость некоторая будет иметься всегда. Да и при несовпадении имен, разве трудно воспользоваться поиском с заменой, если это собственный проект…
Если же это является проблемой, то подход с оберткой едва ли применим в принципе, тут уже нужно пилить какое-то апи с номерами функций и общую функцию-обработчик, а не работать полноценно с cpp ООП из java. Тут уже зависит от целей, я лишь показал свой подход, который пользую в своих проектах и который удобен для меня.
Про кучу прослоек на каждый чих не понял, при доступе и к нативу и к Java коду.
Еще одно, пожалуй, самое важное преимущество моего подхода. В моих приложениях отдельные листенеры не только выводят результат, но и возвращают значения, которые получают от пользователя. То есть, вызывающий поток подвешивается пока пользователь не жмакнет по кнопочке. В моем случае сколько листенеров, столько и очередей выполнения с вызовом AttachCurrentThread. В вашем - очередь одна (чтобы сохранить преимущество общего вызывающего кода, можно конечно принимать в функции вызова и ThreadPool, но тогда пропадает главное преимущество вашего подхода - единоразовый инит и последующее обращение к общей функции)
Ps: преобразования типов - ну как минимум, лишнее действие на каждый чих, необходимость очистки, лишний код (а ведь главное, на мой взгляд, преимущество вашего подхода - вроде как меньший обьем). Не назову чем-то критичным, но все же, чем компактнее вызывающий код и меньше прослоек, тем лучше, имхо