在 React Native 的世界里,JavaScript 就像是总部的指挥官,而 Android 和 iOS 则是两个驻扎在不同国家的本地团队。总部下达命令用统一的“总部语言”(JavaScript),但两个团队真正干活时,却需要用各自的“本地语言”(Java/Kotlin 和 Swift)。原生模块开发,就是为这种跨国协作编写的一本“标准化操作手册”——它规定总部如何下达指令、本地团队如何执行并汇报结果、遇到紧急情况如何主动向总部报警。当你掌握了这本手册的编写方法,你就能让 React Native 应用拥有调用系统相机、访问通讯录、读取传感器等等“本地人”才有的超能力。

岗位关联

对 AI 从业者而言,掌握原生模块开发意味着你不仅能训练和部署模型,还能将 AI 能力无缝嵌入移动应用,调用设备端原生性能实现端侧智能——这让你的 AI 解决方案从“能用”升级为“好用”。

目录
  1. 可视化演示
  2. 原生模块概览
  3. Android实现
  4. iOS实现
  5. 注册与导出
  6. 线程与回调
  7. 事件发射机制
  8. 优化与内存管理
  9. AI 应用场景
  10. Python 演示
  11. 常见误区
  12. 面试题

可视化演示

展示React Native桥接架构三层通信、线程模型任务分发与事件发射Promise回调机制,支持模式切换。

本节配有交互动效(进入视口后自动加载,约 3KB)。

RN桥接架构 JS线程通过Bridge向原生线程发送异步消息 Bridge通过JSON序列化传输模块名+方法名+参数 NativeModules.Mod.method(args) RN桥接实现JS与Android/iOS原生能力异步通信

原生模块概览

想象你是一家跨国公司的总部协调员。公司有两个本地办公室:一个在柏林(Android),一个在东京(iOS)。总部用的官方语言是 JavaScript,但柏林办公室内部用 Java/Kotlin 交流,东京办公室用 Swift/Objective-C 交流。总部想让他们执行某个任务(比如“读取手机电池电量”),不能直接对柏林同事说德语——总部不会说德语。这时候,就需要一本“协作手册”来规定:总部用英文下达标准的任务单,柏林办公室用德语执行后,把结果翻译成英文交回总部。在 React Native 的世界里,这本“协作手册”就是原生模块(Native Module)。

原生模块解决的正是 React Native 的能力边界问题。React Native 的 JavaScript 层能处理 UI 逻辑、业务状态,但遇到操作系统级别的功能——调用摄像头、读取通讯录、访问传感器——就鞭长莫及了。原因是 JavaScript 运行在受限的 JavaScriptCore(或 Hermes)引擎中,无法直接访问操作系统 API。原生模块就像在总部指挥中心和本地团队之间建立的一条专属热线,把原生的“本地能力”以 JavaScript 函数的形式暴露给 RN 代码调用。

原生模块的开发思路是双平台各自实现、JS 侧统一调用。也就是说,Android 团队用 Kotlin 写一套逻辑,iOS 团队用 Swift 写一套逻辑,两套逻辑完成同样的“任务”,但都在各自的办公室里用各自的语言干活。对于 JS 总部的指挥官来说,他只需要记住一个统一的 JavaScript API 名字,不需要关心底层是 Android 还是 iOS。这种“一个接口、双端实现”的模式,正是跨平台开发的精髓——总部只认手册上的标准流程,柏林和东京各自用自己的方式完成。

Android实现

让我们把镜头切到柏林的 Android 办公室。总部发来了一份任务单:“请实现一个模块,能够获取设备型号和电池电量。”柏林团队需要做的是:用他们的母语 Kotlin(或 Java)创建一个“本地团队”——准确地说,是一个继承自 ReactContextBaseJavaModule 的类。这个基类就像是柏林办公室的统一制服,穿上它,团队成员才能被总部识别。

第一步是重写 getName() 方法,这个方法返回一个字符串,就是这本协作手册里“柏林团队”的代号。总部将来调用时,就用这个名字来定位到柏林团队。比如返回 "DeviceInfo",那么 JS 侧通过 NativeModules.DeviceInfo 就能找到它。这就像办公室门口的铭牌,让总部派来的信使一眼就能找到正确的门。

