Dev48
ЯЗЫК
  • О нас
  • Услуги
  • Индустрии
  • Технологии
  • Статьи
  • Контакты
Забронировать звонок
    Главная/Статьи/Razbor novogo sloya jni v expo modules 20
Dev48
info@dev48.org+375 29 320-40-96WhatsApp@isky_by

© 2026 · All rights reserved.

Разбор нового слоя JNI в Expo Modules 2.0

Источник: Expo Blog•6 ноября 2026 г. в 19:55

Разбор нового слоя JNI в Expo Modules 2.0

Kolibri — это JNI-библиотека, лежащая в основе Expo Modules 2.0 на Android. Вызовы объектов C++ работают до 51 раза быстрее, чем с fbjni.

Kolibri — это JNI-библиотека, лежащая в основе Expo Modules 2.0 на Android. Вызовы объектов C++ работают до 51 раза быстрее, чем с fbjni. Объекты JS попадают в Kotlin в 3–5 раз быстрее, так как Kolibri передает их в бинарном буфере вместо того, чтобы создавать их через JNI.

В первом знакомстве с Expo Modules 2.0 мы показали новый способ написания нативных модулей на iOS и упомянули, что версия для Android находится в разработке. Эта статья посвящена Kolibri — JNI-библиотеке, которую Expo Modules 2.0 использует на Android в качестве замены fbjni.

Кратко (TL;DR)

JNI — это самая медленная часть любого вызова между Kotlin и C++ на Android, а такие обертки, как fbjni, замедляют каждый вызов, поскольку все добавляемые ими функции безопасности требуют времени при каждом обращении. Kolibri, JNI-библиотека в основе Expo Modules 2.0, близка к «чистому» JNI и позволяет отключать эти функции там, где они не нужны. Она пропускает проверку исключений для методов, которые не могут их выбрасывать, и принимает JNIEnv в качестве аргумента вместо его поиска. Для гибридных объектов плагин компилятора Kotlin передает указатель C++ прямо в нативный код, а области видимости (scopes) освобождают все, что связано с JS-рантаймом, за один раз. Вместо создания объектов Java по одному за каждый JNI-вызов, Kolibri передает данные из JS в Kotlin через общий бинарный буфер. Результат: вызовы из C++ в Java работают так же быстро, как и «чистый» JNI; вызовы объектов C++ выполняются в 28–51 раз быстрее, чем с fbjni; а объект JS попадает в Kotlin через бинарный буфер в 3–5 раз быстрее, чем при создании через отдельные JNI-вызовы.

Kolibri состоит из трех частей: библиотеки на C++, которая зависит только от <jni.h>, небольшого рантайма Kotlin и плагина компилятора Kotlin, убирающего JNI-бойлерплейт во время компиляции. Прежде чем переходить к принципам работы, нам нужно разобрать несколько концепций. Если вы уже знакомы с JNI и fbjni, перейдите сразу к разделу Почему мы создали собственный инструмент.

Что такое JNI?

Интерфейс Java Native Interface (JNI) обеспечивает взаимодействие нативного кода с виртуальной машиной Java. Из C или C++ он позволяет вызывать методы Java, создавать объекты Java, читать и записывать их поля, а также изменять состояние виртуальной машины. Он также позволяет коду Kotlin и Java вызывать функции, написанные на C++.

JNI — это старый интерфейс, созданный в расчете на безопасность, а не на скорость. В типичном вызове между Kotlin и C++ именно этап JNI является самым медленным. На Android альтернатив нет: если C++ нужно обратиться к Java, используется JNI.

Вот как выглядит вызов одного метода с использованием «чистого» JNI. В данном случае у Java-списка запрашивается его размер:

Поиск метода по имени и строковой сигнатуре, проверка исключений после вызова и ссылка, которую нужно освободить в конце.

Ссылки JNI

