编写 React Native 原生模块的若干提示 -- iOS

假设你已经阅读了官方文档 iOS Native Modulesopen in new window,并且已经学会如何编写一个简单的 iOS 原生模块。

使用脚手架创建组件库

如果需要把原生模块封装成库,方便独立升级、维护、发布和共享, 可以使用 react-native-create-libopen in new window 来创建组件库。

例如:

npx react-native-create-lib  \
  --module-name @sdcx/sample-module \
  --repo-name react-native-sample-module \
  SampleModule

原生模块必须具有类型定义

没有类型的代码是难以维护的。我们要求为原生模块声明完整的类型。

这些类型定义同时作为接口和协议存在,方便多端同事协作,不管是编写原生模块,还是使用原生模块。

import { NativeModules, NativeModule } from 'react-native'

interface SampleInterface extends NativeModule {
  sampleMethod(): void
}

const SampleModule: SampleInterface = NativeModules.SampleModule

以模块名的方式导出原生模块

我们不能把原生模块直接暴露给客户代码,而是要先包裹一下,然后以模块名的方式导出。

可以有不同的导出方式,但不管采用哪种导出方式,务必确保客户代码以模块名的方式导入

import Sample from '@sdcx/sample-module'
// Sample 是模块名
Sample.sampleMethod()

方式一:直接导出

如果模块比较简单,JavaScript 不需要做任何处理,那么可以直接导出。

interface SampleInterface extends NativeModule {
  sampleMethod(): void
}

const SampleModule: SampleInterface = NativeModules.SampleModule

export default SampleModule

如果模块比较复杂,JavaScript 需要做一些额外的处理,则使用余下几种方式导出。

方式二:默认模块

type Callback = (error: Error | null, result: string) => void

interface SampleInterface extends NativeModule {
  sampleMethod(arg: number, callback: Callback): void
}

const SampleModule: SampleInterface = NativeModules.SampleModule

function sampleMethod(arg: number): Promise<string> {
  return new Promise(function (resolve, reject) {
    SampleModule.sampleMethod(arg, (error, result) => {
      if (error) {
        reject(error)
      } else {
        resolve(result)
      }
    })
  })
}

export default {
  sampleMethod,
}

方式三:对象

type Callback = (error: Error | null, result: string) => void

interface SampleInterface extends NativeModule {
  sampleMethod(arg: number, callback: Callback): void
}

const SampleModule: SampleInterface = NativeModules.SampleModule

const Sample = {
  sampleMethod(arg: number): Promise<string> {
    return new Promise(function (resolve, reject) {
      SampleModule.sampleMethod(arg, (error, result) => {
        if (error) {
          reject(error)
        } else {
          resolve(result)
        }
      })
    })
  },
}

export default Sample

也可以创建一个类,然后返回该类的唯一实例。

class Sample {}

export default new Sample()

ObjcModule 是个单例 (Singleton)

这里的 ObjcModule 是指实现了 RCTBridgeModule 协议的类,譬如:

#import <React/RCTBridgeModule.h>

@interface SampleModule : NSObject <RCTBridgeModule>

@property (nonatomic, strong) SampleHelper *sampleHelper;

同一时刻,整个进程只有一个 SampleModule 实例,这是由 React Native 保证的。

既然 SampleModule 是个单例,那么它的实例成员 SampleHelper 也是个单例。

认识到这一点很重要,我们不需要把 SampleHelper 本身变成单例。

ObjcModule 是个门面(Facade)

ObjcModule 是个门面,有两层意思。

其一,ObjcModule 是连接 ObjC 和 JavaScript 两个世界的桥梁,只起连接作用,真正干活的另有其人。

譬如,SampleModule 只负责线程切换,并将真正要做的事情委派给了 SampleHelper

// SampleModule.m
RCT_EXPORT_METHOD(sampleMethod:(NSNumber *)param
                      callback:(RCTResponseSenderBlock)callback) {
    dispatch_async(dispatch_get_main_queue(), ^{
        [self.sampleHelper sampleMethod:param callback:callback];
    });
}

如果模块本身比较简单,也可以直接由 ObjcModule 本身来实现所有功能。

其二,在 ObjC,如果需要在 ObjcModule 之外进行额外的配置,那么应将该职责分配给 ObjcModule,然后由 ObjcModule 进行委托。

譬如,假设为了让 SampleModule 可以正常工作,我们需要挂载一个钩子到 ApplicationDelegateopenURL 方法上。