第二步是声明具体的方法,并用 @ReactMethod 注解标记。这个注解就是盖章——表示“这个方法可以接受总部的远程指令”。没有盖这个章的方法,即使写在类里,总部也无权调用。看一个典型例子:

需要注意的是参数类型。JS 侧传递过来的参数,在 Android 端被映射为标准的 Java/Kotlin 类型:Boolean、Double、String、ReadableMap(对应 JS 对象)、ReadableArray(对应 JS 数组)。如果你在 JS 侧传入了一个数字数组,在 Kotlin 侧收到的就是一个 ReadableArray。这就是手册里的“翻译规则”——总部语言中的每个词,柏林团队都规定了对应的德语词,不能随便翻译。

kotlin16 行
class DeviceInfoModule(reactContext: ReactApplicationContext) :     ReactContextBaseJavaModule(reactContext) {        override fun getName() = "DeviceInfo"        @ReactMethod    fun getBatteryLevel(promise: Promise) {        // 获取电池电量的原生代码        val level = getBatteryLevelFromSystem()        if (level != -1) {            promise.resolve(level)        } else {            promise.reject("ERROR", "无法读取电量")        }    }}

iOS实现

镜头转向东京的 iOS 办公室。东京团队接到的是同一份任务单,但他们用 Swift 来完成。在 iOS 的世界里,一个原生模块需要遵循 RCTBridgeModule 协议——这相当于东京办公室的“员工身份认证”。任何想要接收总部指令的类,都必须先签署这份协议,表明“我愿意遵守总部协作手册的规则”。

Swift 中实现原生模块的关键在于暴露给 Objective-C 运行时。由于 React Native 的桥接层是 Objective-C 写的,Swift 类需要通过 @objc 关键字将自己的方法和属性暴露给 Objective-C 运行时查看。类还要提供一个返回模块名的静态方法 moduleName(),这个方法的返回值就是 iOS 侧注册到总部的模块代号。这类似于柏林办公室的 getName()。

方法的导出使用 @objc 加 RCT_EXTERN_METHODS 宏(在 Objective-C 中)或在 Swift 中通过 @objc 标记方法并用 RCT_EXPORT_METHOD 宏导出。在 Swift 中通常这样写:

在 iOS 中,方法的回调方式有两种:一种是使用 RCTResponseSenderBlock(传统的回调函数方式),另一种是使用 RCTPromiseResolveBlock 和 RCTPromiseRejectBlock(Promise 方式)。现代 RN 开发中,Promise 方式更推荐,因为它在 JS 侧天然支持 async/await 语法,代码更简洁易读。

iOS 端的注意点是 Swift 与 Objective-C 的互操作。React Native 的模块系统底层用 Objective-C 的运行时动态查找方法,所以 Swift 中每一个要导出给 JS 的方法都必须标记 @objc,且方法签名必须符合桥接层的要求。如果方法签名写错了,桥接层找不到对应的方法,JS 侧调用就会直接报错——就像东京团队的门牌号写错了,总部的信使在楼里转了一圈也找不到人。

swift18 行
@objc(DeviceInfoModule)class DeviceInfoModule: NSObject, RCTBridgeModule {    static func moduleName() -> String! {    return "DeviceInfo"  }    @objc func getBatteryLevel(_ resolve: RCTPromiseResolveBlock,                             rejecter reject: RCTPromiseRejectBlock) {    let level = UIDevice.current.batteryLevel    if level >= 0 {      resolve(level)    } else {      reject(NSError(domain: "DeviceInfo", code: -1,                      userInfo: [NSLocalizedDescriptionKey: "无法读取电量"]))    }  }}

注册与导出

柏林和东京的团队都建设好了,但总部还需要一本完整的“通讯录”——所有团队的门牌号、专长领域、可接任务的清单。这就是模块的注册(Registration)环节。没有完成注册的模块,即使类写得再完美,总部也调用不到——就像团队没在总部人力系统备案,总部根本不知道有这个团队存在。

Android 端的注册流程是这样的:需要创建一个 ReactPackage 的实现类,在 createNativeModules() 方法中返回你的模块实例列表,然后用 addPackage() 方法把这个包添加到 MainApplication 的 getPackages() 列表中。代码看起来像这样:

iOS 端的注册相对简单。在 Objective-C 中,使用 RCT_EXPORT_MODULE() 宏就能完成注册;如果用 Swift,则通过 RCTBridgeModule 协议 + moduleName() 方法自动完成。iOS 还有一种导出控件的方法,但那是 View 相关的内容,我们这里只关注模块方法。

导出方法时需要注意的是参数类型的一一对应。JS 侧的方法参数与原生方法参数必须按顺序匹配——如果 JS 侧调用 getBatteryLevel()(无参数),但原生侧定义了 getBatteryLevel(promise: Promise),桥接层仍然能找到方法,因为 Promise 是最后自动注入的;但如果 JS 侧传入 (string, number),原生侧期望 (Double, String),类型不匹配就会导致调用失败。这就好比总部发来的任务单上写着“用德语回复”,但柏林团队用日语回复——两边都懵了。

还有一个常见陷阱:Android 中导出方法时不要使用 Boolean 以外的 Java 包装类作为简单参数类型(如 Integer、Float),桥接层对基本类型映射最稳定。同时,方法的返回值必须为 void,结果必须通过回调或 Promise 返回——JS 和原生之间是异步通信,原生方法无法同步返回结果。这就像总部下达任务后不能站在门口等结果,而是先回办公室,等柏林团队完成后通过信使把结果送回总部。

kotlin7 行
class DeviceInfoPackage : ReactPackage {  override fun createNativeModules(reactContext: ReactApplicationContext):       List<NativeModule> = listOf(DeviceInfoModule(reactContext))    override fun createViewManagers(reactContext: ReactApplicationContext):       List<ViewManager<*, *>> = emptyList()}

线程与回调

现在,总部发出了调用指令,柏林团队和东京团队开始行动了。这时就涉及一个关键问题:任务在哪个“工作台”上执行? React Native 的线程模型可以用一个三层办公室结构来理解:最顶层是 JS 线程(总部办公室),中间层是原生模块线程池(各团队的公共工作区),底层是 UI 主线程(团队的“客户接待室”)。

默认情况下,Android 的原生模块方法会在一个独立的原生线程池中执行,iOS 的方法会在桥接层分配的队列中执行——它们都不在主线程上。这样做的好处是:耗时操作(比如文件读取、网络请求)不会阻塞 UI。但坏处是:如果你在原生方法中直接操作 UI(比如弹一个 Toast 或者更新 View),就必须切换到主线程。Android 上使用 reactContext.runOnUiQueueThread(),iOS 上使用 DispatchQueue.main.async。

线程管理中最核心的概念是Promise 回调。总部(JS 侧)发出调用后不会干等着,而是留下一个“回执”——Promise。原生侧完成计算后,通过调用 promise.resolve(result) 或 promise.reject(error) 来回传结果。这个回传是异步的,总部收到后继续执行后续逻辑。JS 侧这样调用:

回调结果传递的过程中,需要注意两点。第一,回调可能从任意线程发出(执行任务的线程);第二,JS 侧收到回调后会回到 JS 线程继续执行,这个过程由桥接层自动处理。但如果你在原生侧把一个耗时操作放到了后台线程,然后在这个后台线程里调用 resolve,这个 Promise 的“回传信使”会自动把结果带到 JS 线程——你不需要手动处理线程切换。

一个常见的问题是:在 Android 的原生方法中,如果你在后台线程执行完任务后,还想弹出 UI 提示(比如 Toast),就必须回到 UI 线程。RN 提供了专门的 API,Android 端可以直接使用 reactContext.getCurrentActivity()?.runOnUiThread { ... }。iOS 端在后台队列执行完任务后,如果要在主线程更新 UI,也需要手动切换到主队列。记住这个原则:耗时操作后台跑,UI 操作主线程,回调通过 Promise 异步送达。

javascript2 行
const level = await NativeModules.DeviceInfo.getBatteryLevel();console.log('电池电量', level);

事件发射机制

前面讲的都是总部主动打电话给本地团队,那如果本地团队想主动向总部报告消息呢?比如电池电量下降到了 20%,或者用户截了一张图——这些是原生侧主动发生的事件,JS 总部并不知情。这时候就需要事件发射机制(Events)。这就好像东京办公室的传感器监测到了台风,需要主动向总部发警报——不需要总部先来问“今天天气怎么样”。

在 Android 端,事件发射通过 reactContext.getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java) 获取事件发射器,然后调用 emit(eventName, params) 发送事件。在 iOS 端则通过 self.bridge.eventDispatcher() 或 self sendEvent(withName:body:) 发送。两端的事件名必须一致,这样 JS 侧监听时才能用同一个名字。

JS 侧的监听方式如下:

这里最大的坑是生命周期管理。如果你在组件中注册了事件监听,但在组件卸载时没有移除监听,就会造成内存泄漏——JS 侧还保留着对原生事件的引用,原生侧反复发射事件,导致无用的回调不断触发。正确做法是:在 useEffect 的清理函数中调用 subscription.remove(),或者用 emitter.removeAllListeners('onBatteryLow')。

事件发射的另一个要点是跨平台统一。Android 的事件发射器和 iOS 的事件发射器在 JS 侧都归结为同一个 NativeEventEmitter API,你只需要在 JS 侧创建一个 emitter 实例,然后监听特定的事件名即可。事件名在两端的原生代码中必须完全一致——如果 Android 端发的是 "onBatteryLow",iOS 端发的也是 "onBatteryLow",JS 侧就能用同一个监听器处理两个平台的事件。这就好比柏林和东京虽然用不同的语言汇报,但汇报的标题栏都统一用英文填写,总部才能统一处理。

javascript11 行
import { NativeEventEmitter, NativeModules } from 'react-native'; const { DeviceInfo } = NativeModules;const emitter = new NativeEventEmitter(DeviceInfo); const subscription = emitter.addListener('onBatteryLow', (data) => {  console.log('电量低!', data.level);}); // 不再需要时,记得移除监听subscription.remove();

优化与内存管理

最后,我们来聊聊让协作手册更高效的优化技巧。原生模块的每一次 JS 与原生之间的通信,都要经过桥接层的序列化、传输、反序列化——就像总部和本地团队之间每次通信都要派一个信使,信使来回跑是要耗费时间和体力的。如果总部每隔 100 毫秒就派一个信使去问“电池电量是多少”,不仅信使累垮,连总部和本地团队的工作效率都会下降。

第一项优化:减少桥接调用频率。 将频繁的小调用合并为一次大的调用。比如游戏应用中需要监听陀螺仪的连续数据,如果每帧都通过桥接传一次数据,性能会很差;更好的做法是原生侧积累 10 帧的数据后一次传给 JS。就像总部不要求柏林团队“每完成一个小任务就汇报一次”,而是“每完成一个阶段再统一汇报”。

第二项优化:合理管理监听器资源。 原生侧在 onCatalystInstanceDestroy(Android)或 invalidate(iOS)时,要清理所有注册的监听器和事件发射器。Android 端有专门的 onHostDestroy 回调,可以在 Activity 销毁时释放资源。这就像办公室下班时要关灯断电,不然一晚上电都白白浪费。

第三项优化:注意大对象和引用的释放。 不要让原生侧持有 JS 侧传递的大对象(如 ReadableMap、ReadableArray)的引用,因为 JS 侧的对象在原生侧引用后,JS 的垃圾回收无法回收它,会导致内存泄漏。正确的做法是:用完后立即置空引用,或者只提取需要的字段后丢弃原始对象。另外,Promise 回调对象不要长期持有——一旦 resolve 或 reject 之后,应该立刻释放引用,否则回调闭包持有的上下文不会被回收。

内存问题最典型的场景是:在原生模块中启动了一个长期运行的后台任务(比如位置监听),任务持有模块实例的引用,模块又持有 ReactContext 的引用,导致整个 Activity 无法被回收。这种问题在 Android 上尤其常见。解决办法是在模块的 invalidate() 方法中停止任务、清除监听器、释放引用。

AI 应用场景

场景一:端侧 AI 推理的相机模块封装

阶段:模型部署与推理

解决的问题:RN 应用需要调用系统相机进行实时画面采集,并将帧数据送入端侧推理引擎(如 TensorFlow Lite)。传统做法是把图像数据通过 Base64 编码后从 JS 传到原生侧,每次传输几十 KB,延迟高且占内存。通过原生模块,可以直接在原生侧捕获相机帧,将灰度转换、缩放等预处理放在原生栈中完成,只将最终推理结果(如检测框坐标、分类标签)通过 Promise 回传给 JS。

具体案例:一个 AI 拍照翻译 App,相机预览用原生模块驱动,图片预处理(裁剪、旋转、亮度调整)全部在原生侧完成,推理结果通过事件发射机制实时推送识别文本到 RN 界面。JS 侧只负责渲染结果和交互逻辑。

场景二:AI 语音助手的录音与音频流模块

阶段:数据采集与预处理

解决的问题:语音助手需要持续采集麦克风音频流,并将音频帧实时送入门控或唤醒词检测模型。RN 的 JS 层无法直接操作麦克风,原生模块实现音频流采集,并将音频分帧后通过事件机制高频推送给 JS 侧(或原生侧直接跑模型)。这样避免每次录音都经过网络,实现离线语音唤醒。

具体案例:一个跨平台的语音笔记 App,通过原生模块在后台持续监听麦克风,检测到“你好小助”的唤醒词后触发录音,录音文件先缓存在原生侧,再进行 VAD(语音活动检测),最后将有效语音段上传服务端识别。全程用户无感知,续航表现良好。

场景三:AI 健康检测的传感器数据桥接

阶段:多模态数据融合

解决的问题:健康监测 App 需要读取心率、血氧、加速度计等多传感器数据,并实时分析。不同传感器的数据率不同(心率 1Hz、加速度计 50Hz),如果全部通过 JS 桥接传输,会造成大量线程切换开销。原生模块先在原生侧对多路传感数据进行时间戳对齐和轻度滤波,再以压缩后的特征帧一次性传给 JS 侧做上层逻辑判断。

具体案例:一个跑步健身 App 同时采集心率与加速度数据,原生模块负责数据对齐与运动状态初判(跑步/走路/静止),通过事件机制每 500ms 推送一次状态向量到 JS 侧,JS 侧仅负责 UI 展示和异常提醒(如心率过高)。

Python 演示

python132 行
import jsonimport threadingimport timefrom collections import defaultdict  class Promise:    """模拟 RN 的 Promise 回调对象"""    def __init__(self):        self._resolve = None        self._reject = None     def then(self, resolve, reject=None):        self._resolve = resolve        self._reject = reject        return self     def resolve(self, value):        if self._resolve:            self._resolve(value)     def reject(self, error):        if self._reject:            self._reject(error)  class NativeModuleBase:    """模拟原生模块基类(类似 ReactContextBaseJavaModule)"""    module_name = "BaseModule"     def getName(self):        return self.module_name  class AndroidDeviceModule(NativeModuleBase):    """模拟 Android 端原生模块(Kotlin 实现)"""    module_name = "DeviceInfo"     def __init__(self):        self._listeners = []     def getBatteryLevel(self, promise):        # 模拟异步获取电量        def _task():            time.sleep(0.1)            promise.resolve(85)        threading.Thread(target=_task).start()     def startBatteryMonitor(self, event_emitter):        # 模拟持续发射事件        def _monitor():            for i in range(3):                time.sleep(0.5)                event_emitter.emit("onBatteryChange",                                   {"level": 85 - i * 5})        threading.Thread(target=_monitor).start()  class IOSDeviceModule(NativeModuleBase):    """模拟 iOS 端原生模块(Swift 实现)"""    module_name = "DeviceInfo"     def getBatteryLevel(self, promise):        promise.resolve(88)  class Bridge:    """模拟桥接层:模块注册 + 方法分发 + 事件发射"""    def __init__(self):        self._modules = {}        self._event_listeners = defaultdict(list)        self._platform_impl = {}     def registerModule(self, module, platform):        self._modules[module.getName()] = module        self._platform_impl[module.getName()] = platform     def call(self, module_name, method_name, *args):        module = self._modules.get(module_name)        if not module:            raise RuntimeError(f"模块 {module_name} 未注册")        fn = getattr(module, method_name, None)        if not fn:            raise RuntimeError(f"方法 {method_name} 不存在")        promise = Promise()        last_arg = args[-1] if args else None        if isinstance(last_arg, Promise):            fn(*args[:-1], promise)        else:            fn(promise)        return promise     def emit(self, event_name, payload):        for cb in self._event_listeners[event_name]:            cb(payload)     def addListener(self, event_name, callback):        self._event_listeners[event_name].append(callback)     def removeListeners(self, event_name):        self._event_listeners[event_name].clear()  def main():    bridge = Bridge()     # 按平台注册模块(双平台同界面,同逻辑)    bridge.registerModule(AndroidDeviceModule(), "android")    bridge.registerModule(IOSDeviceModule(), "ios")     # JS 侧调用获取电量(走 Android 实现)    def on_resolve(value):        print(f"[JS] 收到电量: {value}%")    promise = bridge.call("DeviceInfo", "getBatteryLevel")    promise.then(on_resolve)     # JS 侧监听电池变化事件    bridge.addListener("onBatteryChange", lambda data:                       print(f"[JS] 事件: 电量变为 {data['level']}%"))     # 启动 Android 端的持续事件发射    android_module = bridge._modules["DeviceInfo"]    android_module.startBatteryMonitor(bridge)     # 让异步任务完成    time.sleep(2)    print("[JS] 移除事件监听")    bridge.removeListeners("onBatteryChange")  if __name__ == "__main__":    main()
目的:模拟 RN 原生模块的注册、调用、Promise 回调和事件发射全流程,帮助理解桥接层的工作原理。
  1. promise.resolve(85)模拟原生侧异步任务完成后,通过 Promise 将结果回传给 JS 侧。
  2. bridge.registerModule(AndroidDeviceModule(), "android")模拟按平台注册原生模块,实现“一个接口、双端实现”。
  3. bridge.emit("onBatteryChange", {"level": ...})模拟原生侧主动发事件,JS 侧通过监听器接收。
  4. module.getName()模拟原生模块返回名称,供桥接层索引调用。
  5. event_emitter.emit()对应 RN 中 RCTDeviceEventEmitter 的事件发射机制。
  6. bridge.addListener()对应 JS 侧 NativeEventEmitter.addListener() 注册监听。
  7. getattr(module, method_name, None)模拟桥接层的动态方法查找与分发。
  8. bridge.removeListeners()模拟组件卸载时移除监听,防止内存泄漏。

常见误区

误区“React Native 是跨平台的,所以原生模块也应该是跨平台的,我只需要写一份代码,两个平台都能用。”

正解原生模块的本质是“接口统一、实现分离”。JS 侧的调用代码可以完全一致,但 Android 必须用 Java/Kotlin 实现,iOS 必须用 Swift/Objective-C 实现。就像柏林办公室和东京办公室都执行总部的“读取电量”任务,但柏林用德语读系统服务,东京用日语读 UIDevice。你不能把 Swift 的代码直接搬到 Kotlin 里运行。

为何常见:“跨平台”这个词容易让人误以为连原生层也是共享的。实际上 React Native 跨平台的是 JS 层和 UI 层,原生能力仍然需要双端各自实现。

误区“Promise 的 resolve 方法在任何线程调用都没问题,反正 JS 侧能收到就行。”

正解Promise 回调可以在任意线程发起,但发起后的数据序列化和 JS 线程调度由桥接层自动处理。问题在于,如果你在后台线程中执行完任务后,又想在原生侧更新 UI,不能直接在那个线程里操作——UI 操作必须回到主线程。Android 用 runOnUiQueueThread,iOS 用 DispatchQueue.main.async。Promise 只负责回传数据,不负责帮你切线程做 UI。

为何常见:初学者以为 Promise 回调就是“线程安全的万能通道”,忽视了原生侧 UI 线程模型的存在。

误区“我注册了事件监听,React Native 会在组件销毁时自动帮我清理,不需要手动移除。”

正解JS 侧的 NativeEventEmitter.addListener() 返回一个订阅对象,必须手动调用 subscription.remove() 或在 useEffect 清理函数中移除监听。如果组件卸载后监听器仍然存在,原生侧每次发射事件都会触发回调,导致内存泄漏和逻辑错误。Android 端的 onHostDestroy 和 iOS 端的 invalidate 也要求你在原生侧主动清理资源。

为何常见:很多开发者习惯了 Web 前端框架的自动清理机制,忽视了 RN 桥接层中 JS 与原生之间的引用关系是隐式且长期存活的。

误区“反正桥接层是异步的,我可以每帧都向原生侧发送数据,React Native 会处理好的。”

正解每一次桥接调用都有序列化、跨线程传输、反序列化的固定开销。高频调用(如 60fps 的实时数据)会占用大量桥接带宽,导致 UI 卡顿。正确的做法是批量传输:原生侧积累一段时间的数据后一次性发给 JS,或者通过 addListener 的 batch 参数做批量事件传递。

为何常见:JS 和原生之间的通信看起来像普通函数调用,实际上底层是跨线程的异步消息传递,性能开销被“异步”的表象所掩盖。

误区“原生方法返回一个自定义对象,桥接层会自动帮我序列化成 JS 对象,JS 侧直接访问属性就行。”

正解桥接层只能序列化标准的 JSON 可序列化数据类型。Android 上自定义 Kotlin data class 不能直接返回给 JS,必须手动转换成 WritableMap;iOS 上自定义 Swift 结构体也需要转换成 NSDictionary 或兼容的 JSON 对象。JavaScript 侧的 NativeModules 调用只能返回桥接层认识的类型,自定义类型会静默失败或抛出异常。

为何常见:现代编程语言的对象序列化机制让开发者习惯了自动序列化,但 RN 桥接层的序列化能力是有限且固定的,不具备通用反射序列化能力。

面试题

Q1. React Native 中原生模块的 Android 端实现,需要继承哪个基类?

单选题 · easy