C++ — это язык без автоматического управления памятью: вы сами выделяете память и сами освобождаете ее. Java устроена иначе: сборщик мусора сам решает, когда объект больше не нужен. Когда C++ хочет удержать объект Java, эти две модели должны где-то сойтись, и JNI соединяет их с помощью ссылок.

Ссылка JNI — это дескриптор, о котором знает сборщик мусора. Пока ссылка жива, лежащий в ее основе объект Java не может быть собран. Он зафиксирован на месте. Именно это делает безопасным использование объекта из C++. Но в этом и подвох: каждую созданную ссылку необходимо когда-то удалить, а в реальном коде их очень много.

В JNI есть два основных типа ссылок. Локальные ссылки предоставляются по умолчанию: каждый аргумент нативного метода и почти каждый возвращаемый JNI-функцией объект являются таковыми. Локальная ссылка действительна только во время текущего вызова нативного метода и только в том потоке, который ее создал. Глобальные ссылки вы создаете самостоятельно с помощью NewGlobalRef, если нужно сохранить объект после возврата из вызова или использовать его в другом потоке.

fbjni и jni-bind

Библиотеки-обертки делают JNI проще в использовании. Две самые популярные из них — fbjni от Meta и jni-bind от Google. Обе созданы в основном ради безопасности: они управляют ссылками за вас, поэтому все очищается автоматически.

Эти библиотеки используют разные подходы. jni-bind проще и сфокусирован на вызове Java из C++. fbjni более комплексная. Она также обрабатывает вызовы в обратном направлении — из Java в C++, а еще добавляет гибридные объекты: сущности, которые существуют по обе стороны границы и состоят из взаимодействующих частей на Java и C++. К ним мы вернемся позже.

В мире React Native стандартом является fbjni. Она поставляется с самим React Native, ее используют TurboModules и многие другие библиотеки в экосистеме.

Почему мы создали собственный инструмент

Kolibri сохраняет защитную сеть, но позволяет ее снять. На практике есть два подхода к написанию кода на Kolibri, и они отлично сочетаются друг с другом:

  • Вы знаете, что делаете. Вы отказываетесь от функций безопасности: никакого автоматического управления памятью и проверок исключений. Вы сами отвечаете за очистку всего и взамен получаете максимально быстрый код, который вообще допускает JNI.
  • Вы хотите иметь защитную сеть. Вы получаете те же возможности, что и в fbjni: управляемые ссылки, автоматические проверки исключений и т.д.

Реальному коду нужны оба подхода. Некоторые пути выполнения критически важны к скорости и должны работать максимально быстро. Другие сложны, производительность там не столь критична, и хочется свести риск утечки памяти к минимуму. fbjni — отличная библиотека, но переходить с «чистого» JNI на fbjni и обратно не очень удобно. В итоге приходится обходить ограничения библиотеки, поскольку она для этого не создавалась. А вот Kolibri — создавалась.

Возьмем ссылки. В Kolibri есть класс Ref, который владеет ссылкой JNI и удаляет ее при своем уничтожении. Вы бы использовали его при описании класса Java на C++:

Вам не нужно оборачивать greeter или name в Ref, чтобы вызвать greet. Kolibri принимает «сырые» ссылки JNI как есть. Это типичный случай для аргументов, поступивших из Java, или для ссылок, которые освободит что-то другое: вам не нужно, чтобы C++ управлял ими, вы хотите лишь передать их дальше и не тратить время на создание оберток. Kolibri отлично работает с этим паттерном и многими другими.

Чем Kolibri отличается от других

Kolibri написана на C++20, а ее API опирается на возможности современного C++. В сочетании с плагином компилятора Kotlin это позволяет ей делать то, что недоступно другим библиотекам.

Пропуск проверок исключений

