Разработка Native Module для React Native-приложения (iOS)
Мост между JavaScript и нативным Swift/Objective-C — один из самых коварных слоёв React Native. Пока приложение работает только с JS-библиотеками, всё относительно предсказуемо. Но как только появляется задача, которую нельзя закрыть стандартным пакетом — работа с Bluetooth Low Energy через CoreBluetooth, доступ к защищённому Keychain через SecItemCopyMatching, интеграция с нативным SDK банка или платёжной системы — приходится писать Native Module вручную.
И вот здесь начинается.
Архитектура моста: старая и новая
До React Native 0.71 мост работал через асинхронную очередь сообщений: JS-поток сериализовал вызов в JSON, отправлял через мост, нативный поток десериализовал и исполнял. Задержка была приемлемой для большинства задач, но при высокочастотных вызовах (например, обновление UI по данным с датчиков) она становилась заметной.
С версии 0.68 появилась New Architecture — JSI (JavaScript Interface) + Turbo Modules. JSI позволяет вызывать нативный код синхронно через C++ host object, минуя очередь сообщений. Это принципиально меняет подход к написанию модулей: вместо RCTBridgeModule нужно реализовывать TurboModule-протокол через кодогенерацию на основе Flow/TypeScript-спецификации.
На практике большинство проектов до сих пор сидят на старой архитектуре, потому что обновление ломает зависимости. Поэтому мы поддерживаем оба подхода.
Старая архитектура: RCTBridgeModule
Типичная структура — Swift-класс, унаследованный от NSObject с @objc атрибутами:
@objc(BiometricModule)
class BiometricModule: NSObject, RCTBridgeModule {
static func moduleName() -> String { "BiometricModule" }
@objc func authenticate(_ reason: String,
resolver: @escaping RCTPromiseResolveBlock,
rejecter: @escaping RCTPromiseRejectBlock) {
let context = LAContext()
var error: NSError?
guard context.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: &error) else {
rejecter("BIOMETRIC_UNAVAILABLE", error?.localizedDescription, error)
return
}
context.evaluatePolicy(.deviceOwnerAuthenticationWithBiometrics,
localizedReason: reason) { success, authError in
if success { resolver(true) }
else { rejecter("AUTH_FAILED", authError?.localizedDescription, authError) }
}
}
}
Регистрация через RCT_EXTERN_MODULE в Objective-C bridging файле обязательна — без неё модуль не появится в реестре.
Самая частая ошибка на этом этапе: разработчик пишет Swift-класс, забывает добавить @objc(BiometricModule) или неправильно именует метод в RCT_EXTERN_METHOD, и на JS-стороне получает undefined is not a function. Отладить сложно, потому что ошибка появляется в рантайме без стектрейса.
Где реально тратится время
Потокобезопасность. React Native вызывает методы модуля на произвольном потоке из своего пула. Если внутри метода обращаешься к UIKit — краш с UIKit called from background thread. Классическое решение — DispatchQueue.main.async { } вокруг UI-кода. Но это создаёт новую проблему: resolve/reject вызываются асинхронно, и если пользователь успел закрыть экран, completion handler обращается к уже освобождённому объекту.
Паттерн с [weak self] и guard обязателен:
DispatchQueue.main.async { [weak self] in
guard self != nil else { return }
resolver(result)
}
Сериализация данных. Мост принимает только типы, которые умеет сериализовать JSON: NSString, NSNumber, NSArray, NSDictionary, NSNull. Хочешь передать Data (бинарные данные) — кодируй в Base64. Хочешь передать кастомный объект — разбирай его в словарь на нативной стороне. Это особенно больно при работе с CoreBluetooth, когда нужно отдавать CBCharacteristic со всеми его свойствами.
Callbacks vs Promises vs Events. Для одноразовых результатов — Promise. Для потока событий (данные с датчика, статус подключения) — RCTEventEmitter. Смешивать подходы в одном модуле — ошибка, которая приводит к утечкам памяти: если RCTResponseSenderBlock сохранить как property и вызвать дважды, приложение крашится с Tried to call a callback that is no longer valid.
New Architecture: Turbo Modules + Codegen
Начиная с RN 0.70+, Codegen генерирует C++ абстракцию по TypeScript-спецификации. Файл spec выглядит так:
import type { TurboModule } from 'react-native';
import { TurboModuleRegistry } from 'react-native';
export interface Spec extends TurboModule {
authenticate(reason: string): Promise<boolean>;
}
export default TurboModuleRegistry.getEnforcing<Spec>('BiometricModule');
На нативной стороне реализуем протокол NativeBiometricModuleSpec, который Codegen сгенерировал автоматически. JSI позволяет вызывать методы синхронно без сериализации в JSON — скорость принципиально другая.
Проблема: если в проекте есть хотя бы один пакет без Turbo Module поддержки, New Architecture будет работать в режиме совместимости, частично теряя преимущества.
Подход к реализации
Аудит начинается с анализа текущей версии RN, наличия JSI-совместимых пакетов и целевого iOS-деплоймента. Если проект на 0.72+ и команда готова к New Architecture — сразу пишем Turbo Module с Codegen. Если нет — классический RCTBridgeModule с прицелом на будущую миграцию.
Покрытие юнит-тестами нативной части через XCTest обязательно. Интеграционные тесты — через Detox или Jest с моком модуля на JS-стороне.
Документируем публичный API в TypeScript-типах, чтобы команда не лезла в нативный код каждый раз.
Что входит в работу
- Анализ требований и выбор архитектурного подхода (Old Bridge / Turbo Module)
- Написание нативного кода на Swift с Objective-C bridging
- TypeScript-типизация публичного API модуля
- Обработка ошибок, потокобезопасность
- Юнит-тесты нативной части (XCTest)
- Интеграция с JS-слоем, проверка в симуляторе и на реальном устройстве
- Документация по использованию модуля
Сроки
От 3 до 5 дней в зависимости от сложности нативного API, который нужно обернуть. Простая обёртка над одним системным фреймворком — ближе к 3 дням. Модуль с потоком событий, бинарными данными и поддержкой New Architecture — 5 дней и больше. Стоимость рассчитывается индивидуально после анализа требований и кодовой базы.