  • A.ReactContextBaseJavaModule← 正确答案
  • B.ReactActivity
  • C.NativeModuleBase
  • D.BaseReactModule

答案:A。Android 端原生模块必须继承 ReactContextBaseJavaModule,它提供了与 ReactContext 交互的基础能力。ReactActivity 是 Activity 的基类,与原生模块无关;NativeModuleBase 和 BaseReactModule 在 RN 的 Android 端并不存在,是干扰项。

Q2. iOS 端(Swift)实现原生模块时,需要遵循哪个协议?

单选题 · easy

  • A.RCTBridgeModule← 正确答案
  • B.NSObjectProtocol
  • C.RNNativeBridge
  • D.SwiftModuleDelegate

答案:A。iOS 端(Swift 或 Objective-C)实现原生模块需要遵循 RCTBridgeModule 协议,它是 RN 桥接层识别原生模块的标准协议。NSObjectProtocol 是所有 NSObject 都遵循的基础协议,不提供模块能力;另外两个选项是杜撰的名字。

Q3. 原生模块的方法需要返回数据给 JS 侧时,正确的做法是什么?

单选题 · mid

  • A.直接 return 一个字符串,桥接层自动处理
  • B.使用 Promise 或 Callback 异步回传结果← 正确答案
  • C.通过全局变量传递,JS 侧自行读取
  • D.将结果写入文件,JS 侧读文件

答案:B。RN 桥接是异步的,原生方法必须通过 Promise(或 Callback)回传结果,不能同步 return。直接 return 在桥接层会被忽略;全局变量和文件读写都绕过了桥接机制,不是合法的通信方式。

Q4. 在 Android 的原生模块中,如果需要弹出一个 Toast,正确的线程处理方式是?

单选题 · mid