В примере с «чистым» JNI за вызовом Java следует ExceptionCheck(). Когда метод Java выбрасывает исключение, JNI не генерирует исключение в C++. Оно остается в подвешенном состоянии, и его нужно запрашивать после вызова. Сама эта проверка является отдельным вызовом JNI, поэтому она тоже требует времени. По умолчанию Kolibri выполняет ее после каждого вызова и повторно выбрасывает отложенное исключение Java как исключение C++.

Многие методы Java никогда не выбрасывают исключений, и вы точно это знаете. Kolibri позволяет указать это явно. Она описывает метод Java с помощью двух шаблонных аргументов: его имени в виде строки и сигнатуры в виде обычной функции C++. Добавьте noexcept к типу этой функции, и сигнатура покажет, что метод не выбрасывает исключений. Вот как Kolibri описывает java.util.Iterator:

Метод hasNext() не выбрасывает исключений, поэтому проверка на них удаляется на этапе компиляции. Метод next() может выбросить исключение, поэтому проверка сохраняется. Это работает для многих методов стандартной библиотеки Java. Такой подход безопасен только для методов, которые действительно не могут выбросить исключение, но когда вы в этом уверены, вам не нужно платить за эту проверку временем выполнения.

Проверка не обходится дешево. В наших бенчмарках она добавляет около 24 нс к вызову ArrayList.size(), который без нее занимает 97 нс. Это четверть от времени всего вызова.

Явная передача JNIEnv

Каждая операция JNI проходит через JNIEnv — хэндл, который вы используете для взаимодействия с виртуальной машиной. Он принадлежит потоку, поэтому перед его использованием необходимо получить правильный экземпляр.

В fbjni это происходит автоматически. Когда функции нужен env, она сама получает текущий. Это удобно, но означает, что каждый вызов fbjni выполняет немного лишней работы для поиска env.

Гибридные объекты с областями видимости (scopes)

У Kolibri свой подход к гибридным объектам, который называется NativeObject. Концепция такая же, как в fbjni: объект Java и его аналог на C++, которые могут общаться друг с другом. Разница заключается в том, как они освобождаются: Kolibri добавляет концепцию области видимости (scope).

Область видимости — это группа объектов с общим временем жизни. При создании объекта C++ его можно привязать к области видимости: текущей или любой другой на ваш выбор. Когда область видимости аннулируется, Kolibri уведомляет каждый привязанный к ней объект, и каждый из них высвобождает ресурсы, зависящие от этой области.

Это важно для Expo Modules из-за того, как мы используем там гибридные объекты. Они дают Kotlin доступ к объектам JS. Получается три участника: состояние живет в JS, код Kotlin хочет с ним работать, а C++ связывает их воедино. Объект C++ хранит объект JS и является нативной половинкой объекта Kotlin. Они связаны, поэтому при удалении объекта Kotlin его часть на C++ тоже исчезает, а вместе с ней и объект JS.

Однако во многих случаях сторона JS исчезает первой. Например, перезагрузка уничтожает всю среду выполнения JS и каждый объект JS в ней, в то время как указывающие на них объекты Kotlin все еще ждут сборщика мусора.

Мы не можем ничего оставлять после себя. Если бы объект C++ сохранил свой объект JS после уничтожения среды выполнения, он содержал бы ссылку на нечто недействительное. Позже, при сборке мусора объекта Kotlin, объект C++ попытался бы очистить уже освобожденное значение JS. Это классическая ошибка use-after-free. Поэтому все, что связано со средой выполнения, должно быть освобождено при ее исчезновении.

Здесь на помощь приходят области видимости. Мы привязываем каждый из этих гибридных объектов к его среде выполнения JS, и когда среда уничтожается, мы аннулируем область видимости. Вот упрощенная версия класса, который мы используем в Expo Modules 2.0:

Гибридные объекты Kolibri изначально созданы с учетом этого, что делает задачу намного проще, чем в fbjni.

Более быстрые вызовы гибридных объектов

Вызовы гибридных объектов в Kolibri также происходят быстрее. Чтобы понять почему, давайте посмотрим, как они работают.

