4. 搭建第一个iOS原生模块

本节的目标不是“看懂一个示例”,而是亲手完成一条最小可用的原生能力通道:在 React Native 工程中创建一个 iOS 原生模块,用 Objective-C 或 Swift 实现方法,通过 RCTBridgeModule 与 RCT_EXPORT_METHOD 把方法暴露给 JavaScript,最后在 JS 侧成功调用并拿到返回值。这条链路一旦跑通,后续接入相机、蓝牙、加密、系统信息等能力都只是替换具体实现。

一、先明确这条链路的四个角色

React Native 的 iOS 原生模块并不是“JS 直接调用 OC 方法”,中间存在明确的桥接层。理解这四个角色,能避免后面出现“方法写了但 JS 找不到”的困惑:

  • JS 侧:通过 NativeModules.模块名.方法名(参数) 发起调用,返回 Promise 或回调。
  • RCTBridge:RN 的桥,负责在 JS 线程与原生线程之间传递调用消息、参数和结果。
  • 原生模块类:继承 NSObject 并遵循 RCTBridgeModule 协议,是方法的实际承载者。
  • 导出宏:RCT_EXPORT_MODULE 注册模块,RCT_EXPORT_METHOD 注册可被 JS 调用的方法。

下面这张图展示了从 JS 调用到原生返回的完整信号链,注意模块名、方法名和参数是如何穿过桥的:

JS 侧 NativeModules.DeviceInfo .getDeviceName() 返回 Promise RCTBridge 序列化调用消息 跨线程调度 JS 线程 ↔ 原生线程 原生模块类 DeviceInfoModule : NSObject <RCTBridgeModule> RCT_EXPORT_METHOD 注册 调用 分发 结果 回调 关键注册点 RCT_EXPORT_MODULE(DeviceInfo) RCT_EXPORT_METHOD(getDeviceName:) resolve / reject 回调 模块名默认取类名去掉 Module 后缀;方法名取冒号前的选择器片段。 参数按顺序映射,resolve 返回成功值,reject 返回错误码与信息。

二、创建工程与原生模块文件

先准备一个可运行的 RN 工程。如果还没有,用以下命令创建(示例工程名 NativeDemo):

npx react-native init NativeDemo
cd NativeDemo/ios
pod install

接下来在 iOS 工程目录下新建原生模块文件。推荐放在 ios/NativeDemo/ 下,与 AppDelegate 同级,便于 Xcode 自动纳入编译。这里以 Objective-C 为例,创建 DeviceInfoModule.h 与 DeviceInfoModule.m。

头文件负责声明类与协议遵循:

// DeviceInfoModule.h
#import <React/RCTBridgeModule.h>

@interface DeviceInfoModule : NSObject <RCTBridgeModule>
@end

实现文件负责注册模块、导出方法并返回结果:

// DeviceInfoModule.m
#import "DeviceInfoModule.h"
#import <UIKit/UIKit.h>

@implementation DeviceInfoModule

RCT_EXPORT_MODULE(DeviceInfo);

// 导出同步方法:返回设备名称
RCT_EXPORT_METHOD(getDeviceName:(RCTPromiseResolveBlock)resolve
                  rejecter:(RCTPromiseRejectBlock)reject)
{
  @try {
    NSString *name = [[UIDevice currentDevice] name];
    resolve(name);
  } @catch (NSException *e) {
    reject(@"device_name_error", e.reason, nil);
  }
}

// 导出带参数方法:计算两数之和
RCT_EXPORT_METHOD(addNumber:(double)a
                  withB:(double)b
                  resolver:(RCTPromiseResolveBlock)resolve
                  rejecter:(RCTPromiseRejectBlock)reject)
{
  resolve(@(a + b));
}

@end

这里有两个关键点。第一,RCT_EXPORT_MODULE(DeviceInfo) 中的 DeviceInfo 就是 JS 侧访问的模块名,若省略参数则默认取类名去掉 Module 后缀。第二,RCT_EXPORT_METHOD 的方法名由选择器决定,冒号前的片段拼接即为 JS 方法名,例如 addNumber:withB:resolver:rejecter: 对应 JS 的 addNumber(a, b),其中 resolver 与 rejecter 是 RN 约定的回调参数,不参与 JS 传参。

如果使用 Swift,需要额外创建一个 Objective-C 桥接头文件,并在其中 #import <React/RCTBridgeModule.h>,Swift 类用 @objc(DeviceInfo) 标注并遵循 RCTBridgeModule,方法用 @objc 暴露。混编时最容易出错的是桥接头文件路径未在 Build Settings 中正确配置。

三、在 JS 侧调用并验证

原生模块注册完成后,无需手动 import,RN 会在启动时自动扫描并挂载。JS 侧通过 NativeModules 访问:

import { NativeModules } from 'react-native';

const { DeviceInfo } = NativeModules;

async function demo() {
  try {
    const name = await DeviceInfo.getDeviceName();
    console.log('设备名称:', name);

    const sum = await DeviceInfo.addNumber(3.5, 6.5);
    console.log('计算结果:', sum); // 10
  } catch (e) {
    console.warn('调用失败:', e.code, e.message);
  }
}

demo();

运行 npx react-native run-ios 后,在 Metro 日志或 Xcode 控制台中应能看到输出。判断链路是否真正打通,可以按以下顺序排查:

  • 模块是否为 undefined:说明模块未注册成功,检查类是否遵循协议、文件是否加入 Xcode 编译目标。
  • 方法是否为 undefined:说明 RCT_EXPORT_METHOD 未生效,检查宏是否写在 @implementation 内、方法签名是否正确。
  • Promise 一直 pending:说明原生方法没有调用 resolve 或 reject,检查分支是否遗漏回调。
  • 参数类型不匹配:RN 支持 NSNumber、NSString、NSArray、NSDictionary 等,double 需用 @(a+b) 包装为 NSNumber 返回。

四、一个可直接复用的最小验证清单

把上面的过程整理成可执行检查项,每次新增原生模块时逐条核对,能显著减少“写了但调不通”的时间:

检查项正确做法常见错误
模块注册RCT_EXPORT_MODULE(DeviceInfo)宏写在 @interface 外
协议遵循类声明 <RCTBridgeModule>忘记引入 React/RCTBridgeModule.h
方法导出宏位于 @implementation 内方法名与 JS 调用不一致
回调参数resolve/reject 成对出现只写 resolve,异常时无返回
编译目标文件加入 Build Phases新建文件未勾选 target
修改原生代码后必须重新编译 iOS 工程,仅刷新 Metro 不会生效。若使用 Swift 混编,每次新增 Swift 文件都要确认桥接头文件已更新,否则会出现“模块找不到”的报错。