В этой статье я расскажу, какие инструменты для вызова нативного кода существуют для
Kotlin Multiplatform, и как я сделал собственный Gradle-плагин для генерации биндингов к C/C++ под JVM, Android, Native и JS.
Предыстория
Совсем недавно, по долгу службы, пришлось иметь дело с интеграцией языка Rust в проект, написанном на Kotlin Multiplatform - там используется инструмент под названием UniFFI.
Его суть заключается в том, что мы один раз описываем интерфейс взаимодействия в специальном файле (udl), реализуем его в Rust, и после этого у нас в распоряжении есть целый зоопарк из генераторов "биндингов" под разные языки - в том числе и под Kotlin Multiplatform (Gobley).
Взглянув на всё это, мне пришла в голову идея - я же знаю как можно сделать всё более производительным и компактным - может мне попробовать сделать свою реализацию?
Я не большой фанат Rust, поэтому в качестве абонента на том конце нативного мира я выбрал обычный Си.
Тем более, недавно у меня была потребность использовать код на Си как в Kotlin/JVM, так и в Kotlin/Native.
Кому это надо?
Тем, кто хочет получать от Kotlin Multiplatform больше, чем ему даёт встроенный функционал. Лично я сталкивался с отсутствием мультиплатформенной реализации OpenCL и CUDA.
Больше про преимущества нативного кода можно узнать из докладов Андрея Паньгина на разных конференциях, посвященных Java.
С чем мы работаем?
Что у нас есть в Kotlin Multiplatform для вызова нативного кода?
Давайте разберем:
JNI
API в JVM, которое появилось ещё в древние времена Java. У него достаточно большие накладные расходы на вызов функций - эта проблема решалась так называемым Critical режимом, но оно было удалено из современных версий JVM.
Для Android тоже используется JNI, но там как раз имеется возможность "ускорять" вызов через специальные аннотации - @FastNative и @CriticalNative
Foreign Function & Memory API (Project Panama)
Современное решение от разработчиков JVM для вызова нативных функций. Также имеет некоторые накладные расходы, схожие с JNI, но вместе с этим имеет также и официальный Critical режим вызова, который не будет удален в будущем.
Nalim
Также одно из недавних решений по исполнению нативного кода в JVM, автором которого является вышеупомянутый Андрей Паньгин. Это решение является самым производительным, но при этом самым ограниченным - нужны специальные ключи запуска JVM, есть проблемы с лицензией, и можно использовать только примитивные типы.
В рамках этого проекта я решил не использовать его. (пока что)
cintrop
Специальный инструментарий для Kotlin/Native, который позволяет импортировать Си-заголовки в Kotlin, и работать с памятью почти без накладных расхо��ов.
jinterop
Не связана напрямую с кодом на Си, но позволяет вызывать сторонние функции в мире JavaScript. Нам это будет полезно с компиляцией Си в WASM, используя Emscripten.
И что теперь?
Выше я описал целых 5 разных способов вызова нативных функций из Kotlin Multiplatform.
Для каждого из них нужно писать свой собственный код связывания.
Неужели нужно всё писать руками?
Нет! Кодогенерация!
Я решил взять решение у UniFFI, который упоминался в начале статьи - у нас будет файл с описанием интерфейса взаимодействия, по которому и будет генерироваться реализация для каждой платформы под Kotlin Multiplatform. А для Си мы будем генерировать заголовок с функциями для реализации.
Для формата описания я взял WebIDL - этот формат задумывался для связки JavaScript с нативом в браузерах, но подойдет и нам. Более того, UniFFI тоже использует его модификацию.
В итоге получился плагин для Gradle, который может использовать CMake проект на C/C++, и без особых проблем делать так, чтобы нативный код запускался на всех таргетах Kotlin - JVM, Android, Native, и даже JS!
Использование
Например, сделаем функцию, которая будет принимать на вход строку, и отдавать строку.
Создаём Kotlin Multiplatform проект, подключаем этот плагин, и декларируем нативный проект, который назовем myNativeLib:
plugins { id("com.huskerdev.native-kt") version "1.0.1" } natives { create("myNativeLib") }
Теперь можем запустить Gradle таску :cmakeInitMyNativeLib, которая создаст простой проект с CMakeLists.txt и файлом api.idl.
Это всё можно было сделать и вручную, но так проще.
Далее переходим в файл api.idl, и описываем нашу функцию:
namespace global { DOMString myFunction(DOMString arg); };
Обратите внимание - функция описывается в `namespace global`, и вместо String используется какой-то DOMString.
Это всё ограничения формата описания WebIDL. В будущем, в плагине появятся кастомные типы, чтобы заменить встроенные, которые создавались специально для JavaScript.В остальном, этот формат очень похож на обычные Си заголовки.
Теперь перезагружаем Gradle проект, и вуаля!
В Kotlin теперь доступна функция myFunction из пакета natives.myNativeLib, а в нативном проекте появился заголовочный файл api.h для реализации в C/C++.
Осталось дело за малым - реализуем функцию в C:
#include "api.h" const char* myFunction(const char* arg) { return arg; }
Про время жизни всех указателей, которые приходят из Kotlin, можно прочитать в readme на GitHub, по ссылке в конце статьи.
И вызываем в Kotlin Multiplatform:
import natives.myNativeLib.myFunction import natives.myNativeLib.loadLibMyNativeLib suspend fun main() { // Асинхронно загружаем библиотеку 'myNativeLib' loadLibMyNativeLib() // Вызываем функцию val result = myFunction("Hello World!") // Выводим результат в консоль println(result) }
Обратите внимание на функцию
loadLibMyNativeLib- это функция обязательна перед вызовом каких-либо нативных функций из вашего проекта.Есть несколько её вариаций - синхронная и асинхронная.
В Kotlin/JS синхронная работать не будет из-за особенностей загрузки .wasm файла.В данном случае используется асинхронная suspend функция.
И при запуске из любого Kotlin таргета, мы получаем:
Hello World!
О Critical
Также и не забыл о возможности ускорять вызов функций за счет вышеупомянутых возможностей Critical.
Есть несколько ограничений:
Только примитивные типы
Не могут вызываться колбэки
Выполнение должно завершиться достаточно быстро, чтобы не блокировать GC
Включить его можно, добавив к функции аннотацию Critical:
namespace global { [Critical] void criticalFunction(); };
По моим не очень профессиональным замерам, время на накладные расходы снижается примерно в два раза.
На момент написания статьи, этот функционал в плагине реализован только для "Foreign Function & Memory API".
Итог
Вдохновившись UniFFI, у меня получилось создать некую "альтернативу" для Си.
Проект не пытается заменить UniFFI, но покрывает другую потребность: компактные, производительные биндинги к C/C++ для Kotlin Multiplatform без лишнего оверхеда.
Это было достаточно интересно, и думаю, некоторым пригодится этот плагин.
По крайней мере, сам я точно буду им пользоваться :-)
Ознакомиться с подробностями проекта можно на GitHub.
Это не финальная версия, а больше даже MVP.
Любая критика как по статье, так и по проекту приветствуется.
