
Введение
У нас возникла типичная проблема React Native: большая часть приложения прекрасно работала на TypeScript, пока одна часть логики не перестала восприниматься как работа на JavaScript.
Сама задача не представляла собой ничего экзотического: нужно было взять небольшой фрагмент логики, приблизить его к производительности нативного кода, сохранить одну реализацию для Android и iOS, и предоставить к нему доступ через типизированный API в рамках React Native.
На первый взгляд, идея кажется простой. React Native остается React Native. C++ решает те задачи, где JavaScript либо слишком медленный, либо слишком неудобный, либо не подходит для существующего нативного кода.
Затем начинается основная работа.
Код на C++ - это простая часть. Сложнее - это его окружение: генерация кода, JSI, TurboModules, CMake, NDK, Xcode, Objective-C++, многопоточность, владение памятью, ошибки и простой вопрос о том, должен ли этот модуль вообще существовать.
Я Илья, технический директор компании «Исходный код». В этой статье я расскажу о том, как мы подходим к созданию модуля Turbo Native на чистом C++ в React Native.
Точка, где JavaScript перестает быть правильным слоем
React Native позволяет создавать большинство мобильных интерфейсов на JavaScript или TypeScript. Для многих задач разработки продуктов этого достаточно. Состояние UI, формы, навигация, сетевые запросы, валидация, бизнес-процессы - все это естественным образом реализуется на стороне TypeScript.
Проблема возникает, когда работа уже не связана с UI.
Модулю может потребоваться обрабатывать большие объемы двоичных данных, выполнять ресурсоемкие вычисления, повторно использовать существующую библиотеку на C или C++, обрабатывать изображения или аудио, выполнять криптографические операции или использовать одну и ту же логику предметной области как в Android, так и в iOS.
Очевидный ответ - писать нативный код.
Менее очевидная часть - это выбор степени нативности этого кода.
Мы можем написать отдельные реализации на Kotlin и Swift. Это работает, когда логика тесно связана с API платформы. Камера, Bluetooth, Keychain, Android Keystore, уведомления, геолокация - это проблемы платформы. C++ не сделает их волшебным образом кроссплатформенными.
Другой случай - это чистая логика. Алгоритмы, парсеры, сжатие, математические модели, расчет маршрута, декодирование изображений, хеширование, компьютерное зрение, игровые движки, пользовательские бинарные форматы. Здесь C++ начинает приобретать смысл, поскольку его ядро может оставаться платформенно-независимым.
Обещание простое: одна реализация, две платформы.
Компромисс также прост: одна реализация, две нативные системы сборки.
Три способа связать C++ с React Native
В React Native есть несколько способов интегрировать нативную логику в JavaScript. Я бы не стал рассматривать их как равноценные варианты для нового модуля.
Устаревшие собственные модули
В старой архитектуре использовался асинхронный мост. JavaScript вызывал нативные модули через сериализованную границу. Данные передавались из JS в нативные модули и обратно через мост.
Это работало много лет, и многие приложения до сих пор используют эту архитектуру.
Для нового модуля C++, критически важного с точки зрения производительности, я бы не стал начинать с этого.
Проблемы носят практический характер:
Преобразование данных имеет свою цену.
Синхронные API имеют ограничения.
Граница между JavaScript и нативными приложениями является слабо типизированной.
Для Android и iOS часто требуются отдельные нативные реализации.
Архитектура постепенно смещается из центра разработки React Native.
Использование устаревшего модуля может быть допустимо, если проект уже построен на старой архитектуре и задача не чувствительна к накладным расходам на вызовы. Для нового модуля C++ это обычно неправильный вариант по умолчанию.
Прямой JSI
JSI предоставляет нам прямой доступ к среде выполнения JavaScript из C++. Мы можем регистрировать функции, объекты, HostObject и значения внутри среды выполнения.
Это мощно. И это остро.
Direct JSI означает, что мы контролируем практически все вручную: преобразование типов, время жизни объектов, регистрацию, обработку ошибок, потокобезопасность, совместимость со средой выполнения и то, как модуль отображается для JavaScript.
Я бы использовал прямой JSI только тогда, когда TurboModules слишком ограничены в своих возможностях. Например, при создании низкоуровневой библиотеки с собственным API для работы во время выполнения или когда модулю требуется очень специфическая объектная модель, которую Codegen плохо выражает.
Для большинства модулей приложений прямой JSI создает слишком много ручного труда.
Модуль Turbo Native на чистом C++
Для современных проектов на React Native я обычно предпочитаю использовать модули Turbo Native на чистом C++.
Идея проста. Мы описываем API модуля на TypeScript или Flow. React Native Codegen генерирует нативные UI и вспомогательный код. Наш класс на C++ реализует сгенерированный контракт. JavaScript взаимодействует с модулем через JSI, но мы не внедряем каждую функцию в среду выполнения вручную.
Примерная форма выглядит так:

