Передать int через JNI — тривиально. Передать структуру с вложенными массивами, строками и мапами — это уже приключение. За последний год я перепробовал три подхода и нашёл тот, который работает в продакшене под нагрузкой. Спойлер: это не JSON.
Проблема
Представьте структуру данных, которую нужно передать из Kotlin в C++:
data class Detection(
val label: String,
val confidence: Float,
val bbox: Rect
)
data class Rect(
val x: Float,
val y: Float,
val width: Float,
val height: Float
)
data class FrameResult(
val frameId: Long,
val detections: List,
val metadata: Map
)
На C++ стороне аналогичная структура с std::vector и std::map. Как это передать через JNI? У нас есть три основных подхода, и каждый имеет свою цену.
Подход 1: Flatten to primitives
Самый быстрый, но самый уродливый способ. Раскладываем всё в плоские массивы примитивов. Для каждой структуры — отдельный набор параметров JNI-функции:
extern "C" JNIEXPORT void JNICALL
Java_com_example_Detector_processFrame(
JNIEnv *env, jobject,
jlong frame_id,
jfloatArray bboxes, // [x, y, w, h, x, y, w, h, ...]
jfloatArray confidences, // [0.95, 0.87, ...]
jobjectArray labels, // ["person", "car", ...]
jint detection_count
) {
jfloat *bbox_ptr = env->GetFloatArrayElements(bboxes, nullptr);
jfloat *conf_ptr = env->GetFloatArrayElements(confidences, nullptr);
for (int i = 0; i < detection_count; i++) {
float x = bbox_ptr[i * 4 + 0];
float y = bbox_ptr[i * 4 + 1];
float w = bbox_ptr[i * 4 + 2];
float h = bbox_ptr[i * 4 + 3];
float conf = conf_ptr[i];
// Передаём в C++ обработчик
detections.push_back({x, y, w, h, conf});
}
env->ReleaseFloatArrayElements(bboxes, bbox_ptr, JNI_ABORT);
env->ReleaseFloatArrayElements(confidences, conf_ptr, JNI_ABORT);
}
Плюсы — минимальный оверхед, максимальная скорость. Минусы — код превращается в нечитаемое месиво из индексов. Когда у меня было 15 полей в структуре, я несколько раз ошибся с индексами и ловил баги неделями. Не рекомендую для сложных структур.
Подход 2: JSON-сериализация
Самый простой способ. Сериализуем в JSON на Kotlin, передаём строку через JNI, парсим на C++. И обратно аналогично. Я использовал nlohmann/json на C++ и kotlinx.serialization на Kotlin:
#include "nlohmann/json.hpp"
using json = nlohmann::json;
extern "C" JNIEXPORT jstring JNICALL
Java_com_example_Detector_processJson(
JNIEnv *env, jobject, jstring json_str
) {
const char *raw = env->GetStringUTFChars(json_str, nullptr);
auto data = json::parse(raw);
env->ReleaseStringUTFChars(json_str, raw);
long frame_id = data["frameId"];
for (auto &det : data["detections"]) {
std::string label = det["label"];
float confidence = det["confidence"];
auto bbox = det["bbox"];
// ... обработка
}
// Формируем ответ
json response;
response["processed"] = true;
response["count"] = data["detections"].size();
return env->NewStringUTF(response.dump().c_str());
}
Просто? Да. Быстро? Нет. На структуре с 1000 детекций парсинг JSON занимал 2.5 мс, а сама обработка — 0.8 мс. То есть 75% времени уходило на сериализацию. Для прототипа — отлично, для продакшена — нет.
Подход 3: ByteBuffer — наш выбор
Это подход, который мы в итоге внедрили в продакшен. Идея: упаковываем структуру в binary layout внутри ByteBuffer с прямым доступом (ByteBuffer.allocateDirect). C++ получает указатель на память и читает напрямую, без копирования.
Сначала определяем binary protocol — фиксированный layout байтов. Каждое поле на известном оффсете:
// binary_protocol.h — общее описание для C++ и документация для Kotlin
// Frame layout (64 bytes header):
// 0-7: frame_id (int64)
// 8-11: detection_count (int32)
// 12-15: metadata_size (int32)
// 16-63: reserved / metadata kv pairs
// Detection layout (32 bytes each):
// 0-3: label_length (int32)
// 4-7: label_offset (int32) — relative to detections start
// 8-11: confidence (float32)
// 12-23: bbox (4 x float32)
// 24-31: reserved
struct DetectionBinary {
int32_t label_length;
int32_t label_offset;
float confidence;
float bbox_x, bbox_y, bbox_w, bbox_h;
int32_t reserved[2];
};
struct FrameHeader {
int64_t frame_id;
int32_t detection_count;
int32_t metadata_size;
uint8_t reserved[48];
};
Kotlin-сторона, которая пишет в ByteBuffer:
fun serializeFrame(frame: FrameResult): ByteBuffer {
val headerSize = 64
val detectionSize = 32
// Считаем общий размер для лейблов
val labelsTotal = frame.detections.sumOf { it.label.toByteArray(Charsets.UTF_8).size }
val totalSize = headerSize + frame.detections.size * detectionSize + labelsTotal
val buffer = ByteBuffer.allocateDirect(totalSize).order(ByteOrder.LITTLE_ENDIAN)
// Header
buffer.putLong(frame.frameId)
buffer.putInt(frame.detections.size)
buffer.putInt(0) // metadata_size
buffer.position(headerSize)
// Detections
val labelsStart = headerSize + frame.detections.size * detectionSize
var labelOffset = 0
for (det in frame.detections) {
val labelBytes = det.label.toByteArray(Charsets.UTF_8)
buffer.putInt(labelBytes.size)
buffer.putInt(labelsStart + labelOffset)
buffer.putFloat(det.confidence)
buffer.putFloat(det.bbox.x)
buffer.putFloat(det.bbox.y)
buffer.putFloat(det.bbox.width)
buffer.putFloat(det.bbox.height)
buffer.position(buffer.position() + 8) // reserved
labelOffset += labelBytes.size
}
// Labels blob
for (det in frame.detections) {
buffer.put(det.label.toByteArray(Charsets.UTF_8))
}
buffer.flip()
return buffer
}
C++ чтение из ByteBuffer
extern "C" JNIEXPORT jobject JNICALL
Java_com_example_Detector_processDirect(
JNIEnv *env, jobject, jobject byte_buffer
) {
uint8_t *ptr = (uint8_t *)env->GetDirectBufferAddress(byte_buffer);
jlong capacity = env->GetDirectBufferCapacity(byte_buffer);
FrameHeader *header = (FrameHeader *)ptr;
DetectionBinary *detections = (DetectionBinary *)(ptr + 64);
char *labels = (char *)(ptr + 64 + header->detection_count * 32);
for (int i = 0; i < header->detection_count; i++) {
DetectionBinary &d = detections[i];
std::string label(labels + d.label_offset, d.label_length);
printf("Frame %ld: %s (%.2f) [%.1f, %.1f, %.1f, %.1f]\n",
header->frame_id,
label.c_str(),
d.confidence,
d.bbox_x, d.bbox_y, d.bbox_w, d.bbox_h
);
}
// Возвращаем результат — тоже через ByteBuffer
jobject result = env->NewDirectByteBuffer(ptr, capacity);
return result;
}
Бенчмарк: сравнение подходов
Замерил на 500 детекциях, Ryzen 9, JVM 17. Flatten primitives: 0.12 мс на сериализацию + 0.08 мс на десериализацию. JSON: 2.4 мс сериализация + 2.1 мс десериализация. ByteBuffer direct: 0.03 мс сериализация + 0.01 мс десериализация. ByteBuffer в 4 раза быстрее, чем flatten, и в 200 раз быстрее, чем JSON. При этом код ByteBuffer чище, чем у flatten-подхода, потому что мы работаем с типизированными оффсетами, а не с голыми индексами.
Обработка std::vector и std::map
Для std::vector я пишу в ByteBuffer размер вектора, затем элементы подряд. Для std::map — записываю пары key-value как плоский массив: сначала число записей, затем чередование key_length + key_bytes + value_length + value_bytes. Да, это ручная сериализация, но она предсказуема и быстра.
Главное правило владения памятью: если вы создаёте ByteBuffer на C++ стороне через NewDirectByteBuffer — C++ владеет этой памятью, и вы должны гарантировать, что она жива, пока Kotlin её использует. В нашем продакшене мы используем пул pre-allocated буферов на C++ стороне, чтобы избежать аллокаций на hot path.
JNI для сложных структур — это не так страшно, когда у тебя есть правильный protocol. ByteBuffer-подход работает у нас уже 8 месяцев под нагрузкой, и ни одного краша по вине сериализации. Рекомендую.
Пока нет комментариев
Будьте первым, кто оставит комментарий к этой статье