  • A.直接在模块方法中调用 Toast,无需切线程
  • B.在 ReactContext 的 runOnUiQueueThread 中执行 Toast← 正确答案
  • C.在后台线程池中执行 Toast
  • D.通过 Promise 回传给 JS 后由 JS 触发

答案:B。UI 操作(如 Toast)必须在主线程执行。原生产模块方法默认运行在原生线程池中,需要切换到 UI 线程。直接弹 Toast 可能在子线程崩溃;后台线程池执行仍是子线程;回传给 JS 再触发 Toast 绕了远路,且无法直接控制原生 UI。

Q5. iOS 端导出方法回传结果时,Swift 中应该使用什么类型的回调参数?

单选题 · mid

  • A.(String) -> Void
  • B.RCTPromiseResolveBlock 与 RCTPromiseRejectBlock← 正确答案
  • C.NSObject -> Void
  • D.@escaping (Bool) -> Void

答案:B。iOS 桥接层提供的 Promise 回调类型是 RCTPromiseResolveBlock 和 RCTPromiseRejectBlock,它们会被映射为 JS 侧的 Promise。普通的 Swift 闭包无法被桥接层识别;NSObject -> Void 和 (Bool) -> Void 不是 RN 桥接的标准回调类型。

Q6. 以下哪项是 Android 端注册原生模块的必需步骤?

单选题 · easy

