Где скрывается задержка в React Native: пересечение границ, а не медленный код
Узнайте, почему быстрые API и компоненты с мемоизацией могут всё равно казаться медленными в React Native, и как батчинг, треддинг и подходы, разработанные в эпоху JSI, снижают затраты при работе с разными ядрами.
Источники: The Hidden Latency Cost in React Native Bridge Architectures
Экран приложения на React Native может казаться медленным даже тогда, когда все показатели работоспособности выглядят нормальными: API отвечает примерно за 100 мс, запросы индексируются, компоненты сохраняются в кэше, а изображения загружаются из кэша. Однако нажатие на экран регистрируется с опозданием, нижняя панель открывается с задержкой, метки на карте отстают от реальности, анимация, которая работает без сбоев на флагманском телефоне, тормозит на бюджетном устройстве с Android, а всплеск событий с датчиков блокирует интерфейс. Во многих из этих случаев потерянное время тратится на этапе, который команды редко анализируют: передаче данных между средой выполнения JavaScript и нативным кодом.
В этом руководстве объясняется, как происходил переход при использовании устаревшей схемы Bridge, почему её затраты накапливаются, а не отражаются в отдельном звонке, что на самом деле меняется с появлением Новой архитектуры (JSI, TurboModules, Fabric и переработанный рендерер), и какие принципы проектирования позволяют сохранять низкие затраты на обмен данными между разными средами выполнения независимо от выбранной архитектуры. Новая архитектура является стандартной с версии React Native 0.76, но понимание схемы Bridge всё ещё важно: многие продакшен-приложения используют устаревшие модули, слои взаимодействия, старые библиотеки и паттерны, сформированные под влиянием этой схемы. Что ещё важнее, удаление Bridge не делает плохо спроектированное взаимодействие между средами выполнения бесплатным.
Два среды выполнения в одном приложении
Полезной моделью мышления является представление о том, что приложение React Native работает как минимум в двух средах. Ваши компоненты, состояние, бизнес-правила и сетевые операции находятся в JavaScript-части; всё, что связано с операционной системой, находится в нативной части.
┌──────────────────────────────┐
│ JavaScript / TS │
│ │
│ React Components │
│ State Management │
│ Business Logic │
│ API Handling │
└──────────────┬───────────────┘
│
│ JS ↔ Native communication
│
┌──────────────▼───────────────┐
│ Native Side │
│ │
│ Android / iOS APIs │
│ Camera │
│ Bluetooth │
│ GPS │
│ Storage │
│ Native Views │
│ Sensors │
└──────────────────────────────┘
Код React может выполняться на JavaScript, но практически каждая значимая функция в конечном итоге требует использования платформы. Типичные причины для взаимодействия с платформой включают:
- отображение нативных интерфейсов,
- запуск камеры,
- чтение координат GPS,
- доступ к файловой системе,
- активация вибрации,
- взаимодействие с устройствами Bluetooth,
- использование защищенного хранилища,
- обращение к сторонним нативным SDK,
- прием данных с датчиков,
- использование других API, специфичных для платформы.
Таким образом, данные постоянно перемещаются между двумя средами. В классической архитектуре весь этот трафик проходил через React Native Bridge.
Как старый Bridge передавал вызовы
Изначальный дизайн намеренно разделял выполнение кода на JavaScript и выполнение в нативной среде. Одна полная пересылка данных выглядела примерно так:
JavaScript
↓
Prepare parameters
↓
Serialize data
↓
Queue bridge message
↓
Cross execution boundary
↓
Native receives message
↓
Native performs operation
↓
Prepare result
↓
Serialize result
↓
Queue response
↓
JavaScript receives result
Основной особенностью было то, что вся пересылка данных происходила асинхронно. Аргументы сериализовались, помещались в очередь, передавались через границу между средами, десериализовывались и обрабатывались в нативной среде, а любой результат возвращался обратно тем же путем. Сама команда React Native в своих материалах о новой архитектуре называет сериализацию и асинхронное взаимодействие ключевыми ограничениями старой модели.
Для редких вызовов такая нагрузка не представляет опасности. Хорошим примером может служить запись строки в буфер обмена:
await Clipboard.setString("Hello");
Какой бы ни была стоимость операции пересечения здесь, пользователь никогда её не почувствует. Проблемы начинаются тогда, когда граница превращается в канал высокой частоты, от которого приложение постоянно зависит.
Один вызов стоит недорого; проблема в режиме работы
В обсуждениях производительности Bridge часто предполагается, что каждый вызов слишком дорог. Такой подход не учитывает суть проблемы. Настоящее замедление обычно возникает в результате совокупности нескольких факторов, умножающихся друг на друга:
частота × размер данных × нагрузка на планировщик × требования к синхронизации
Предположим, что одно пересечение стоит почти ничего. Теперь увеличим количество таких операций:
1 call
10 calls
100 calls
1,000 calls
10,000 calls
Затем можно увеличить размер каждого пакета данных, постоянно отправлять обновления во время прокрутки пользователя или требовать двойного прохождения данных от нативного кода к JavaScript и обратно, прежде чем можно будет отрисовать следующее визуальное состояние. Раньше считавшаяся незначительной стоимость теперь оказывает влияние на критический путь отрисовки. Ни одна отдельная функция не выглядит медленной в инструментах анализа производительности; задержки распределяются по всей системе, и именно поэтому они остаются незамеченными.
Рассматривайте задержки взаимодействия как часть цепочки обработки
Полезно записать каждый этап, через который проходит взаимодействие:
Total Latency =
JS Work
+ Serialization
+ Queue Waiting
+ Context Boundary Cost
+ Native Work
+ Return Communication
+ Rendering Delay
Команды обычно настраивают один параметр по отдельности, как правило, работу нативного кода:
Native Work
или работу JavaScript:
JS Work
Однако пользователь воспринимает только суммарный результат:
TOTAL LATENCY
Рассмотрим операцию нативного кода, которая завершается за:
2 ms
Само по себе это отличное число. Но полный разбор может выглядеть так:
JS preparation 2 ms
Serialization 2 ms
Queue waiting 4 ms
Native operation 2 ms
Response communication 3 ms
React update 4 ms
--------------------------------
Total 17 ms
«Операция за 2 мс» превратилась в процесс, занимающий примерно 17 мс. В документации React Native указано, что лимит времени на обработку кадра составляет около 16,67 мс при 60 кадрах в секунду; если его превысить, кадр может быть пропущен. Многие современные телефоны обновляют изображение чаще, чем 60 раз в секунду, что ещё больше сокращает этот лимит.
Лимиты времени на обработку кадров практически не оставляют места на дополнительные нагрузки
При 60 Гц расчёты выглядят так:
1000 ms / 60 ≈ 16.67 ms
Это даёт примерно
16.67 ms
время на создание каждого кадра. При 120 Гц это время уменьшается вдвое:
1000 ms / 120 ≈ 8.33 ms
Примерно восемь миллисекунд — это очень мало. Если часть этого времени тратится на бесполезную передачу данных между компонентами, сама архитектура потребляет ресурсы, отведённые на отрисовку. Симптомы знакомы:
- сбои в анимациях,
В документации по производительности также разделяются FPS потока JavaScript и FPS основного потока интерфейса. Когда поток JavaScript перегружен, он не может оперативно обрабатывать события сенсорного ввода или обновления интерфейса, запускаемые с помощью JavaScript, в то время как взаимодействия, реализованные нативно, такие как прокручивание списка, могут оставаться плавными. Именно это разделение объясняет классическую проблему при отладке: одна часть экрана работает плавно, а другая — замедляется.
Сценарий: передача данных о местоположении в карту
Представьте приложение для вызова такси, получающее непрерывный поток данных GPS. Простая схема работы выглядит так:
Native GPS
↓
Bridge
↓
JavaScript
↓
State Update
↓
React Render
↓
Bridge
↓
Native Map
↓
Marker Position Updated
Теперь запустите этот цикл для каждого исправления. Ни вычисление положения, ни перемещение маркера сами по себе не являются ресурсоемкими операциями, но архитектура создает постоянные повторяющиеся циклы:
Native → JS → Native
Этот процесс продолжается до тех пор, пока экран открыт. Если отобразить множество драйверов одновременно, нагрузка увеличивается во много раз:
Driver 1 position
Driver 2 position
Driver 3 position
Driver 4 position
...
Driver 100 position
Отправка сотни небольших обновлений по одному может оказаться гораздо более затратной, чем отправка одного хорошо подготовленного пакета, содержащего все обновления сразу. Коренная причина заключается в структуре потока данных, а не в механизме отрисовки React.
Сценарий: потоки данных с датчиков
Данные акселерометра и гироскопа следуют тому же принципу. Предположим, что нативный код отправляет:
60 events / second
Каждое событие попадает в JavaScript, обрабатывается и может запустить новое обновление, направленное обратно в нативный интерфейс. В результате формируется поток данных в реальном времени, проходящий через границу между двумя средами:
Sensor
↓
Native
↓
JS
↓
Calculation
↓
React
↓
Native UI
При низкой частоте событий это может работать идеально. При высокой частоте, особенно на более слабом оборудовании, события могут поступать быстрее, чем приложение успевает их обработать, что приводит к появлению ещё одной скрытой стоимости.
Очереди, которые растут быстрее, чем опустошаются
Предположим, события поступают каждый
10 ms
но для их эффективной обработки требуется
15 ms
Это уже не просто медленная операция; это накопление задач, которое растёт без ограничений. Поступают новые данные, в то время как старые всё ещё ожидают обработки, поэтому интерфейс всегда отображает прошлые данные. Для реальных временных интерфейсов это худший возможный исход. Пользователю редко нужны все промежуточные значения; ему нужна самая свежая значимая информация. Дизайны, которые объединяют или удаляют устаревшие события, позволяют сохранять очередь небольшой и отображение актуальным.
Интерфейсы с возможностью обмена сообщениями увеличивают количество операций пересечения границ
Этот принцип характерен не только для React Native; инженеры, разрабатывающие распределённые системы, сталкиваются с ним ежедневно. Сравните API модуля, который устанавливает по одному полю за каждый вызов:
await nativeModule.setName(name);
await nativeModule.setEmail(email);
await nativeModule.setAge(age);
await nativeModule.setCountry(country);
await nativeModule.setLanguage(language);
с API, принимающим всю информацию для обновления сразу:
await nativeModule.updateProfile({
name,
email,
age,
country,
language,
});
Вторая версия требует гораздо меньшего количества операций пересечения границ для достижения того же результата. Краткое правило звучит так:
Выполняйте больше работы за одну операцию пересечения и делайте её реже.
Этот же принцип применим к:
- HTTP-API,
- запросам к микросервисам,
- доступу к базе данных,
- рабочим потокам,
- межпроцессному взаимодействию,
- мобильным мостам связи,
- распределённым системам в целом.
В сетевых технологиях это называется активным обменом сообщениями. Традиционный мост просто перенёс эту классическую проблему распределённых систем внутрь одного мобильного приложения.
Размер полезной нагрузки — второй фактор
Частота передачи данных — это лишь половина картинки; важен также объём передаваемых данных. Рассмотрим возможность передачи чего-то подобного через границы традиционной архитектуры:
{
users: [...10000Users]
}
Отправитель должен закодировать структуру данных, а получатель — воссоздать её и обработать. Большие массивы, глубоко вложенные объекты, метаданные изображений, двоичные данные и крупные деревья конфигураций всё это увеличивает нагрузку. Наиболее подверженными этой проблеме являются следующие задачи:
- кадры камеры,
- буферы аудио,
- обработка изображений,
- входные данные для машинного обучения,
- большие результаты запросов,
- сложные анимации,
- динамические карты,
- стримы с датчиков.
В документации по архитектуре React Native приводится крайний, но показательный пример: буферы кадров камеры в реальном времени, объем которых может достигать десятков мегабайт на кадр и огромных объемов в секунду. Постоянная сериализация таких данных через асинхронный мост — это просто неподходящий инструмент.
Почему цепочки обработки кадров делают издержки очевидными
Один кадр может содержать мегабайты пикселей. Теперь представим передачу кадров с
30 FPS
или даже
60 FPS
Если каждый из них должен быть преобразован в формат, подходящий для JavaScript и сериализуемый, и отправлен через асинхронный слой, транспортировка становится узким местом. Необходимо избегать такой ситуации для каждого отдельного кадра:
Native Camera
↓
Huge serialized payload
↓
JavaScript
↓
Process
↓
Serialized result
↓
Native
Именно поэтому библиотеки с высокой производительностью всё чаще размещают тяжелую обработку рядом с нативной памятью и предоставляют JavaScript узкий, эффективный интерфейс. Такой подход приводит к одному из самых значимых изменений в современном React Native.
Как меняется JSI
JSI (JavaScript Interface) позволяет JavaScript взаимодействовать с нативным и C++ кодом гораздо более прямо. В отличие от старого механизма Bridge, который полностью основывался на сериализации и очереди:
JavaScript
↓
Serialize
↓
Bridge
↓
Deserialize
↓
Native
новая архитектура может предоставлять нативные функции через прямые интерфейсы:
JavaScript
↓
JSI
↓
C++ / Native
Согласно документации React Native, JSI позволяет JavaScript хранить ссылки на объекты C++ и C++ — ссылки на объекты JavaScript, что позволяет вызывать методы без использования модели сериализации Bridge. Концептуально вызов нативного метода начинает напоминать вызов функции в хост-объекте, а не отправку сообщения другому процессу. Это фундаментальное изменение в архитектуре.
Почему JSI не решает проблем с производительностью
Часто возникает следующее неверное понимание:
"JSI устраняет Bridge, поэтому проблемы с производительностью исчезают."
Это не так. Механизм передачи данных становится гораздо лучше, но плохой проектирование системы всё равно приводит к задержкам. Сто тысяч синхронных вызовов через JSI:
100,000 synchronous native calls
Каждый из этих способов может оказаться дешевле вызова Bridge, однако большая часть работы при этом всё равно остается ненужной. То же самое касается передачи огромных объектов снова и снова, выполнения ресурсоемкой логики на потоке JavaScript, блокировки основного потока UI или отрисовки тысяч компонентов, которые на самом деле не требовали отрисовки. Синхронный доступ также создаёт новый риск: медленный синхронный вызов нативного кода теперь блокирует поток JavaScript напрямую, вместо того чтобы обработаться позже.
Следовательно, правильный вывод — это не «Bridge был медленным, а JSI — быстрым». Более точно будет сказано следующее:
Каждый переход между средами выполнения стоит денег. Структурируйте приложение так, чтобы ненужные переходы вообще не происходили.
TurboModules: современная система нативных модулей
TurboModules заменяют устаревшую систему Native Modules. Старые модули были доступны через Bridge; новые интегрируются более тесно с помощью JSI и Codegen, который генерирует типизированные связи на основе спецификации. Среди преимуществ, перечисленных React Native, — следующие:
- синхронный доступ к нативному коду, когда это необходимо в конкретном случае использования,
- типобезопасность на всем протяжении границы взаимодействия,
- возможность совместной работы с C++,
- возможность общего использования кода на разных платформах,
- задержанная загрузка модулей.
Задержанная загрузка заслуживает особого внимания. В старой модели инициализация нативных модулей во время запуска могла привести к дополнительной работе до появления первого экрана. С задержанной загрузкой модуль инициализируется только тогда, когда он впервые используется. Вместо того чтобы:
Application starts
↓
Initialize everything
↓
Eventually show UI
запуск может выглядеть более так:
Application starts
↓
Initialize required components
↓
Show UI
↓
Load additional modules when needed
Подобные изменения влияют на то, что пользователи замечают гораздо сильнее, чем цифры тестов производительности: на скорость, с которой приложение становится готовым к использованию.
Fabric: часть редизайна, отвечающая за отрисовку
Не только слой коммуникации был перепроектирован. Fabric, новая система отрисовки, перемещает большую часть инфраструктуры отрисовки в общедоступный C++ и позволяет использовать функции, которые были затруднительны в старой асинхронной модели, включая:
- синхронный доступ к макету там, где это необходимо,
- лучшую взаимосовместимость,
- несколько уровней приоритетов обновлений,
- поддержку современных функций React,
- более гибкое планирование.
Приоритеты важны, потому что интерфейсы не обрабатывают каждое обновление одинаково:
User presses button → HIGH priority
Background data refresh → LOWER priority
Analytics event → LOW priority
Animation → HIGH priority
Prefetching → LOW priority
Качественный рендерер не должен считать нажатие кнопки и отправку данных в систему аналитики одинаково срочными. Приоритизация является основой адаптивного интерфейса, и именно Fabric позволяет React Native использовать модель планирования React. Если вы хотите узнать, как сам React кодирует приоритет обновлений, статья о том, как React использует маски битов для кодирования приоритета объясняет этот механизм.
Настоящим врагом является постоянная смена границ
Если весь проблемный феномен нужно свести к двум словам, это будет постоянная смена границ: многократная передача небольших фрагментов информации туда-сюда между средами выполнения.
JS
↓
Native
↓
JS
↓
Native
↓
JS
↓
Native
Когда вы видите такую структуру, это обычно означает, что ответственность за ту или иную функцию разделена не в том месте. Необходимо спросить себя, где на самом деле должна находиться эта работа. Возьмем обработку аудио. Дизайн с высоким уровнем нагрузки выглядит так:
Native microphone
→ JS audio frame
→ calculate
→ native
→ next frame
→ JS
→ calculate
→ native
Лучший вариант разделения заключается в том, чтобы обработка высокочастотных сигналов выполнялась нативно, а в JavaScript передавались только те результаты, которые требуют дальнейших действий:
Native microphone
↓
Native processing
↓
Native processing
↓
Native processing
↓
Meaningful result
↓
JavaScript
Объем вычислений может быть примерно одинаковым, но паттерн взаимодействия совершенно разный, как и нагрузка на оба потока.
Практические методы для снижения нагрузки
Группировка операций в один вызов
Цикл, который ожидает нативный вызов для каждого элемента, является распространенной причиной высокой нагрузки:
for (const item of items) {
await NativeModule.saveItem(item);
}
При наличии 500 элементов возникает потенциально сотни отдельных операций пересечения, каждая из которых сопряжена с дополнительными затратами на планирование. Лучший нативный API принимает всю коллекцию целиком и позволяет нативному коду выполнять итерацию:
await NativeModule.saveItems(items);
Различия в структуре легко описать:
❌ 500 boundary crossings × small operation
✅ 1 boundary crossing × 500 native operations
Группировка операций является одной из самых простых и эффективных архитектурных оптимизаций. Один из аспектов дизайна, который необходимо учесть: при групповом вызове требуется четкое описание возможных ошибок, например информация о том, какие элементы не обработались, чтобы один неисправный элемент не привел к безвозвратной потере остальных.
Ограничение частоты или объединение высокочастотных событий
Предположим, что нативный модуль генерирует 120 обновлений в секунду. JavaScript редко нуждается во всех из них. Для многих интерфейсов примерно 20 значимых обновлений в секунду выглядят практически одинаково на экране. Вместо того чтобы обрабатывать
120 events / second
вы можете обработать
20 events / second
или накапливать события на уровне операционной системы и передавать только самое последнее состояние. Реализация ограничения скорости непосредственно на уровне операционной системы обычно лучше, чем в JavaScript, поскольку события, обработанные в JavaScript, уже понесли затраты на передачу. Такой подход подходит для:
- отслеживания местоположения,
- событий прокрутки,
- данных с датчиков,
- отчетов о прогрессе,
- уровней звука,
- статистики сети,
- движения по карте,
- динамических панелей управления.
Самым дешевым событием является то, которое никогда не отправляется.
Передавайте смысл, а не сырые данные
Вместо того чтобы передавать что-то вроде
10 MB raw dataset
от уровня операционной системы в JavaScript и просить JavaScript вычислить ответ, вычисляйте ответ там, где уже находятся данные. Замените этот поток:
Native
↓
10 MB data
↓
JavaScript
↓
find average
на этот:
Native
↓
calculate average
↓
JavaScript receives: 42.7
Короче говоря, передавайте
meaning
вместо того чтобы
raw information
когда архитектура это позволяет. Недостаток заключается в том, что логика переходит в нативный код, который необходимо писать и тестировать для каждой платформы или в виде общего кода на C++; использовать этот подход следует только для данных, которые действительно большие или используются часто.
Задержка в сети — это не задержка взаимодействия
Быстрый бэкенд может скрыть медленную работу. Предположим, API отвечает за
120 ms
и панель мониторинга показывает зеленый цвет. Однако после получения ответа ещё многое должно произойти:
API Response
↓
JSON Parsing
↓
State Transformation
↓
Redux / Zustand / Context
↓
Component Re-rendering
↓
JS ↔ Native interaction
↓
Layout
↓
UI Commit
К тому моменту, когда результат появляется на экране, пользователь уже мог подождать
200+ ms
Отчеты о мониторинге бэкенда показывают, что все работает быстро; пользователи жалуются на медленную работу приложения. Обе стороны говорят правду, поскольку они измеряют разные вещи. Задержка бэкенда и задержка взаимодействия — это разные показатели, и для мобильных приложений именно второй фактор обычно определяет, насколько удобен продукт.
Тестируйте на устройствах, которые действительно используют ваши пользователи
Проблемы с производительностью любят скрываться на оборудовании разработчиков. Флагманский телефон имеет быстрый процессор, достаточно памяти, высокоскоростную память хранения, мощную графику и хорошую систему охлаждения, поэтому всё кажется в порядке. Однако реальные пользователи запускают ту же версию приложения на телефонах с гораздо меньшим количеством ресурсов, и
small inefficiency × thousands of operations
проблемы вдруг становятся заметными. Тестирование только на премиум-устройствах сопряжено с рисками. Надежная матрица тестирования должна включать по меньшей мере:
High-end device
Mid-range device
Lower-end supported device
Это особенно важно, когда ваша аудитория включает рынки, где большая доля пользователей пользуется доступными Android-телефонами.
Измеряйте версии для выпуска, а не версии разработки
Версии разработки выполняют дополнительные операции для отображения предупреждений, отладки и работы с инструментами, и официальное руководство по производительности прямо рекомендует использовать версии для выпуска при измерениях. Прежде чем делать вывод о медленной работе самого React Native, проведите профилирование
Release / Production build
вместо того чтобы полагаться только на
Development build
В противном случае вы можете тратить часы на устранение замедления, с которым никогда не столкнется пользователь.
Чек-лист для диагностики медленного отображения экрана
Использование функции useMemo() в коде с медленной работой редко является правильным первым шагом. Вместо этого рассмотрите следующие вопросы.
1. Занят ли поток JavaScript?
Ищите:
- дорогостоящие циклы,
2. Занят ли поток UI?
Ищите:
- дорогостоящую нативную отрисовку,
- сложные макеты,
- слишком крупные изображения,
- глубокие иерархии элементов интерфейса,
- анимации, выполняемые на неправильном потоке.
3. Слишком часто пересекается ли граница нативного кода?
Ищите запросы такого типа, особенно те, что происходят с высокой частотой:
Native → JS → Native
4. Насколько велики объемы данных?
Одного подсчета вызовов недостаточно. Измеряйте, сколько данных проходит через каждый из них.
5. Можно ли группировать вызовы?
По возможности преобразуйте
many small operations
в
one meaningful operation
6. Действительно ли JavaScript нуждается в этих данных?
Если нативный код генерирует 1 000 точек данных, а JavaScript использует только их среднее значение, отправляйте это среднее значение.
7. Необходимо ли это делать прямо сейчас?
Оставить как есть
User-critical work
кроме того
background work
Аналитика никогда не должна конкурировать с обратной связью от сенсорных устройств в том же кадре.
8. Существует ли зависимость от устаревшей архитектуры?
В текущих документациях React Native модули Legacy Native Modules и компоненты Legacy Native Components отмечены как устаревшие, и рекомендуется использовать Turbo Native Modules и Fabric Native Components там, где это уместно. При выборе пакета для функции, критичной с точки зрения производительности, проверьте, какую архитектуру он поддерживает; библиотека, работающая через слой взаимодействия, может не принести ожидаемой выгоды. Статус поддержки быстро меняется, поэтому убедитесь в этом, ознакомившись с текущими примечаниями к выпуску библиотеки.
Правило распределения задач
Для любой функции, чувствительной к производительности, сначала определите, какая часть приложения на самом деле отвечает за её выполнение.
- Если задача относится к JavaScript (бизнес-правила, состояние интерфейса, оркестрация), пусть она выполняется там и избегайте обращения к нативному коду на каждом шаге.
- Если задача относится к нативному коду (циклы сенсоров, обработка кадров, буферы аудио), пусть эти операции выполняются нативно и оставаются там.
- Переходите к использованию нативного кода тогда, когда это действительно необходимо, а не для того, чтобы каждый промежуточный шаг был виден в обеих частях приложения.
Здоровое разделение обычно выглядит так:
┌───────────────────────┐
│ JavaScript │
│ │
│ Business decisions │
│ Application state │
│ UI orchestration │
└───────────┬───────────┘
│
Meaningful calls
│
┌───────────▼───────────┐
│ Native │
│ │
│ Platform APIs │
│ High-frequency work │
│ Hardware interaction │
└───────────────────────┘
а не так, сотни раз в секунду:
JS ↔ Native ↔ JS ↔ Native ↔ JS ↔ Native
Что на самом деле исправляет новая архитектура
Новая архитектура устраняет несколько структурных ограничений старой модели. Вместо того чтобы строить всё вокруг обязательного, сериализованного, асинхронного моста, современный React Native предоставляет прямое взаимодействие через JSI:
JSI
↓
Direct JS/native interfacing
наряду с другими компонентами, работающими поверх него:
TurboModules
↓
Modern native module system Fabric
↓
Modern rendering architecture Codegen
↓
Typed interfaces between environments Modern scheduling
↓
Better prioritization of work
Команда React Native утверждает, что устранение моста снижает нагрузку от сериализации, ускоряет запуск и обеспечивает более прямую связь между JavaScript и нативным движком. Более значительной является гибкость — теперь фреймворк имеет больший контроль над:
- временем выполнения операций,
- местом их выполнения,
- способом доступа к нативным функциям,
- приоритетом обновлений.
Этот принцип применим ко всем границам
Если отойти на шаг от React Native, становится ясно общий принцип: у каждой архитектурной границы есть своя цена. Подумайте о таких точках пересечения:
Browser → API
Service → Service
Application → Database
Process → Worker
JavaScript → Native
Application → Redis
Backend → Message Broker
Системы часто проектируются так, будто вызовы между этими границами ведут себя как обычные внутрипроцессные функции. На самом деле это не так. Каждая граница добавляет набор следующих элементов:
- сериализацию,
- копирование данных,
- планирование выполнения операций,
- очередь задач,
- сетевые операции,
- синхронизацию,
- замену контекста,
- обработку ошибок,
- управление памятью.
Библиотека Bridge делает этот принцип особенно заметным внутри мобильных приложений.
Исправьте архитектуру перед микрооптимизациями
Хочется сразу обратиться к подобным инструментам:
useMemo(...)
useCallback(...)
React.memo(...)
У них действительно есть практическое применение. Но если приложение передаёт 5 МБ ненужных данных между запусками 30 раз в секунду, экономия 0,2 мс благодаря useMemo() не окажет значимого эффекта. Работа над производительностью наиболее эффективна, когда она выполняется в следующем порядке:
Architecture
↓
Data flow
↓
Scheduling
↓
Rendering
↓
Algorithms
↓
Micro-optimizations
а не наоборот.
Основные выводы
Предыдущая версия Bridge была не просто результатом плохой инженерной практики. Она позволяла JavaScript и нативному коду оставаться разделенными при асинхронном взаимодействии и сыграла важную роль в развитии React Native. По мере того как приложения начали использовать функции реального времени, сложные анимации, камеры, системы машинного обучения, карты, обработку аудио, сложные жесты и другие формы высокочастотного взаимодействия с устройством, ограничения сериализованной асинхронной передачи данных стали очевидными. JSI, TurboModules, Fabric и переработанная модель планирования и отрисовки устраняют многие из этих ограничений, но не сами экономические факторы.
- Оценивайте весь процесс взаимодействия, а не только вызов нативного кода или ответ API.
- Учитывайте лимиты кадровой частоты: примерно 16,67 мс при 60 Гц и примерно 8,33 мс при 120 Гц.
- Группируйте операции, ограничивайте или объединяйте потоки событий на уровне источника и отправляйте результаты вместо сырых данных.
Если приложение кажется медленным, несмотря на то что каждый компонент работает быстро, обратите внимание на связи между этими компонентами. Часто дорогостоящей частью является не сам процесс обработки, а расстояние, которое должны пройти данные для ее выполнения.