Мне нравится такой баланс: мы по-прежнему получаем типизированную границу и общую реализацию на C++, но при этом не пишем весь код JSI вручную.
Когда бы я на самом деле выбрал C++
C++ - это не признак серьезности, а плата за его использование.
Я бы выбрал его, если бы модуль обладал хотя бы одним из следующих свойств:
Логика должна работать одинаково на Android и iOS.
Код не зависит напрямую от Android SDK или iOS SDK.
Задача носит вычислительный характер.
У нас уже есть библиотека на C или C++.
Модуль работает с большими бинарными буферами.
Производительность имеет достаточно большое значение, чтобы оправдать сложность сборки нативного приложения.
Для правильного размещения или управления памятью требуется больше контроля, чем предоставляет нам JavaScript.
В качестве подходящих кандидатов можно рассматривать декодирование изображений, сжатие, алгоритмы распознавания, расчет маршрута, обработку сигналов, хеширование, парсеры для нестандартных бинарных форматов, математически сложные модели, а также игровые или графические движки.
Я бы не стал использовать C++ для простой вспомогательной функции только потому, что это звучит быстрее. Нативный модуль добавляет конфигурацию сборки, регистрацию платформы, усложняет отладку, приводит к сбоям в нативных приложениях, проблемам с ABI и замедляет сборку.
Тест, который я использую, скучный, но полезный:
Можем ли мы объяснить измеряемую причину существования C++?
Если ответ отрицательный, то TypeScript, вероятно, является лучшим инженерным решением.
Небольшой пример
Для пошагового руководства я буду использовать намеренно небольшой модуль, состоящий всего из двух операций:
Умножить два числа.
Вычислить гипотенузу.
Сам пример намеренно упрощен. Никому не нужен C++ для умножения чисел в рабочем приложении React Native. Цель - показать полный путь выполнения, не скрывая при этом логику за бизнес-логикой.
Мы создадим:
Скрытый текст
MyReactNativeApp/
android/
app/
src/
main/
jni/
CMakeLists.txt
OnLoad.cpp
ios/
NativeMathModuleProvider.h
shared/
NativeMathModule.h
NativeMathModule.cpp
specs/
NativeMathModule.ts
src/
native/
math.ts
App.tsx
package.json
Обычно я разделяю папки по обязанностям:
Папка |
Что там живет? |
specs |
Контракты TypeScript для генерации кода |
shared |
Платформенно-независимая реализация на C++ |
android/app/src/main/jni |
Сборка и регистрация Android |
ios |
Регистрация iOS |
src/native |
Публичная обертка TypeScript |
Важен не выбор названий папок, а принцип разделения. Адаптер модуля не должен становиться местом, где вся логика предметной области будет заброшена.
Шаг 1. Описываем контракт TypeScript
Мы начинаем с границ API, а не с C++.
Создайте файл:
Скрытый текст
specs/NativeMathModule.ts
import type {TurboModule} from 'react-native';
import {TurboModuleRegistry} from 'react-native';
export interface Spec extends TurboModule {
readonly multiply: (a: number, b: number) => number;
readonly hypotenuse: (a: number, b: number) => number;
}
export default TurboModuleRegistry.getEnforcing<Spec>(
'NativeMathModule',
);
Имя файла имеет значение. Файл спецификации должен начинаться с Native , иначе Codegen может его не найти.
getEnforcing означает, что модуль является обязательным. Если React Native не может найти NativeMathModule , он выбрасывает исключение. Мне это нравится для модулей, без которых приложение не может работать, потому что ошибка проявляется немедленно и сразу заметна.
Для дополнительных модулей используйте:
TurboModuleRegistry.get<Spec>('NativeMathModule');
В этом случае результат может быть равен null , и слой TypeScript должен будет это обработать.
Этот контракт - первая полезная страховка. Если сигнатура TypeScript и реализация C++ не совпадают, проект должен завершиться с ошибкой во время сборки, а не позже, в случайной пользовательской сессии.
Шаг 2. Настройка генерации кода
Добавьте codegenConfig в файл package.json :
Скрытый текст
{
"name": "MyReactNativeApp",
"version": "1.0.0",
"codegenConfig": {
"name": "AppSpecs",
"type": "modules",
"jsSrcsDir": "specs",
"android": {
"javaPackageName": "com.myreactnativeapp.specs"
},
"ios": {
"modulesProvider": {
"NativeMathModule": "NativeMathModuleProvider"
}
}
}
}
Эти поля не носят декоративный характер:
Поле |
Значение |
name |
Пространство имен для сгенерированного кода |
type |
Сгенерированный тип сущности |
jsSrcsDir |
Папка со спецификациями |
javaPackageName |
Java-пакет для артефактов Android |
modulesProvider |
Сопоставление между именем модуля и поставщиком iOS |
Поле с именем особенно легко забыть. В этом примере это AppSpecs , и это имя отображается в сгенерированном заголовочном файле C++:
#include <AppSpecsJSI.h>
Если вы переименуете его, обновите включаемые файлы C++. Это тот тип мелких несоответствий, которые могут отнять удивительно много времени на этапе первоначальной настройки.
Шаг 3. Реализуем модуль C++
Теперь мы можем написать сам модуль.
Создавать:
Скрытый текст
shared/NativeMathModule.h
#pragma once
#include <AppSpecsJSI.h>
#include <memory>
namespace facebook::react {
class NativeMathModule
: public NativeMathModuleCxxSpec<NativeMathModule> {
public:
explicit NativeMathModule(
std::shared_ptr<CallInvoker> jsInvoker);
double multiply(
jsi::Runtime& runtime,
double a,
double b);
double hypotenuse(
jsi::Runtime& runtime,
double a,
double b);
};
} // namespace facebook::react
Класс наследует от класса, сгенерированного Codegen:
NativeMathModuleCxxSpec<NativeMathModule>
Здесь контракт TypeScript достигает C++. Если сигнатура метода не соответствует спецификации, сборка должна выдать ошибку.
Каждый метод получает:
jsi:: Runtime& runtime
Для простых вычислений это не нужно. Для настоящих модулей среда выполнения предоставляет доступ к значениям JavaScript и контексту выполнения. Важное правило - не рассматривать это как безобидный указатель, который можно хранить и использовать где угодно. Время жизни во время выполнения и контекст потока имеют значение.
Реализация небольшая:
Скрытый текст
shared/NativeMathModule.cpp
#include "NativeMathModule.h"
#include <cmath>
#include <utility>
namespace facebook::react {
NativeMathModule::NativeMathModule(
std::shared_ptr<CallInvoker> jsInvoker)
: NativeMathModuleCxxSpec(std::move(jsInvoker)) {}
double NativeMathModule::multiply(
jsi::Runtime&,
double a,
double b) {
return a * b;
}
double NativeMathModule::hypotenuse(
jsi::Runtime&,
double a,
double b) {
return std::hypot(a, b);
}
}
Это та часть, которую все хотят написать и это также самая маленькая часть всей истории.
Полезным свойством здесь является отсутствие зависимостей от Android или iOS. Для обеих платформ будет скомпилирована одна и та же реализация на C++.
Шаг 4. Подключаем модуль к Android
Для работы Android необходим набор инструментов для нативной сборки:
Android NDK
CMake
Gradle
Плагин Android Gradle
Инструментарий C++ для Android
NDK и CMake можно установить через SDK Manager в Android Studio.
Создайте папку:
Скрытый текст
cmake_minimum_required(VERSION 3.13)
project(appmodules)
include(
${REACT_ANDROID_DIR}/cmake-utils/ReactNative-application.cmake
)
target_sources(
${CMAKE_PROJECT_NAME}
PRIVATE
../../../../../shared/NativeMathModule.cpp
)
target_include_directories(
${CMAKE_PROJECT_NAME}
PUBLIC
../../../../../shared
)
Параметр target_sources добавляет реализацию C++ в нативную библиотеку. Параметр target_include_directories указывает компилятору, где находятся заголовочные файлы.
В реальном проекте я обычно переносил бы пути в переменные:
set(SHARED_DIR ../../../../../shared)
target_sources(
${CMAKE_PROJECT_NAME}
PRIVATE
${SHARED_DIR}/NativeMathModule.cpp
)
target_include_directories(
${CMAKE_PROJECT_NAME}
PUBLIC
${SHARED_DIR}
)
Далее подключите CMake в файле android/app/build.gradle :
android {
// other configuration
externalNativeBuild {
cmake {
path "src/main/jni/CMakeLists.txt"
}
}
}
Теперь Gradle знает, что сборка Android включает этап CMake.
Регестрируем модуль на Android
React Native по-прежнему должен знать, какой объект C++ следует создать, когда JavaScript запрашивает NativeMathModule .
В:
android/app/src/main/jni/OnLoad.cpp
включить заголовок:
Скрытый текст
#include <NativeMathModule.h>
Затем зарегистрируйте модуль в cxxModuleProvider :
std::shared_ptr<facebook::react::TurboModule>
cxxModuleProvider(
const std::string& name,
const std::shared_ptr<facebook::react::CallInvoker>& jsInvoker) {
if (name == facebook::react::NativeMathModule::kModuleName) {
return std::make_shared<facebook::react::NativeMathModule>(
jsInvoker
);
}
return autolinking_cxxModuleProvider(name, jsInvoker);
}
Не удаляйте эту строку:
autolinking_ cxxModuleProvider( name, jsInvoker)
Это позволяет React Native продолжать поиск модулей, связанных посредством автолинковки. Удаление этой функции - одно из тех незначительных изменений, которые могут сломать что-то несвязанное и создать ощущение, что вся система работает некорректно.
Собираем Android:
yarn android
или:
npm run android
Если кэш сборки начнет создавать проблемы, очистите проект Android:
Скрытый текст
CD Android
cd android
./gradlew clean
cd ..
yarn android
В Windows:
Скрытый текст
cd android
gradlew.bat clean
cd ..
yarn android
Шаг 5. Подключаем модуль к iOS
В iOS-приложениях все обстоит иначе.
Запускайте генерацию кода через Pods:
cd ios
пакетная установка
bundle exec pod install
В процессе выполнения команды pod install React Native генерирует нативные UI.
Откройте рабочую область, а не файл проекта:
открыть MyReactNativeApp.xcworkspace
Использование здесь файлов .xcodeproj - это небольшая ошибка, которая может привести к большой путанице.
Добавление файлов C++ в Xcode
Общая папка должна быть частью проекта Xcode:
Откройте рабочую область.
Выберите проект приложения.
Используйте функцию «
Add Files to....» .Выберите общую папку.
Убедитесь, что целевой объект приложения выбран.
Файлы должны появиться в папке «Compile Sources» .
Создаем iOS ModuleProvider
Для iOS модуль TurboModule, написанный на чистом C++, регистрируется через небольшой адаптер Objective-C++.
Собираем:
Скрытый текст
ios/NativeMathModuleProvider.h
#import <Foundation/Foundation.h>
#import <ReactCommon/RCTTurboModule.h>
NS_ASSUME_NONNULL_BEGIN
@interface NativeMathModuleProvider
: NSObject <RCTModuleProvider>
@end
NS_ASSUME_NONNULL_END
В реализации необходимо использовать расширение .mm , а не .m :
Скрытый текст
ios/NativeMathModuleProvider.mm
#import "NativeMathModuleProvider.h"
#import <ReactCommon/CallInvoker.h>
#import <ReactCommon/TurboModule.h>
#import "NativeMathModule.h"
@implementation NativeMathModuleProvider
- (std::shared_ptr<facebook::react::TurboModule>)getTurboModule:
(const facebook::react::ObjCTurboModule::InitParams &)params
{
return std::make_shared<facebook::react::NativeMathModule>(
params.jsInvoker
);
}
@end
Расширение .mm имеет значение, поскольку этот файл написан на Objective-C++. Обычный файл .m не может использовать типы C++, такие как std:: shared_ptr .
Провайдер подключается через файл package.json :
Скрытый текст
{
"codegenConfig": {
"name": "AppSpecs",
"type": "modules",
"jsSrcsDir": "specs",
"ios": {
"modulesProvider": {
"NativeMathModule": "NativeMathModuleProvider"
}
}
}
}
После изменения конфигурации запустите Pods снова:
cd ios
bundle exec pod install
Затем собираем:
yarn ios
или используйте Xcode напрямую.
Шаг 6. Не допускаем утечки нативного модуля
Первое, что может показаться заманчивым, - это импортировать сгенерированный модуль непосредственно в каждый компонент.
Я стараюсь этого не делать.
Создайте небольшую общедоступную обертку:
Скрытый текст
src/native/math.ts
import NativeMathModule from '../../specs/NativeMathModule';
export const nativeMath = {
multiply(a: number, b: number): number {
if (!Number.isFinite(a) || !Number.isFinite(b)) {
throw new TypeError('Arguments must be finite numbers');
}
return NativeMathModule.multiply(a, b);
},
hypotenuse(a: number, b: number): number {
if (!Number.isFinite(a) || !Number.isFinite(b)) {
throw new TypeError('Arguments must be finite numbers');
}
return NativeMathModule.hypotenuse(a, b);
},
};
Эта оболочка выглядит скучно. Именно поэтому она полезна.
Это позволяет нам в одном месте проверять аргументы, скрывать имя нативного модуля, предоставлять более чистый публичный API, добавлять резервный вариант позже, централизовать обработку ошибок, упрощать тесты и заменять реализацию без переписывания компонентов React.
Оболочка также является местом, где код продукта остается в рабочем состоянии. Нативные имена и сгенерированные контракты не должны распространяться по всему пользовательскому интерфейсу, как плющ.
Шаг 7. Используйте его в React Native
Пример минимального использования:
Скрытый текст
import React, {useState} from 'react';
import {
Button,
StyleSheet,
Text,
TextInput,
View,
} from 'react-native';
import {nativeMath} from './src/native/math';
export default function App(): React.JSX.Element {
const [firstValue, setFirstValue] = useState('3');
const [secondValue, setSecondValue] = useState('4');
const [result, setResult] = useState<number | null>(null);
const calculate = () => {
const a = Number(firstValue);
const b = Number(secondValue);
setResult(nativeMath.hypotenuse(a, b));
};
return (
<View style={styles.container}>
<TextInput
value={firstValue}
onChangeText={setFirstValue}
keyboardType="numeric"
placeholder="First number"
style={styles.input}
/>
<TextInput
value={secondValue}
onChangeText={setSecondValue}
keyboardType="numeric"
placeholder="Second number"
style={styles.input}
/>
<Button
title="Calculate hypotenuse"
onPress={calculate}
/>
{result !== null && <Text>Result: {result}</Text>}
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
gap: 16,
padding: 24,
justifyContent: 'center',
},
input: {
borderWidth: 1,
padding: 12,
},
});
Для значений 3 и 4 метод C++ возвращает 5 .
Повторюсь, математика здесь не главное. Главное - это путь от TypeScript к сгенерированному контракту, затем к C++, регистрации в Android и iOS, а затем обратно к чистой обертке на TypeScript.
Синхронные методы полезны до тех пор, пока они не приведут к зависанию приложения
Приведенные выше методы являются синхронными:
const result = NativeMathModule.multiply(2, 5);
Синхронные API удобны для коротких операций: небольших вычислений, чтения крошечного значения из памяти, проверки флага, преобразования короткой строки или работы с небольшим массивом.
Ловушка имеет ту же форму, что и ловушка для тяжелых работ.
Вот такой API я не хотел бы видеть в продакшене:
const result = NativeImageProcessor.processLargeImage(data);
Если обработка изображения занимает сотни миллисекунд или секунд, JavaScript начинает ждать. UI может перестать отвечать. Модуль может работать быстро на C++, но при этом продукт все равно будет казаться неисправным.
Для тяжелых работ я бы составил контракт по-другому:
Сделайте API асинхронным.
Перенесите вычисления в рабочий поток или собственный исполнитель.
Результат возвращается в виде промиса.
Переход обратно на JavaScript возможен только через безопасный механизм React Native.
Здесь важно одно правило: не следует хранить ссылку на jsi:: Runtime и вызывать ее из случайного фонового потока позже. Доступ к среде выполнения JavaScript должен осуществляться в соответствующем контексте выполнения.
Именно здесь многие нативные модули становятся нестабильными. Код работает в локальном тесте, а затем пользователи начинают выполнять реальные действия: покидать экран, поворачивать устройство, запускать две операции, закрывать приложение, открывать его заново из фонового режима. Внезапно время жизни и границы потоков перестают быть теорией.
Сопоставление типов - это договор, а не рекомендация
Генерация кода поддерживает основные типы данных, которые вам обычно необходимы:
TypeScript |
C++ |
boolean |
bool |
number |
usually double |
string |
std::string |
Array<T> |
сгенерированное представление массива |
object |
сгенерированная структура |
nullable type |
необязательное представление |
Promise |
асинхронный результат |
Есть одна деталь, на которую я всегда обращаю внимание: число в JavaScript - это число типа double по стандарту IEEE 754.
Это означает, что большие целые числа за пределами этого диапазона не являются безопасными в качестве обычных чисел:
-(2^53 - 1) ... 2^53 - 1
Для типов int64_t , идентификаторов, счетчиков или значений, где важна точность, используйте другое представление. В зависимости от версии и конфигурации React Native это может быть строка, пользовательский тип-переходник, разделение значения на части или поддерживаемый путь BigInt .
Важно принимать решение обдуманно. Граница типов, которая почти сохраняет данные, хуже той, которая категорически отказывается компилироваться.
Добавление сторонней библиотеки C++
Одна из распространенных причин создания этого модуля - не написание кода на C++ с нуля, а повторное использование уже существующего.
Предположим, у нас есть библиотека вот такого вида:
Скрытый текст
third-party/
├── include/
│ └── calculator.h
└── src/
└── calculator.cpp
Добавьте это в CMake:
set(THIRD_PARTY_DIR ../../../../../third-party)
target_sources(
${CMAKE_PROJECT_NAME}
PRIVATE
${SHARED_DIR}/NativeMathModule.cpp
${THIRD_PARTY_DIR}/src/calculator.cpp
)
target_include_directories(
${CMAKE_PROJECT_NAME}
PUBLIC
${SHARED_DIR}
${THIRD_PARTY_DIR}/include
)
Если библиотека поставляется в виде предварительно собранного файла .so или .a , объявите его как импортированную библиотеку и свяжите его:
Скрытый текст
add_library(
native_calculator
STATIC
IMPORTED
)
set_target_properties(
native_calculator
PROPERTIES
IMPORTED_LOCATION
"${CMAKE_CURRENT_SOURCE_DIR}/libs/${ANDROID_ABI}/libnative_calculator.a"
)
target_link_libraries(
${CMAKE_PROJECT_NAME}
native_calculator
)
Для Android вам потребуются бинарные файлы для поддерживаемого вами ABI:
arm64-v8a
armeabi-v7a
x86
x86_64
Для производственной среды обычно требуется arm64-v8a . Эмулятору может потребоваться x86_64 , если вы не используете образ ARM.
Для iOS библиотека должна содержать подходящие архитектуры для устройства и симулятора. На практике XCFramework часто является наиболее чистым форматом распространения.
Это одно из тех мест, где становится очевидной реальная стоимость C++. Код на C++ может быть переносимым, но бинарные файлы, архитектуры, флаги сборки и правила упаковки по-прежнему принадлежат каждой конкретной платформе.
Ошибки требуют двух границ
Я предпочитаю проверять ошибки на двух уровнях.
Первый уровень - это JavaScript или TypeScript. Он защищает публичный API и обеспечивает предсказуемые ошибки в коде продукта:
Скрытый текст
export function divide(a: number, b: number): number {
if (b === 0) {
throw new RangeError('Division by zero');
}
return NativeMathModule.divide(a, b);
}
Второй уровень - это C++.
В C++ не следует полностью доверять JavaScript. Модуль может быть вызван из другой обертки позже. Контракт может измениться. После рефакторинга могут появиться некорректные данные. Нативный модуль - не место для оптимизма.
Проверьте, что может сломаться:
числовые границы
размеры массивов
индексы
nullptr
переполнение
собственное состояние объекта
размер входного двоичного файла
Не допускайте, чтобы неподдерживаемые исключения выходили за пределы ABI. Не превращайте обычный недопустимый ввод в сбой процесса. Стратегия обработки ошибок должна соответствовать API TurboModule и быть достаточно простой, чтобы ее поддерживать.
Здесь скука - это хорошо. Встроенные отчеты о сбоях - не самый приятный способ обнаружить отсутствие проверки данных.
Право собственности на память является частью API
C++ предоставляет контроль над памятью. Но он также дает достаточно свободы действий.
Минимальный уровень гигиены, который я ожидаю от участников модуля:
Использовать RAII
Предпочтительнее использовать std:: unique_ptr и std::shared_ptr
Избегайте ручного создания и удаления
Не храните исходные указатели без четкого указания владельца.
Уважайте срок службы TurboModule.
Потоки выпуска, дескрипторы файлов и подписки
Не сохраняйте значение jsi:: Value дольше допустимого срока.
Будьте осторожны с ArrayBuffer и внешней памятью.
Плохое состояние:
SomeProcessor* processor = new SomeProcessor( );
Более безопасная форма:
Скрытый текст
автопроцессор = std:: make_unique<SomeProcessor>();
Если объект находится в состоянии модуля:
class NativeProcessorModule
: public NativeProcessorModuleCxxSpec<NativeProcessorModule> {
private:
std::unique_ptr<Processor> processor_;
};
Речь идет не только о стиле. Право собственности определяет, кому разрешено уничтожать объект, когда он может исчезнуть и что произойдет, если JavaScript все еще считает, что объект существует.
Приложения React Native полны сложных взаимосвязей на протяжении всего жизненного цикла. Экраны размонтируются. Модули могут существовать дольше, чем части UI. Нативная работа может завершиться после того, как исходный JavaScript-вызов будет завершен. Управление памятью - это то, как мы предотвращаем превращение этих взаимосвязей в сбои.
Многопоточность - еще одна область, где модули на C++ имеют смысл
Короткий синхронный метод может выполняться непосредственно во время вызова JavaScript-кода в TurboModule.
Длительная задача не должна быть сложной.
Как минимум, разделите эти контексты в своей голове:
поток JavaScript
поток пользовательского интерфейса
нативные рабочие потоки
потоки, созданные сторонней библиотекой
Длительная работа может переместиться в:
std:: thread
пул потоков
std:: async
исполнитель платформы
очередь, принадлежащая сторонней библиотеке
Модуль по-прежнему должен обрабатывать отмену, безопасное завершение работы, совместно используемые данные, уничтоженные экземпляры модуля и возвращать результат в соответствующий контекст JavaScript.
Для разделяемой памяти доступны стандартные примитивы C++:
std:: mutex
std:: lock_guard
std:: atomic
std:: condition_variable
Проблема не в том, что блокировки существуют. Проблема в том, где и как долго мы их удерживаем. Блокировка, которая слишком долго блокирует поток JavaScript или UI, - это просто еще один способ зависнуть приложение.
Вот почему мне нравится делать адаптер TurboModule максимально простым. Чем больше логики вы вкладываете в адаптер, тем больше возникает соблазн объединить доступ к данным во время выполнения, изменение состояния, фоновую обработку и преобразование API в одном месте.
Что изменяется в проектах Expo?
Expo не запрещает C++ TurboModules. Однако он запрещает делать вид, что произвольный нативный код может быть получен через JavaScript-пакет.
Для такого типа модулей необходима нативная сборка:
npx expo prebuild
npx expo run:android
npx expo run:ios
Еще один вариант - это сборка для разработчиков, созданная с помощью EAS Build.
После обычной установки с помощью npm install, Expo Go не будет содержать ваш пользовательский модуль C++. Он не сможет загрузить нативный код, который никогда не был встроен в приложение.
Непрерывная генерация нативных приложений также меняет рабочий процесс. Внесенные вручную изменения в Android и iOS могут быть потеряны после выполнения команды prebuild --clean. Для создания многократно используемого модуля необходимо продумать конфигурацию CMake для Android, podspec для iOS, автоматическую привязку и, возможно, плагин конфигурации Expo.
Это не повод избегать Expo, а скорее повод честно оценить его границы. Обновления JavaScript и обновления нативных приложений - это не один и тот же механизм развертывания.
Архитектура, которую я предпочитаю
Мне нужна вот такая форма:
Скрытый текст
TypeScript API
|
v
TurboModule adapter
|
v
Platform-independent C++ core
|
+-- algorithms
+-- domain model
+-- third-party libraries
+-- tests
Класс TurboModule должен оставаться достаточно простым.
Она должна преобразовывать входные данные, вызывать ядро, преобразовывать выходные данные и обрабатывать границы модулей. Она не должна становиться местом, где размещается бизнес-логика.
Более аккуратная структура выглядит так:
Скрытый текст
cpp/
├── turbo/
│ ├── NativeMathModule.h
│ └── NativeMathModule.cpp
├── core/
│ ├── MathEngine.h
│ └── MathEngine.cpp
└── tests/
└── MathEngineTest.cpp
Это дает нам несколько практических преимуществ:
Ядро C++ не зависит от React Native.
Проверить основную логику можно на обычном компьютере.
Обновление до React Native проходит безболезненно.
Тот же самый сердечник можно использовать повторно в другом месте.
Уровень интеграции остается достаточно малым, чтобы можно было рассуждать о нем.
Это разделение - не архитектура ради архитектуры, а попытка минимизировать ущерб. Нативная интеграция и так сложна, логика предметной области не должна быть заперта в этой сложности.
Что мы получаем и за что платим
Модуль Turbo Native для C++ предоставляет нам реальные преимущества:
одна реализация для Android и iOS
высокая производительность для вычислительных задач
повторное использование существующих библиотек C или C++
напечатанный контракт через Codegen
ниже по высоте, чем старый мост
синхронное взаимодействие там, где это безопасно
доступ к обработке данных более низкого уровня
Цена тоже вполне реальна:
более сложные сборки
Знание C++, CMake, NDK и Objective-C++
более сложная отладка
риски владения памятью
более медленная сборка нативных приложений
зависимость от внутренних механизмов React Native
Обычное OTA-обновление для кода C++ через JS-пакет недоступно.
Вот какой компромисс я хочу показать командам, прежде чем они начнут. C++ может устранить проблему с производительностью и создать проблему с поддержкой. Иногда это именно тот обмен, который им нужен. Иногда - нет.
Заключение
Мое правило выбора этого пути
Я бы использовал модуль Turbo Native на чистом C++, когда C++ дает ощутимую выгоду: общая кроссплатформенная логика, повторное использование существующей библиотеки или реальное повышение производительности для задач, которые TypeScript не должен выполнять.
Я бы не стал использовать это для того, чтобы придать простой функции серьезный вид.
Наилучшая версия этой архитектуры имеет небольшой стабильный публичный интерфейс, тонкий адаптер TurboModule, отдельное ядро на C++ и тесты, которые не требуют запуска всего мобильного приложения.
Принцип прост: перемещайте только ту часть, которая заслуживает того, чтобы остаться исходной.
Все остальное должно оставаться там, где команда сможет создавать, тестировать, проверять, выпускать и изменять это с минимальными препятствиями.
Результаты


zagidullinalik
Не совсем понимаю, зачем ради таких задач тащить в проект весь этот нативный зоопарк. Да, C++ может дать прирост, но вместе с ним приходят CMake, NDK, проблемы сборки и отладки.