  • A.实现 ReactPackage 并加入 MainApplication 的包列表← 正确答案
  • B.在 AndroidManifest.xml 中声明模块
  • C.在 build.gradle 中添加模块依赖
  • D.创建一个新的 Activity 继承模块类

答案:A。Android 端注册原生模块必须通过 ReactPackage 实现类,并在 MainApplication.getPackages() 中添加。AndroidManifest.xml 用来声明权限和组件,不需要声明原生模块;build.gradle 只管依赖配置;Activity 继承模块类完全不相关。

Q7. 关于 JS 侧调用原生模块方法的参数类型,以下哪些说法是正确的?

多选题 · mid

  • A.Android 端 JS 对象对应 ReadableMap← 正确答案
  • B.Android 端 JS 数组对应 ReadableArray← 正确答案
  • C.iOS 端 JS 数字对应 NSInteger
  • D.两端方法参数的顺序可以随意调整

答案:AB。Android 端 JS 对象和数组分别对应 ReadableMap 和 ReadableArray,这是桥接层定义的映射。iOS 端 JS 数字映射的是 NSNumber 而不是 NSInteger(后者是原生 C 类型);参数的顺序必须与 JS 调用一致,随意调整会导致类型不匹配。

Q8. 关于原生模块的事件发射(Events),以下哪些做法是正确的?

多选题 · hard