В C++ вы создаете объект и получаете указатель на него. Этот указатель передается в Java и сохраняется в поле объекта Java: обычный long, число, представляющее адрес. Объект Java владеет объектом C++.

Теперь предположим, что вы вызываете метод x на объекте Java, и x привязан к методу C++. JNI связывает нативные методы с обычными функциями без параметра this. Поэтому стороне C++ нужно как-то найти объект. Она знает как: указатель находится прямо там, в объекте Java. В классическом гибриде fbjni его получение требует еще пяти вызовов JNI: трех последовательных чтений полей и двух вызовов для удаления локальных ссылок, создаваемых первыми двумя чтениями. Затем указатель снова приводится к типу C++.

Kolibri использует плагин компилятора Kotlin, чтобы пропускать эти вызовы. Это та же самая переработка на этапе сборки, которая убрала рефлексию из Expo Modules в SDK 56. Вы пишете такой же код, какой написали бы с fbjni — внешний метод в классе Kotlin — и помечаете его аннотацией @NativeMethod:

Под капотом плагин переписывает метод так, чтобы указатель передавался в C++ в качестве первого аргумента:

На стороне C++ вы регистрируете метод-член напрямую, и Kolibri приводит указатель обратно к объекту без единого вызова JNI:

То же самое работает и для аргументов. Пометьте параметр, являющийся NativeObject, аннотацией @AsNativePointer, и его указатель пересечет границу аналогичным образом.

В наших бенчмарках это сокращает время вызова гибридного объекта с 76–138 нс в fbjni до 2,7 нс.

Передача данных в бинарном формате

Kolibri создана не только для связи между C++ и Kotlin. Ее главная задача — обеспечивать работу Expo Modules 2.0, где данные поступают из JS. Поэтому мы должны рассмотреть весь путь целиком.

Возьмем объект JS. Это хранилище ключ-значение, и на стороне Kotlin оно превращается в map. Обычный способ конвертации — построить этот map из C++: для каждого ключа и каждого значения создать его Java-эквивалент через JNI. Именно так поступают TurboModules и Expo Modules 1.0. Это работает, но создает множество вызовов JNI, а как показывает эта статья, JNI — самая медленная часть вызова.

Мы задались вопросом, обязательно ли делать так много вызовов, и провели бенчмаркинг альтернатив: различных форматов и разных способов передачи данных. Мы остановились на бинарном формате в прямом байтовом буфере (direct byte buffer). Прямой ByteBuffer — это объект Java, указывающий на нативную память, поэтому C++ и Kotlin работают с одними и теми же байтами.

Kolibri предоставляет примитивы для этого. Каждый поток получает один буфер, выделенный в C++ и предоставленный Kotlin в виде прямого ByteBuffer через BinaryBuffer.shared(). Kolibri также определяет бинарный формат: тегированные значения для булевых значений, чисел, строк, списков, мапов и массивов байтов, а также тег, который потребители могут использовать для собственных схем. Он поставляется с кодом для записи и чтения этого формата с обеих сторон.

Вот как Expo Modules 2.0 записывает объект JS в буфер на C++ (в слегка упрощенном виде):

Вот как Kotlin считывает его обратно из той же памяти:

Ни один из циклов не совершает вызовов JNI. Чтобы оценить выигрыш, мы измерили вызов из JS в функцию Kotlin, которая принимает один аргумент и возвращает Int. Мы передали аргумент двумя способами:

  • Создание объектов Java через JNI: C++ использует JNI для создания объекта Java для каждого значения: Double для каждого числа, Boolean для каждого булева значения, String для каждой строки, ArrayList для каждого массива и HashMap для каждого объекта. Каждая операция add и put — это еще один вызов JNI.
  • Запись в бинарный буфер: C++ записывает аргумент в буфер. Kotlin считывает его оттуда и создает те же объекты Java без единого вызова JNI.