作为一个封装良好的原生模块,我们首先在 SampleModule 暴露相关的静态公开方法,并在该方法内部,获取 SampleModule 的唯一实例,将实际要做的事情委托给 SampleHelper

@interface SampleModule : NSObject <RCTBridgeModule>

// 静态公开方法
+ (BOOL)handleOpenURL:(NSURL *)url withBridge:(RCTBridge *)bridge {
    // 获取 `SampleModule` 的唯一实例
    SampleModule *sampleModule = [bridge moduleForClass:self];
    return [sampleModule handleOpenURL:url];
}

// 实例私有方法
- (BOOL)handleOpenURL:(NSURL *)url {
    // 并将实际要做的事情委托给 `SampleHelper`
    return [self.sampleHelper handleOpenURL:url];
}

然后在 AppDelegate 中通过如下方式注册 SampleModulehandleOpenURL 方法:

@interface AppDelegate : UIResponder <UIApplicationDelegate>

- (BOOL)application:(UIApplication *)app
            openURL:(NSURL *)url
            options:(NSDictionary<UIApplicationOpenURLOptionsKey,id> *)options {
    return [SampleModule handleOpenURL:url withBridge:self.bridge];
}

合成 bridge 属性

某些时候,可能需要在 ObjcModule 内部获取 bridge

@implementation SampleModule

RCT_EXPORT_METHOD(sampleMethod){
    RCTLogInfo(@"%@", self.bridge);
}

bridge 属性是在 RCTBridgeModule 协议中声明的。

@protocol RCTBridgeModule <NSObject>

@property (nonatomic, weak, readonly) RCTBridge *bridge;

React Native 会在运行时通过 setter 方法来设置该属性,如果 bridge 有对应的 setter 的话。

我们可以通过以下方式之一来为 bridge 生成对应的 setter 和 getter 方法:

其一,在模块内部添加 @synthesize bridge = _bridge;

@implementation SampleModule

@synthesize bridge = _bridge;

其二,重新声明 bridge 属性

@interface SampleModule : NSObject <RCTBridgeModule>

@property (nonatomic, weak) RCTBridge *bridge;

如果你的模块继承了 RCTEventEmitter,那么你可以直接使用 self.bridge,因为 RCTEventEmitter 已经重新声明了 bridge 属性。

#import <React/RCTBridge.h>
@interface RCTEventEmitter : NSObject <RCTBridgeModule, RCTInvalidating>

@property (nonatomic, weak) RCTBridge *bridge;

当 ObjcModule 销毁时,释放持有的资源

ObjcModule 何时会被销毁?当主动或被动调用 bridgereload 方法时。

譬如使用 CodePush 实现热更新时,会主动调用 reload

譬如在开发模式下,按下 r 键时,会被动调用 reload

bridge 重载时,会销毁所有的 ObjcModule,然后重新创建它们。

ObjcModule 在销毁时,需要释放它持有的资源,否则会导致内存泄漏。

我们有两种方式可以实现这一点:

其一,ObjcModule 实现 RCTInvalidating 协议,并在 invalidate 中释放所持有的资源。

@interface SampleModule() <RCTInvalidating>

- (void)invalidate {
    // 在这里释放资源
}

其二,ObjcModule 监听 RCTBridgeWillReloadNotification 事件,在注册的 selector 中释放所持有的资源。

- (instancetype)init {
    if (self = [super init]) {
        [[NSNotificationCenter defaultCenter]
                                    addObserver:self
                                       selector:@selector(handleReload)
                                           name:RCTBridgeWillReloadNotification
                                         object:nil];
    }
    return self;
}

- (void)dealloc {
    [[NSNotificationCenter defaultCenter]
                                removeObserver:self
                                          name:RCTBridgeWillReloadNotification
                                        object:nil];
}

- (void)handleReload {
    // 在这里释放持有的资源
}

参数类型转换

一个 plain object 参数从 JavaScript 传递到 ObjC,就是一个 NSDictionary。把这种类型的参数转换成模型,是个较好的模式。

不要使用 RCTConvert 进行参数类型转换。首先它不够直观,难以维护,其次 React Native 在未来会弃用这种方式。

我们通过手动的方式来进行转换,直观且易于维护。