  • A.在组件卸载时移除事件监听器← 正确答案
  • B.Android 端通过 DeviceEventManagerModule 的 emit 发送事件← 正确答案
  • C.事件名在 Android 和 iOS 两端可以不同
  • D.使用 NativeEventEmitter 接收事件← 正确答案

答案:ABD。正确的做法包括:组件卸载时移除监听器防止泄漏;Android 端通过 DeviceEventManagerModule.RCTDeviceEventEmitter 发射事件;JS 侧用 NativeEventEmitter 接收。事件名在 Android 和 iOS 两端必须一致,否则 JS 侧无法用统一的名字监听。

Q9. 原生模块的优化中,处理大量高频数据(如传感器数据)的最佳实践是?

单选题 · hard

  • A.每次数据变化都通过桥接层直接传给 JS
  • B.在原生侧聚合数据,降低桥接传输频率← 正确答案
  • C.将数据写入 SQLite,JS 侧查询
  • D.用全局变量存储数据,JS 定时读取

答案:B。高频数据的最佳实践是原生侧聚合(比如累积多个采样点后一次性传输),以降低桥接调用的开销。每次都传会导致桥接层拥堵、UI 卡顿;写数据库和全局变量绕过了桥接机制,引入额外延迟和不必要的复杂度。

Q10. 以下哪些做法能有效防止原生模块导致的内存泄漏?

多选题 · hard

  • A.在模块销毁时移除所有事件监听器← 正确答案
  • B.长时间持有 Promise 对象的引用
  • C.后台任务完成后释放 ReactContext 引用← 正确答案
  • D.长期持有 JS 传入的大型 ReadableMap

答案:AC。防止内存泄漏的正确做法包括:模块销毁时移除监听器、后台任务完成后释放 ReactContext 的引用。长时间持有 Promise 会阻止回调闭包被回收,大型 ReadableMap 长期持有会导致 JS 侧对象无法被 GC 回收——这两者都是常见的泄漏原因,选它们反而是错误做法。