Оба способа дают функции Kotlin одно и то же значение, и результат возвращается тем же путем, поэтому единственная разница заключается в том, как аргумент попадает в Kotlin. Мы измерили строки, объекты и массивы. Каждое число представляет собой время выполнения всего вызова от JS до Kotlin и обратно:

Именно этот формат Expo Modules 2.0 использует для передачи данных, и мы рассмотрим его работу более подробно в следующей статье.

Производительность

Чтобы оценить все эти преимущества, мы сравнили Kolibri с «чистым» JNI (raw JNI) и fbjni в обоих направлениях: C++ вызывает Java и Kotlin вызывает объекты C++. Все три подхода выполняют одинаковую работу с одними и теми же объектами, и все три выполняют поиск классов, методов и полей один раз заранее, так, как их кэширует реальный код. Чистый JNI принят за отправную точку (базовую линию).

Мы запускали всё на Apple M3 Pro с JDK 17. Для вызовов из C++ в Java каждое число представляет собой медианное время одного вызова за 10 раундов по 2 миллиона вызовов после прогрева. Мы запустили этот бенчмарк три раза (шесть раз для таблицы вызовов методов) и приводим медиану результатов. Вызовы из Kotlin в C++ измерялись с помощью JMH. Это десктопная JVM, а не устройство на Android, поэтому абсолютные значения времени на телефоне будут другими.

Вызовы объектов C++ из Kotlin

Здесь разница выражена сильнее всего. Каждый бенчмарк вызывает метод гибридного объекта, и этот метод возвращает значение, хранящееся в части C++.

В таблице во втором колонке также передается другой гибридный объект в качестве аргумента, поэтому стороне C++ приходится находить два объекта.

С плагином компилятора вызов объекта Kolibri обходится примерно во столько же, сколько написанный вручную нативный код JNI, передающий указатель, и в 28–51 раз дешевле, чем такой же вызов через fbjni. Чтобы найти объект C++, метод cthis() из fbjni считывает два или три поля. Все поля, кроме последнего, содержат объекты Java, поэтому каждое такое чтение создает локальную ссылку, которую затем необходимо удалить. Более новый подход HybridClassBase пропускает одно чтение и одно удаление, совершая три вызова JNI вместо пяти, и работает примерно в два раза быстрее классического.

Передача указателя также превосходит его ручное чтение с помощью GetLongField, но на десктопной JVM эта разница меньше: 2,7 нс против 4,1 нс.

Сам вызов тоже обходится дешевле. Статический нативный метод с двумя аргументами типа int и без объектов выполняется за 2,2 нс с чистым JNI и с Kolibri, и за 9,6 нс с fbjni.

Вызовы Java из C++

В этом случае Kolibri работает так же быстро, как чистый JNI: каждая строка отличается примерно в пределах 2%, что укладывается в погрешность бенчмарка. Там, где код чистого JNI проверяет исключения и удаляет локальные ссылки вручную, Kolibri делает ту же работу за вас без дополнительных затрат.

Самый большой разрыв с fbjni наблюдается на методе size(). Этот метод помечен как noexcept в Kolibri, поэтому проверка исключений вообще отсутствует, в то время как fbjni по-прежнему выполняет ее. Чтобы измерить саму проверку отдельно, мы также вызвали size() с добавленной обратно проверкой. Чистый JNI увеличивает показатель с 97 нс до 121 нс, а Kolibri — с 96 нс без noexcept до 121 нс с ним. fbjni, которая всегда выполняет проверки, тратит 134 нс.

Остальные повседневные операции выглядят следующим образом:

Kolibri отстает от чистого JNI не более чем на 5% в каждой строке, за исключением поиска JNIEnv, где она работает быстрее. fbjni оказывается медленнее во всех операциях, кроме конструктора, где она работает так же быстро, как чистый JNI.

Почему не улучшить fbjni вместо этого?