RCT_EXPORT_METHOD(sampleMethod:(NSDictionary *)params {
    SampleOptions *options = [SampleOptions optionsWithDictionary:params];
}
@interface SampleOptions

@property(nonatomic, copy) NSString *key;

+ (instancetype)optionsWithDictionary:(NSDictionary *)params {
    SampleOptions *options = [[SampleOptions alloc] init];
    options.key = params[@"key"];
    return options;
}

数字类型的参数,请仅使用 NSNumber,不要使用 NSInteger CGFloat int float,React Native 在未来不会再支持这些参数。

在 ObjC 使用 Callback 而不是 Promise

Callback 和 Promise 都可以用于将数据异步地从 ObjC 传递到 JavaScript。

在 ObjC,Callback 可以是一个 RCTResponseSenderBlock,同时用于处理成功和失败的情况。也可以是两个 RCTResponseSenderBlock,分别用于处理成功和失败的情况。

Callback 最多只能调用一次。如果你使用两个 RCTResponseSenderBlock,那么你只能调用其中一个。

也可以使用 Promise 来代替 Callback。在 ObjC,Promise 由 RCTPromiseResolveBlockRCTPromiseRejectBlock 成对组成。

和 Callback 类似,你只能调用 RCTPromiseResolveBlockRCTPromiseRejectBlock 两者之一,并且最多只能调用一次。

这意味着你可以调用一个成功回调或失败回调,但不能同时调用,而且每个回调最多只能调用一次。然而,原生模块可以存储回调并在以后调用它。

值得注意的是,回调(Callback 或 Promise)不可以不调用,否则将产生内存泄漏

为了简化操作,避免困惑,我们的建议是,使用且仅使用一个 RCTResponseSenderBlock 作为从 ObjC 到 JavaScript 的回调方式,并遵循 Node 的约定,将传递给 callback 的数组的第一个参数视为错误对象。

RCT_EXPORT_METHOD(sampleMethod:(RCTResponseSenderBlock)callback) {
    callback(@[[NSNull null], @"result"]);
}

在 JavaScript 使用 Promise 而不是 Callback

ObjC 为了自己方便,使用了 Callback 作为回调。但对于 JavaScript 来说,Promise 是一个更好的选择。

因此,我们需要在 JavaScript 这边做一些转换,以便能够使用 Promise

import { NativeModules, NativeModule } from 'react-native'

type Callback = (error: Error | null, result: string) => void

interface SampleInterface extends NativeModule {
  sampleMethod(callback: Callback)
}

const SampleModule: SampleInterface = NativeModules.SampleModule

function sampleMethod() {
  return new Promise((resolve, reject) => {
    SampleModule.sampleMethod((error, result) => {
      if (error) {
        reject(error)
      } else {
        resolve(result)
      }
    })
  })
}

export default {
  sampleMethod,
}

返回错误时,总是包含 code 和 message 字段

我们建议在回调返回的错误对象中,总是包含 codemessage 字段。

记住,code 是个字符串

我们遵循这么一个原则:code 用来进一步分析问题,message 则可以直接提示给用户。

if (resp.errCode == ErrCodeSuccess) {
    callback(@[[NSNull null], resp.returnKey]);
} else {
    callback(@[
        @{ @"code"   : [self stringFromErrorCode:resp.errCode],
           @"message": resp.errStr },
        @""
    ]);
}
- (NSString *)stringFromErrorCode:(int) code{
    NSDictionary *dict = @{
        @(ErrCodeUserCancel): @"ErrCodeUserCancel",
        @(ErrCodeSentFail)  : @"ErrCodeSentFail",
        @(ErrCodeAuthDeny)  : @"ErrCodeAuthDeny",
        @(ErrCodeUnsupport) : @"ErrCodeUnsupport",
    };
    return dict[@(code)];
}

在 JavaScript,可以通过以下方式获取 codemessage

import Sample from '@sdcx/sample-module'

try {
  await Sample.sampleMethod()
} catch (error) {
  console.log(error.code, error.message)
}

使用 RCTLog 输出日志

#import <React/RCTLog.h>

RCT_EXPORT_METHOD(sampleMethod) {
    RCTLogInfo(@"example log info");
}

为什么我们要使用 RCTLog API 而不是 NSLog 来输出日志?

一个最为重要的原因是,RCTLog 输出的日志和 JavaScript 通过 console 输出的日志,都会被 RCTLogFunction 函数处理。

我们可以通过 RCTSetLogFunction 来定制 RCTLogFunction 函数,这使得我们可以将 React Native 的日志输出到我们的远程日志系统中,以便我们远程查看日志,分析问题。

此外 RCTLog API 还有以下特点:

  • RCTLog 输出的日志,会出现在 vs code 或 chrome 的控制台中。

  • 可以通过 RCTSetLogThreshold(RCTLogLevelInfo) 来控制输出的日志级别。

  • RCTLogWarningRCTLogError 会触发黄色警告提示和红色错误提示。

正确处理原生事件

和 Callback 或 Promise 只能调用一次不同,事件可以源源不断地从 ObjC 传递给 JavaScript。

在使用事件时,需要注意以下一些细节:

ObjCModule 需要继承 RCTEventEmitter,同时实现 RCTBridgeModule 协议。

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

@interface SampleModule : RCTEventEmitter <RCTBridgeModule>

实现 supportedEvents 方法,返回一个字符串数组,表示支持的事件名称。

// 注意事件常量值不要和其它组件库发生冲突
static NSString *const EVENT_NAME = @"sample_event_name";

- (NSArray<NSString *> *)supportedEvents {
    return @[ EVENT_NAME ];
}

最好实现 constantsToExport,把相关事件名作为常量导出。

+ (BOOL)requiresMainQueueSetup {
    return NO;
}

- (NSDictionary<NSString *, NSString *> *)constantsToExport {
    return @{
        @"EVENT_NAME": EVENT_NAME,
    };
}

以及需要实现 startObservingstopObserving 两个方法,根据是否有订阅者来决定是否发送事件,否则当 JavaScript 没有订阅该事件时,会发生警告。

@implementation SampleModule {
  BOOL _hasListeners;
}

// Will be called when this module's first listener is added.
-(void)startObserving {
    _hasListeners = YES;
}

// Will be called when this module's last listener is removed, or on dealloc.
-(void)stopObserving {
    _hasListeners = NO;
}

发送事件前,需要检测是否有订阅者,以及 bridge 是否有效。

- (void)recieveSampleEvent {
    if (_hasListeners && self.bridge.isValid) {
        [self sendEventWithName:EVENT_NAME body:@{}];
    }
}

在 JavaScript,使用 NativeEventEmitter 包裹原生模块,得到一个 NativeEventEmitter 实例

import { NativeModules, NativeModule, NativeEventEmitter } from 'react-native'

interface Constants {
  EVENT_NAME: string
}

interface SampleInterface extends NativeModule {
  getConstants(): Constants
}

const SampleModule: SampleInterface = NativeModules.SampleModule
const SampleEventEmitter: NativeEventEmitter = new NativeEventEmitter(
  SampleModule
)

export { SampleEventEmitter }
export default SampleModule

像如下那样监听事件,留意事件名,我们使用了从 ObjC 导出的常量。

import Sample, { SampleEventEmitter } from '@sdcx/sample-module'

const { EVENT_NAME } = Sample.getConstants()

useEffect(() => {
  const subscription = SampleEventEmitter.addListener(EVENT_NAME, () => {
    // do something
  })
  return () => {
    subscription.remove()
  }
}, [])

正确处理线程

如果重写了 constantsToExport 方法,则需要实现 + requiresMainQueueSetup 方法,告知 React Native 是否在主线程初始化你的模块。

如果你的模块不需要访问 UIKit,那么返回 NO 就可以了。

+ (BOOL)requiresMainQueueSetup {
    return NO;
}

所有原生模块的方法,默认都会在同一个串行队列中执行,因此请不要阻塞这个队列。

我们可以重写 methodQueue 方法,指定一个队列,该模块的所有方法将在该队列执行。

如果模块需要访问 UIKit,那么返回 dispatch_get_main_queue()

- (dispatch_queue_t)methodQueue {
    return dispatch_get_main_queue();
}

如果模块需要执行耗时任务,并且需要串行执行,那么返回一命名的串行队列。

- (dispatch_queue_t)methodQueue {
    return dispatch_queue_create("com.example.ModuleQueue", DISPATCH_QUEUE_SERIAL);
}

指定的 methodQueue 将由模块中的所有方法共享。如果只有若干方法是长时间运行的,或者由于某种原因需要在与其他方法在不同的队列上运行,可以在这些方法内部使用 dispatch_async,而不会影响其他方法。

RCT_EXPORT_METHOD(doSomethingExpensive:(NSString *)param
                              callback:(RCTResponseSenderBlock)callback) {
    dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
        // 在后台执行耗时任务
        ...
        // 可以在任意队列调用 callback
        callback(@[...]);
    });
}

