Кратко расскажу о себе и о том, зачем возникла необходимость в подобном. Я более десяти лет пишу приложения под 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. Хотя там все намного проще, но нюансы все равно есть.
Ну а если остались вопросы, спрашивайте. При необходимости я дополню статью.
