Почему у React Native больше нет моста
Здесь будет встроен видео-разбор темы (5–7 минут, YouTube / RuTube / VK Видео) — тем, кто лучше воспринимает на слух, текст ниже не обязателен.
Прошлая статья кончилась обещанием: вызов метода нативного модуля на телефоне — не сообщение в очередь, а синхронный вызов функции на C++. Разберём, что за этим стоит. Сначала — мост, через который React Native жил до новой архитектуры: очередь чисел, номера вместо функций и ответ когда-нибудь потом. Потом — то, что его заменило: JSI, объект, у которого get написан на C++, и вызов, который возвращает значение сразу, — с ценой, которую за это платят. И наконец — откуда C++ знает, что keepAwake принимает boolean, а exportCsv возвращает промис: из спецификации на TypeScript, по которой кодогенератор пишет C++, Objective-C и Java. Механизмы выполнятся в песочнице моделями, а кодогенератор настоящий — запущен из пакета react-native 0.86, и его заголовок мы прочтём строка за строкой.
До новой архитектуры JavaScript и натив друг друга не вызывали. Между ними лежала очередь — тот самый мост, класс MessageQueue из Libraries/BatchedBridge. Файл есть и в 0.86, но в его шапке стоит @deprecated, а в конце статьи будет видно, почему на телефоне он больше не работает. Схема была такая: вызов метода не уходил в натив, а записывался в очередь — номер модуля, номер метода и массив аргументов; очередь уезжала пачкой, а ответ возвращался отдельным сообщением.
Ниже модель этой очереди: те же поля и та же упаковка колбэков, что в enqueueNativeCall и __invokeCallback, но без отладочных проверок и трассировки. Натив изображает последний таймер. Прогноз: что вернёт вызов elapsedMs и что именно уедет по мосту?
Вызов вернул undefined, и иначе быть не могло: пока JS держит управление, натив ещё не знает, что его позвали. Оба вызова уехали одной пачкой, и в ней нет ни одного имени: модуль 7, методы 0 и 1. Имена мост раздавал один раз при старте, в __fbBatchedBridgeConfig, а дальше ходили только номера. Функций в пачке тоже нет. Колбэк elapsedMs превратился в 1, а пара колбэков промиса — в 2 и 3: это callID, сдвинутый на бит, и младший бит говорит, какой из двух колбэков звать. Натив возвращал номер, а JS по номеру находил функцию в Map. Промис при мосте — та же пара колбэков, только завёрнутая: так устроен promiseMethodWrapper в NativeModules.js.
Уезжала очередь обычно тогда, когда натив сам забирал её через flushedQueue, получив управление обратно. А если с прошлой отправки прошло 5 мс и больше, JS выталкивал её сам через global.nativeFlushQueueImmediate — константа MIN_TIME_BETWEEN_FLUSHES_MS = 5 лежит в том же файле. Что стоила такая схема:
| Свойство моста | Чем за него платили |
|---|---|
| Ответ — всегда потом | Даже «сколько прошло миллисекунд» — колбэк на следующем круге обмена. Всё, что нужно знать сразу, например размеры экрана, натив пересылал заранее, при старте, константами модуля |
| Аргументы — только JSON-подобные | В отладочной сборке isValidArgument пропускала строки, конечные числа, булевы, null, undefined, массивы и объекты. Функция на месте аргумента, NaN и Infinity отвергались ошибкой «… is not usable as a native method argument» — колбэки в конце списка к этому моменту уже заменены номерами. Отправленные аргументы вдобавок замораживались, чтобы их не правили после отправки |
| Пачки в обе стороны | Вызов ждал своей пачки туда, ответ — своей обратно. Сколько бы ни работал метод, результат не мог прийти раньше следующего круга |
Мост не был асинхронным без исключений. Метод мог объявить себя синхронным — на Android это @ReactMethod(isBlockingSynchronousMethod = true), — и такой вызов шёл мимо очереди, через глобальную функцию nativeCallSyncHook. Почему это оставалось исключением, написано в самом MessageQueue:
Старый отладчик выполнял JS не на телефоне, а в Chrome на компьютере и говорил с нативом через WebSocket. Синхронно ждать ответ через сеть нельзя, поэтому в отладке синхронные методы просто падали. В новой архитектуре такой отладки нет вовсе: JS обязан жить в одном процессе с нативом, иначе C++ не сможет выставить ему свои объекты.
JSI — JavaScript Interface — это C++-интерфейс движка. Через него C++ создаёт JS-значения, вызывает JS-функции и, главное, выставляет в JS объекты и функции, тело которых написано на C++. Объекту для этого достаточно реализовать три метода:
В React Native 0.86 такой объект — реестр модулей. При запуске ReactInstance.cpp ставит глобаль RN$Bridgeless = true, а TurboModuleBinding::install, увидев её, кладёт в глобальную область nativeModuleProxy — экземпляр BridgelessNativeModuleProxy. Его get держится на одной мысли: имя свойства — это имя модуля.
getModule просит модуль у поставщика — на iOS и Android это TurboModuleManager, который создаёт экземпляр по первой просьбе и держит его в кеше, — и отдаёт его JS-представление, jsRepresentation. Оно тоже кешируется, поэтому повторное обращение по имени возвращает тот же объект. getPropertyNames в классе не переопределён, а по умолчанию он возвращает пустой список.
В JS ближе всего к HostObject стоит Proxy: его ловушки get, set и ownKeys срабатывают в тех же случаях. Соберём на нём модель реестра. Прогноз: сколько имён покажет Object.keys у реестра с двумя модулями, сколько модулей будет создано и чем кончится последняя строка?
Имён ноль: прокси не хранит списка, он умеет только отвечать на вопрос по имени. Создан один модуль из двух — про Haptics никто не спросил, и его поставщик так и не вызывался. Отсутствующий модуль — null, а не undefined, как в предпросмотре прошлой статьи: так отвечает get на C++. Присваивание упало с тем же текстом, что бросает настоящий set.
И главное — timer.elapsedMs() вернул число сразу, в той же строке. В модели моста тот же вызов вернул undefined, а число пришло колбэком. Никакой очереди, никаких номеров: JS читает свойство — выполняется get на C++, JS вызывает функцию — выполняется функция на C++, и её результат становится результатом вызова.
Одна деталь из исходников стоит отдельной строки. TurboModuleRegistry, который мы в прошлой статье переписали двумя попытками, сначала пробует global.__turboModuleProxy. В 0.86 на телефоне этой глобали нет: TurboModuleBinding::install ставит её, только если RN$Bridgeless не определён. Поэтому модуль находится второй попыткой — через NativeModules[name], а NativeModules в NativeModules.js и есть global.nativeModuleProxy. Имя старого реестра осталось, а за ним теперь HostObject.
С этого места модель прокси едет в подложке примеров, а NativeModules в цепочке поиска из прошлой статьи сначала смотрит в неё — как NativeModules.js на телефоне.
Синхронный вызов — не бесплатная победа. Пока функция на C++ работает, JS-поток стоит: не срабатывают таймеры, не выполняются обработчики нажатий, React не рендерит. Для ответа за микросекунды это идеально: прочитать часы, константу, значение из памяти. Для работы на сотни миллисекунд — беда. Поэтому в новой архитектуре живут оба вида: метод, который возвращает значение, и метод, который возвращает промис.
В подложке — модель реестра из прошлого раздела и цепочка поиска из прошлой статьи: requireNativeModule("RestTimer") найдёт модуль через NativeModules, который теперь смотрит в прокси. У модуля два метода с одной и той же работой на 300 мс — синхронный и с промисом. Прогноз: в каком порядке появятся строки и какой из двух тиков опоздает?
Синхронный вызов вернул файл первой же строкой, а тик, поставленный до него, опоздал: все 300 мс JS ждал натив, и таймер не мог сработать. С промисом наоборот: exportCsv вернул управление сразу, тик пришёл вовремя, а файл — потом. Оговорка честности: поток в песочнице один, и работу «в другом потоке» здесь изображает таймер. На телефоне промис-метод правда уходит на чужую очередь, и JS-поток всё это время свободен.
Кто решает, каким будет вызов? Не тот, кто вызывает, а тип возврата в спецификации. На iOS и на Android это одна и та же строка:
| Тип возврата в спецификации | Вид метода | Как выполняется |
|---|---|---|
number, boolean, string, объект, массив | NumberKind, BooleanKind и другие | синхронно, в JS-потоке: вызов вернёт значение, а JS до тех пор ждёт |
Promise<T> | PromiseKind | вызов сразу отдаёт промис, натив работает на своей очереди и потом зовёт resolve или reject |
void | VoidKind | тоже асинхронно: вызов сразу возвращает undefined, работа встаёт в очередь модуля. Ошибку внутри такого метода JS уже не поймает — вызов давно вернулся |
Модуль целиком может объявить себя синхронным — это isSyncModule_ в первой строке функции, — но по умолчанию правило такое. И правило это — для модулей на Objective-C и Java: их вызов идёт через isMethodSync. Модуль, написанный прямо на C++ по заголовку из следующего раздела, JS зовёт в своём потоке целиком, даже void-метод: bridging::callFromJs просто вызывает функцию. Там промис-метод возвращает промис, а увести работу с JS-потока — уже забота автора модуля. Авторам модулей Expo тот же выбор дан словами. В expo-secure-store одно и то же чтение из Keychain объявлено дважды:
Тело одинаковое. Function выполнится прямо в вызове, в JS-потоке. AsyncFunction по умолчанию уходит на очередь expo.modules.AsyncFunctionQueue — она объявлена в AsyncFunctionDefinition.swift пакета expo-modules-core — и возвращает промис. В прошлой статье обёртка SecureStore.ts звала именно асинхронный вариант, getValueWithKeyAsync.
Правило для своих спецификаций: синхронным делайте то, что отвечает за микросекунды. Всё, что трогает диск, сеть или систему, — промис.
Откуда C++ знает, что keepAwake принимает boolean, а exportCsv возвращает промис? При мосте это и не требовалось: аргументы приезжали JSON-подобной пачкой и разбирались на месте. JSI передаёт настоящие JS-значения, и кто-то должен перевести их в bool и std::string. Этот перевод пишется не руками: договор описывают один раз на TypeScript или Flow, а нативный код по нему генерирует кодогенератор из пакета @react-native/codegen.
Спецификация — обычный файл, и у неё три правила. Имя файла начинается с Native: файлы отбирает выражение /^(Native.+|.+NativeComponent)/ из combine-utils.js, и файл с другим именем кодогенератор пропускает молча — ни ошибки, ни кода. Интерфейс называется Spec и расширяет TurboModule, а сам интерфейс передан в вызов TurboModuleRegistry с именем модуля. Эти два правила парсер проверяет и останавливается с ошибкой: интерфейс RestTimerSpec даёт MisnamedModuleInterfaceParserError с текстом «All TypeScript interfaces extending TurboModule must be called 'Spec'», а спецификация без вызова реестра — UnusedModuleInterfaceParserError. Вот спецификация модуля таймера отдыха для «Подхода»:
Кодогенератор запускается сам. На iOS — внутри pod install, который выполняет npx expo prebuild: scripts/cocoapods/codegen_utils.rb зовёт тот самый скрипт, что ниже. На Android — задачами Gradle-плагина GenerateCodegenSchemaTask и GenerateCodegenArtifactsTask, которые берут тот же @react-native/codegen. Запустим его руками в учебном проекте из одной спецификации. Где искать спецификации, скрипт узнаёт из поля codegenConfig в package.json:
В build/generated/ios/ReactCodegen лежат три файла про наш модуль. Первый, PodhodSpecJSI.h, — база для модуля, написанного на C++ сразу для обеих платформ. Вот класс из него целиком, как его выдал кодогенератор:
Читаем сверху вниз. kModuleName = "RestTimer" — строка из getEnforcing<Spec>("RestTimer"): по ней nativeModuleProxy и найдёт модуль. methodMap_ — таблица «имя → число аргументов и вызыватель»: когда JS читает RestTimer.keepAwake, TurboModule::get ищет имя именно в ней. Дальше три вызывателя, по одному на метод, и каждый делает то, что при мосте делал разбор пачки, только сразу и строго. Нет аргумента — throw jsi::JSError(rt, "Expected argument in position 0 to be passed"). Есть — перевод: args[0].asBool(), args[0].asString(rt). Возврат: callFromJs<double> для числа, jsi::Value::undefined() для void, а для промиса — callFromJs<jsi::Value>: сам промис как JS-значение.
static_assert — проверка уже для автора модуля, и срабатывает она при компиляции C++. «Expected elapsedMs(...) to have 1 parameters» при пустых скобках в спецификации — не опечатка: первым параметром реализация получает jsi::Runtime&. Модуль, чей метод разошёлся со спецификацией, просто не соберётся. Два других файла — тот же договор для Objective-C, а запуск с -t android даёт его и для Java:
В .mm вид метода записан прямо в вызове: NumberKind, VoidKind, PromiseKind — ровно то, что прошлый раздел скармливал isMethodSync. В Java тот же выбор виден по аннотации: isBlockingSynchronousMethod = true кодогенератор поставил только методу, который возвращает число. А в протоколе <RCTBridgeModule, RCTTurboModule> слово Bridge пережило сам мост — имена в React Native меняются медленнее устройства.
В песочнице спецификация делает свою JS-половину — даёт типы. Прогноз: что скажет компилятор о трёх вызовах и что напечатает «натив»?
Компилятор возразил дважды — «Аргумент типа "string" нельзя назначить параметру типа "boolean"» и «Ожидалось аргументов: 1, получено: 0», — но код выполнился: типы до рантайма не доезжают. «Натив» песочницы — обычный JS, его никто не собирал по спецификации, и он проглотил и строку, и undefined. На телефоне типы тоже стёрты, и Metro их даже не проверяет: Babel вырезает типы, не читая их. Но между JS и модулем на C++ стоит сгенерированный вызыватель. Третий вызов остановится на проверке числа аргументов с JS-ошибкой «Expected argument in position 0 to be passed», второй — на args[0].asBool(), который в jsi.cpp бросает исключение с текстом «Value is a string, expected a boolean». Спецификация стережёт обе стороны: компилятор — JS, сгенерированный код — натив.
MessageQueue.js и соседи в пакете лежат ради совместимости. Выключатель у них один — глобаль RN$Bridgeless, и в 0.86 её ставит сам ReactInstance.cpp, безусловно. Вот где она разводит пути:
| Место | При мосте | В 0.86, где RN$Bridgeless === true |
|---|---|---|
Реестр модулей, NativeModules.js | таблица из __fbBatchedBridgeConfig | global.nativeModuleProxy — HostObject |
TurboModuleRegistry | __turboModuleProxy, потом NativeModules | __turboModuleProxy не ставится, поиск идёт через NativeModules — то есть через тот же прокси |
Вызовы из натива в JS, registerCallableModule.js | BatchedBridge.registerCallableModule | global.RN$registerCallableModule |
Таймеры, setUpTimers.js | JS-модуль JSTimers, который натив дёргал через мост | комментарий в файле: «In bridgeless mode, timers are host functions installed from cpp» |
| Вернуть старую архитектуру | newArchEnabled=false в gradle.properties | Gradle-плагин 0.86 печатает, что это «not supported anymore since React Native 0.82», и собирает с новой |
Рендерер React на телефоне тоже говорит с нативом через JSI — отсюда синхронный measure на устройстве, о котором шла речь в прошлой статье. Сам React от этого не меняется: компоненты и хуки из части IV работают как работали, меняется только то, что лежит под ними.
В предпросмотре JSI нет вовсе: react-native-web выполняется в браузере, и его NativeModules — JS-объект с одним UIManager. Поэтому всё, что в этой статье выполнялось, — модели механизмов. Настоящими в песочнице были типы TurboModule из пакета react-native и компилятор, а за её пределами — кодогенератор и его вывод.
Модель реестра с прокси остаётся в подложке до конца главы. Следующей статье она понадобится сразу: там модуль просит у системы разрешение на фото или уведомления. Такой вызов, как вы теперь знаете, обязан быть промисом — ответ на него даёт не натив и не код, а пользователь, и ждать его можно сколько угодно.
- При мосте JS и натив обменивались очередью: номер модуля, номер метода и JSON-подобные аргументы, колбэки — номерами, ответ — отдельным сообщением на следующем круге. Поэтому метод не мог вернуть значение.
- JSI позволяет выставить в JS объект, у которого
getиsetнаписаны на C++. В 0.86 реестрNativeModules— это HostObjectnativeModuleProxy: модуль ищется по имени при обращении, создаётся один раз и кешируется, отсутствующий —null, а список имён пуст. - Метод, возвращающий значение, выполняется синхронно в JS-потоке и держит его до конца;
voidиPromise— асинхронно. Выбор задаёт тип возврата в спецификации; в Expo тот же выбор называетсяFunctionиAsyncFunction. - Договор пишут на TypeScript: файл
Native*.ts,interface Spec extends TurboModule, вызовTurboModuleRegistryс именем. Кодогенератор из@react-native/codegen— на iOS его зовётpod install, на Android сборка Gradle — делает из него C++-шаблон, протокол Objective-C и абстрактный класс Java. - В сгенерированном заголовке — имя модуля,
methodMap_с числом аргументов и вызыватели, которые проверяют и переводят аргументы (asBool,asString). Типы TypeScript стерегут JS-сторону, сгенерированный код — нативную.
1/4Метод нативного модуля при мосте принимает колбэк. Что уезжает в натив вместо функции?
2/4Что вернёт Object.keys(NativeModules) на телефоне с React Native 0.86?
3/4В спецификации метод объявлен как keepAwake(on: boolean): void, модуль написан на Objective-C или Java. Как он выполнится на телефоне?
4/4Модуль написан на C++ по сгенерированному заголовку. JS вызывает RestTimer.keepAwake() без аргумента. Что будет?
Реестр, который забывает модули
В этой модели nativeModuleProxy забыли кеш: каждое обращение по имени создаёт новый модуль. Экран тренировки запустил отдых на 90 секунд, а шапка ленты, спросив модуль ещё раз, видит ноль. Допишите get так, чтобы по одному имени всегда отдавался один и тот же объект.
Храните созданный модуль в Map по имени и зовите поставщика, только если записи ещё нет:
Теперь «создаю RestTimer» печатается один раз, а остаток — 90. У модуля есть состояние, и два экземпляра — это два разных таймера. Поэтому на телефоне кешей даже два: TurboModuleManager держит экземпляр модуля, а TurboModuleBinding::getModule — его jsRepresentation.
Таймер отдыха стоит, пока пишется файл
Экран отдыха тикает каждые 100 мс. Кнопка «Выгрузить журнал» зовёт синхронный метод модуля, который полсекунды пишет файл, — и таймер на это время замирает. В подложке — модель реестра и цепочка поиска. Сделайте так, чтобы выгрузка не останавливала таймер: у модуля есть и второй метод.
Возьмите промис-метод и дождитесь его через await — обработчик нажатия для этого станет асинхронным:
Тиков за выгрузку станет около пяти: JS-поток свободен, пока модуль работает. В спецификации настоящего модуля выбор записали бы типом возврата — exportCsv(csv: string): Promise<string>, — и кодогенератор сделал бы метод асинхронным сам. Синхронный вариант с тем же телом имеет смысл, только если работа укладывается в микросекунды.