如果模块是对第三方 SDK 的封装,则需要留意第三方 SDK 对线程安全的要求,模块可能需要确保 SDK 的初始化、请求、回调均在同一队列执行。这些 SDK 或者会自己管理其耗时任务的执行队列,模块不再需要为它们开辟新的队列。

将权限请求委托给第三方库

某些涉及隐私的操作,需要用户授予权限才能进行。譬如获取用户位置,访问相册等等。

我们遵分离关注点的原则,ObjC 不负责请求权限,仅在执行操作前,检查是否拥有执行操作所需权限,如果没有,则通过 Error 机制把异常信息返回给 JavaScript,由 JavaScript 进行处理。

RCT_EXPORT_METHOD(sampleMethod:(NSDictionary *)params
                      callback:(RCTResponseSenderBlock)callback) {
    [self checkPermissions:^(BOOL granted) {
        if (!granted) {
            self.callback(@[self.permissionError]);
            return;
        }
        SampleOptions *options = [SampleOptions optionsWithDictionary:params];
        [self doSomethingWithOptions:options callback:callback];
    }];
}

在 JavaScript,我们使用 react-native-permissionsopen in new window 处理权限的检查和请求。

通常会使用高阶函数,把需要权限的请求包裹一下。

export function withXxxPermission<T>(fn: () => Promise<T>) {
  return async () => {
    const granted = await Permissions.check(Permissions.XXX)
    if (!granted) {
      await Permissions.request(Permissions.XXX)
    }
    return fn()
  }
}