Kolibri создана специально под наши сценарии использования. Это не универсальная библиотека JNI. Она содержит только то, что нам было нужно, а ее API намеренно минимизирован. fbjni охватывает гораздо больше возможностей, большая часть которых нам не понадобилась. Например, в Kolibri практически нет оберток для классов java.lang. Если вам нужна такая обертка, вы пишете ее самостоятельно.

Достижение тех же результатов путем изменения fbjni потребовало бы масштабной переработки библиотеки. Kolibri решает те же задачи, что и fbjni, но с другим подходом к той же проблеме.

Компромиссы

Kolibri не бесплатна. React Native уже поставляется с fbjni, поэтому код на базе fbjni не требует дополнительных затрат: никаких новых библиотек и увеличения размера приложения. Kolibri — это еще одна библиотека в вашем приложении и еще одна библиотека, которую нам предстоит поддерживать.

Однако эти затраты меньше, чем кажется. Чтобы заставить fbjni работать с требуемой скоростью, нам пришлось бы во многих местах реализовывать обходные пути. Это тоже потребовало бы написания большого объема дополнительного кода, и итоговый размер приложения, вероятно, остался бы примерно прежним. В конце концов, мы считаем, что этот компромисс оправдан.

Что дальше?

Kolibri — это фундамент. В следующей статье мы рассмотрим, как на ее основе построена Expo Modules 2.0 и откуда берется остальной прирост производительности.

Если вы пишете нативные модули для Android, мы будем рады узнать ваше мнение в канале #creating-expo-modules в Discord-сообществе Expo.

← Все статьи

О чём эта статья

Ещё в разделе «Разработка ПО»

Все →
Отправка через API и проверка результатов стали в 2 раза быстрее
Datalab

Отправка через API и проверка результатов стали в 2 раза быстрее

Массовый SMS-маркетинг: как делать массовые рассылки
Sendinblue

Массовый SMS-маркетинг: как делать массовые рассылки

Microsoft планирует сократить использование услуг неооблаков на фоне признаков нагрузки при развертывании ИИПресса
Microsoft

Microsoft планирует сократить использование услуг неооблаков на фоне признаков нагрузки при развертывании ИИ

Как Lila Sciences и Anyscale масштабировали обучение GLM-5.3-Flash до 1 млн токенов
Anyscale

Как Lila Sciences и Anyscale масштабировали обучение GLM-5.3-Flash до 1 млн токенов

Готовый к промышленной эксплуатации распределенный инференс с помощью Ray Serve
Anyscale

Готовый к промышленной эксплуатации распределенный инференс с помощью Ray Serve

Anyscale в Azure: создание и развертывание ИИ в масштабе в вашем собственном арендаторе
Anyscale

Anyscale в Azure: создание и развертывание ИИ в масштабе в вашем собственном арендаторе

Ещё от Expo

Как один разработчик занял 2-е место в нидерландском App Store с образовательным приложением на Expo
Expo

Как один разработчик занял 2-е место в нидерландском App Store с образовательным приложением на Expo

Программа Apple Developer Program: бесплатная и платная версия
Expo

Программа Apple Developer Program: бесплатная и платная версия

Игра-головоломка про котиков на WebGPU, созданная на Expo за три дня с ИИ-агентом в роли отдела тестирования
Expo

Игра-головоломка про котиков на WebGPU, созданная на Expo за три дня с ИИ-агентом в роли отдела тестирования

Представляем @expo/agent-cli: CLI, созданный для агентов
Expo

Представляем @expo/agent-cli: CLI, созданный для агентов

Графический движок на Rust из 30 000 строк кода на Expo и 120 000 установок без рекламы
Expo

Графический движок на Rust из 30 000 строк кода на Expo и 120 000 установок без рекламы

Поддержка iPhone Duo с Expo: руководство разработчика
Expo

Поддержка iPhone Duo с Expo: руководство разработчика