Розробка 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 днів та більше. Вартість розраховується індивідуально після аналізу вимог та кодової бази.







