Бруклинский мост. Зачем генерируем C++ на Kotlin
Нелегко на рынке найти senior разработчика для конкретной сферы. Каждый из них имеет уникальные знания в своем языке и фреймворке, будь это Java, Kotlin, С++, JS или Swift. Каждый накапливал свои знания годами. Но найти senior разработчика, который одинаково хорош и C++ и в Kotlin. Утром переписывает openssl, а по вечерам пинает мидлов за неправильное использование корутин, почти нереально.
Для каждого проекта нужны высококлассные специалисты, поддерживающие проект на плаву, развивающие его, предоставляя клиенту новые фичи, а заказчику большие деньги. Уход каждого специалиста для компании приносит новые убытки, но вот уход ndk специалиста может привести проект к краху. Ладно, не все так драматично. Если у вас все под контролем.
Почему вообще мы говорим о ndk, и для чего нужен мост C++ в Android. Мир нативных разработок и библиотек огромен, а некоторые вещи порой просто так не сделаешь полностью в jvm. Это может быть как минимум затратно, либо банально — неэффективно. Некоторые вещи просто не сделаешь без нативных библиотек: развитие собственного шифрования, создание кастомных бинарных типов файлов, реализация firewall, работа с нативной камерой и многое другое. Причем, как правило, для мобильщиков это выглядит так, что уже какая-нибудь unix команда разработала движок, протестировала и поделилась с мобильщиками. Мобильщикам же остается искать, как подобраться к этой библиотеке, не через bash же запускать.
Ткнуть палкой
На самом деле с bash тоже можно, но лучше напрямую fork + execl с предподготовленным pipe для обмена данных. Немного неэффективно, но рабочий вариант. Использование библиотек через отдельный процесс является способом, который стоит рассматривать. Этот подход изолирует процессы от неожиданных ошибок, утечек памяти, а также позволяет использование расширенные права (суперпользователь к примеру), но только в ограниченном контексте приложения, что дополнительно позволяет сузить поверхность атаки.
Все способы взаимодействия с C++, можно условно разделить на несколько категорий. Обобщим их в два. Все решения предполагающие работу через IPC должны быть реализованы через сериализацию моделей, так что первым способом будет реализация на основе сериализации. К требованиям данного способа является то, что оба языка должны одинаково поддерживать механизм сериализации: protobuf, cbor, json или другое. Второй же способ может быть реализован только в одном процессе, он подразумевает непосредственную работу с интерфейсами и методами классов и объектов. Для java подразумевается jni.
Для высоконагруженных алгоритмов каждая операция на счету. На телефонах же ставки повышаются еще и малопроизводительными процессорами. С учетом таких факторов, автор провел сравнительные тесты Protobuf vs Jni. И раз тут интриги нет, то jni показал неплохие результаты производительности. Но вот реализация требует большого опыта.
Горизонт луны
Между двумя сферами разработки возникла ни то связь, ни то неприязнь. Некоторые средства отладки нативной разработки не пристроишь к jvm, так и jvm разработку не натянешь на нативную. Все конечно же решается танцами с бубном, строгими правилами, отбитыми руками и просроченными релизами.
Схема работы JNI
Все начинается, как ни странно, с рефлексии. С использованием обфускаторов и оптимизаторов байт кода, любой метод может потеряться и в непредвиденном месте вызвать падение, прям в руках преданного пользователя.
jclass jCounterClass = env->FindClass("com/github/klee0kai/kotlinndktest/JniCounter");
jmethodID initMethod = env->GetMethodID(jClassIndex->jCounterClass, "", "()V");
Для kotlin разработчиков рефлексия может вызвать еще большую боль. При использовании вы можете теперь встретить различные преобразования типов в коде и в реалтайме. Тип kotlinx.Int
может быть представлен как примитивный тип int
, а вот kotlinx.Int?
будет представлен как java.lang.Integer
. Различные методы, поля, в особенности те, которые используют уникальные для котлина типы: UByte
, UInt,
могут в реалтайме приобрести уже уникальные имена.
Кроме рефлексии, вы можете столкнуться с проблемами контроля памяти, поиском утечек памяти. К примеру, для jvm можно выполнять слепки памяти, просматривать графы использования экземпляра класса до GC Root, в Android можно даже реализовать утилиту leakcanary.
Удержание объекта в MainActivity. Инструмент Memory Profiler.
В C++ мире отработаны механизмы подобно ASan. Но если у вас в одном процессе и C++ и jvm, то отследить утечки памяти сложнее. По jvm у вас объект удерживается сразу GC Root, а как это соотнести с кодом. ASan — тоже особо информации не предоставляет.
Объект, созданный в C++, не отслеживается в Memory Profiler
Подобные утечки ресурсов могут появится на ровном месте. Для любого маппинга модели из jvm в C++ и обратно может потребоваться копирование текстовых переменных, временных массивов и много другого. Все это проскочит ревью, не проявится на тестах, и только у пользователя с особым желанием потыкать приложение может выскочить ошибка памяти. Особым видам к таким ошибкам может быть к примеру использование указателей на объекты jobject
, jclass
не по ЖЦ. Это приводит не к привычной ошибке NPE, а к самой настоящей ошибке процесса SIGABRT
На солнечном парусе
Java Native Interface на самом деле можно уже воспринимать как мост между jvm и нативной библиотекой. В ней присутствуют все необходимые инструменты для вызовов методов и создания классов в JVM. Но это не RPC. Для продуктивной и стабильной сборки хотелось бы иметь в руках фреймворк, генерирующий конечные стабы, доступные к применению. Чтобы к примеру следующий класс
@JniMirror
object NdkEngine {
init {
Brooklyn.load("kotlinndktest")
}
fun helloFromJvm(): String = "hello from jvm"
external fun helloFromCppPro(): String
}
был представлен его отображением в нативной библиотеке.
class ComGithubKlee0kaiKotlinndktestNdkEngine {
public:
static std::string helloFromJvm();
static std::string helloFromCppPro();
private:
static jobject jvmSelf;
};
Вообще «Бруклин» возник не на пустом месте. Автору довелось неделями заниматься маппингом моделей из Kotlin в C++, проверяя все вызовы, все преобразования строк и массивов. В конченом итоге, написывая одно и тоже по несколько раз, формируется уже четкий шаблон однообразных действий. Но так как такого кода в любом случае получается много, и даже набитая рука сможет наделать ошибок. Лучше, если за нас будет писать плагин.
Каждая итерация компиляции проекта теперь позволяет отслеживать все изменения для дата классов и зеркалированных классов. Kotlin compiler plugin, подключаемый напрямую в gradle сборку, теперь самостоятельно генерирует файлы для индексирования jvm. Конечно мы не можем избавиться от падений в реалтайме при использовании рефлексии, но свести к минимуму, перенеся всю рефлексию только на этап инициализации библиотеки — можно.
int init(JNIEnv *env) {
if (comGithubKlee0kaiKotlinndktestNdkEngineIndex) return 0;
comGithubKlee0kaiKotlinndktestNdkEngineIndex = std::make_shared();
comGithubKlee0kaiKotlinndktestNdkEngineIndex->cls = (jclass) env->NewGlobalRef(
env->FindClass("com/github/klee0kai/kotlinndktest/NdkEngine"));
comGithubKlee0kaiKotlinndktestNdkEngineIndex->init1 = env->GetMethodID(
comGithubKlee0kaiKotlinndktestNdkEngineIndex->cls, "", "()V");
if (!comGithubKlee0kaiKotlinndktestNdkEngineIndex->init1) return -1;
comGithubKlee0kaiKotlinndktestNdkEngineIndex->helloFromJvm1 = env->GetMethodID(
comGithubKlee0kaiKotlinndktestNdkEngineIndex->cls, "helloFromJvm",
"()Ljava/lang/String;");
if (!comGithubKlee0kaiKotlinndktestNdkEngineIndex->helloFromJvm1) return -1;
comGithubKlee0kaiKotlinndktestNdkEngineIndex->helloFromCppPro1 = env->GetMethodID(
comGithubKlee0kaiKotlinndktestNdkEngineIndex->cls, "helloFromCppPro",
"()Ljava/lang/String;");
if (!comGithubKlee0kaiKotlinndktestNdkEngineIndex->helloFromCppPro1) return -1;
comGithubKlee0kaiKotlinndktestNdkEngineIndex->hashCode1 = env->GetMethodID(
comGithubKlee0kaiKotlinndktestNdkEngineIndex->cls, "hashCode", "()I");
if (!comGithubKlee0kaiKotlinndktestNdkEngineIndex->hashCode1) return -1;
comGithubKlee0kaiKotlinndktestNdkEngineIndex->toString1 = env->GetMethodID(
comGithubKlee0kaiKotlinndktestNdkEngineIndex->cls, "toString",
"()Ljava/lang/String;");
if (!comGithubKlee0kaiKotlinndktestNdkEngineIndex->toString1) return -1;
return 0;
}
А вот реалтайм ошибки типа No implementation found for
можно вообще не боятся. При любом переносе exported
метода генерируется новый интерфейсный метод для нативной библиотеки, и дальнейший проброс вызова в stub класс в C++.
extern "C" JNIEXPORT jstring JNICALL
Java_com_github_klee0kai_kotlinndktest_NdkEngine_helloFromCppPro(
JNIEnv *env,
jobject jObject
) {
brooklyn::bindEnv(env);
auto mirror = brooklyn::ComGithubKlee0kaiKotlinndktestNdkEngine(jObject);
auto jvmResultObj = mirror.helloFromCppPro();
return brooklyn::mapper::mapToJString(env, std::make_shared(jvmResultObj));
}
Остается последнее действие. Скорректировать последовательность сборки проекта, к примеру для Android проектов.
afterEvaluate {
val kotlinCompileTasks = tasks.filterIsInstance()
val cmakeTasks =
(tasks.filterIsInstance() +
tasks.filterIsInstance()
)
cmakeTasks.forEach { cmakeTask ->
kotlinCompileTasks.forEach { kotlinTask ->
cmakeTask.mustRunAfter(kotlinTask)
}
}
}
Сухой остаток
Надеюсь бруклинский мост кому-нибудь спасет горящие релизы. Главное позволит полноценно разделить подпроекты на самостоятельные тестируемые модули. Так kotlin код можно тестировать в kotlin, C++ код можно тестировать посредством GTest или других инструментов плюсов. Мост же уже протестирован в библиотеке, а значит ваше приложение будет ближе к стабильности.
Пет-проект ждет вашего участия. Планируем развивать инкрементальную сборку, поддержку обфускации и вложенных массивов.