将服务鉴权委托给主工程

有时,我们封装的原生组件库,可能需要访问我们自己的服务,而这些服务需要鉴权。

我们的组件库是集成到我们的主工程中使用的,而我们的主工程已经拥有了一套鉴权机制来获取和刷新 token。

我们的组件库应该复用主工程这套鉴权机制。

可我们的组件库又不能依赖我们的主工程,该怎么办呢?

答案是控制反转。我们在组件库声明一套鉴权接口,由主工程注入实现。

// 组件库
import { NativeModules, NativeModule } from 'react-native'

interface SampleInterface extends NativeModule {
  sampleMethod(params: any, token: string): void
}

const SampleModule: SampleInterface = NativeModules.SampleModule

interface TokenProvider {
  refreshToken: () => Promise<string>
  accessToken: () => Promise<string>
}

class Sample {
  private tokenProvider: TokenProvider

  setTokenProvider(tokenProvider: TokenProvider) {
    this.tokenProvider = tokenProvider
  }

  async sampleMethod(params: any) {
    const token = await this.tokenProvider.accessToken()
    SampleModule.sampleMethod(params, token)
  }
}

export default new Sample()
// 主工程
import Sample from '@sdcx/sample-module'

Sample.setTokenProvider({
  refreshToken: AuthService.refreshToken,
  accessToken: AuthService.accessToken,
})

不仅仅是服务鉴权,任何组件库不知道怎么办,或不是它的职责的事情,都可以通过控制反转委托给主工程来处理。

合理使用请求-响应模式

如果 ObjCModule 的不同方法需要执行不同的耗时任务,那么我们可以使用请求-响应模式。

我们可以将每一个功能都委托给一个具体的请求对象来实现。

RCT_EXPORT_METHOD(sampleMethod:(NSString *)param
                      callback:(RCTResponseSenderBlock)callback) {
    SampleRequest *request = [[SampleRequest alloc] initWithCallback:callback];
    [request doSomethingExpensive:param];
}
static NSString * const ErrorCode = @"SampleModuleSampleRequestError";

@implementation SampleRequest

@property(nonatomic, copy) RCTResponseSenderBlock callback;
@property(nonatomic, strong) SDK *sdk;

- (instancetype)initWithCallback:(RCTResponseSenderBlock)callback {
    if (self = [super init]) {
        _callback = callback;
        _sdk = [[SDK alloc] init];
        _sdk.delegate = self;
    }
    return self;
}

- (void)doSomethingExpensive:(NSString *)param {
    // sdk 内部开启异步队列来执行耗时任务
    self.sdk.doSomethingWithParam(param);
}

- sdk:(SDK *)sdk didSuccessWithResult:(NSString *)result {
    self.callback(@[[NSNull null], result]);
}

- sdk:(SDK *)sdk didFailWithError:(NSError *)error {
    self.callback(@[@{@"code": ErrorCode, @"message": error.localizedDescription}]);
}

使用 Objective-C 而不是 Swift

出于以下考虑,我们建议使用 Objective-C 而不是 Swift 来开发组件库。

  • 和 React Native 保持一致,这是最重要的理由。

  • ObjC 有强大的 runtime,更适合用来编写组件库。

  • 没必要让不熟悉 Swift 的同事增加学习成本。

  • 在一个 React Native 应用中,我们使用 ts/js 来实现业务和 UI,而不是 Swift。

上次更新: