使用 JS 构建跨平台的原生应用:React Native iOS 通信机制初探
作者: 发布于:

在初识 React Native 时,非常令人困惑的一个地方就是 JS 和 Native 两个端之间是如何相互通信的。本篇文章对 iOS 端 React Native 启动时的调用流程做下简要总结,以此窥探其背后的通信机制。

JS 启动过程

React Native 的 iOS 端代码是直接从 Xcode IDE 里启动的。在启动时,首先要对代码进行编译,不出意外,在编译后会弹出一个命令行窗口,这个窗口就是通过 Node.js 启动的 development server

问题是这个命令行是怎么启动起来的呢?实际上,Xcode 在 Build Phase 的最后一个阶段对此做了配置:

因此,代码编译后,就会执行 packager/react-native-xcode.sh 这个脚本。
查看这个脚本中的内容,发现它主要是读取 XCode 带过来的环境变量,同时加载 nvm 包使得 Node.js 环境可用,最后执行 react-native-cli 的命令:

react-native bundle \
  --entry-file index.ios.js \
  --platform ios \
  --dev $DEV \
  --bundle-output "$DEST/main.jsbundle" \
  --assets-dest "$DEST"

react-native 命令是全局安装的,在我本机上它的地址是 /usr/local/bin/react-native。查看该文件,它调用了  react-native 包里的local-cli/cli.js 中的 run 方法,最终进入了 private-cli/src/bundle/buildBundle.js。它的调用过程为:

  1. ReactPackager.createClientFor
  1. client.buildBundle
  1. processBundle
  1. saveBundleAndMap

上面四步完成的是 buildBundle 的功能,细节很多很复杂。总体来说,buildBundle 的功能类似于 browerify 或 webpack :

  1. 从入口文件开始分析模块之间的依赖关系;
  1. 对 JS 文件转化,比如 JSX 语法的转化等;
  1. 把转化后的各个模块一起合并为一个 bundle.js

之所以 React Native 单独去实现这个打包的过程,而不是直接使用 webpack ,是因为它对模块的分析和编译做了不少优化,大大提升了打包的速度,这样能够保证在 liveReload 时用户及时得到响应。

Tips: 通过访问 http://localhost:8081/debug/bundles 可以看到内存中缓存的所有编译后的文件名及文件内容,如:

Native 启动过程

Native 端就是一个 iOS 程序,程序入口是 main 函数,像通常一样,它负责对应用程序做初始化。

除了 main 函数之外,AppDelegate 也是一个比较重要的类,它主要用于做一些全局的控制。在应用程序启动之后,其中的 didFinishLaunchingWithOptions 方法会被调用,在这个方法中,主要做了几件事:

  • 定义了 JS 代码所在的位置,它在 dev 环境下是一个 URL,通过 development server 访问;在生产环境下则从磁盘读取,当然前提是已经手动生成过了 bundle 文件;
  • 创建了一个 RCTRootView 对象,该类继承于 UIView,处于程序所有 View 的最外层;
  • 调用 RCTRootView 的 initWithBundleURL 方法。在该方法中,创建了 bridge 对象。顾名思义,bridge 起着两个端之间的桥接作用,其中真正工作的是类就是大名鼎鼎的 RCTBatchedBridge

RCTBatchedBridge 是初始化时通信的核心,我们重点关注的是 start 方法。在 start 方法中,会创建一个 GCD 线程,该线程通过串行队列调度了以下几个关键的任务。

loadSource

该任务负责加载 JS 代码到内存中。和前面一致,如果 JS 地址是 URL 的形式,就通过网络去读取,如果是文件的形式,则通过读本地磁盘文件的方式读取。

initModules

该任务会扫描所有的 Native 模块,提取出要暴露给 JS 的那些模块,然后保存到一个字典对象中。
一个 Native 模块如果想要暴露给 JS,需要在声明时显示地调用 RCT_EXPORT_MODULE。它的定义如下:

#define RCT_EXPORT_MODULE(js_name) \
RCT_EXTERN void RCTRegisterModule(Class); \
+ (NSString *)moduleName { return @#js_name; } \
+ (void)load { RCTRegisterModule(self); }

可以看到,这就是一个宏,定义了 load 方法,该方法会自动被调用,在方法中对当前类进行注册。
模块如果要暴露出指定的方法,需要通过 RCT_EXPORT_METHOD 宏进行声明,原理类似。

setupExecutor

这里设置的是 JS 引擎,同样分为调试环境和生产环境:
在调试环境下,对应的 Executor 为 RCTWebSocketExecutor,它通过 WebSocket 连接到 Chrome 中,在 Chrome 里运行 JS;
在生产环境下,对应的 Executor 为 RCTContextExecutor,这应该就是传说中的 javascriptcore

moduleConfig

根据保存的模块信息,组装成一个 JSON ,对应的字段为 remoteModuleConfig。

injectJSONConfiguration

该任务将上一个任务组装的 JSON 注入到 Executor 中。
下面是一个 JSON 示例,由于实际的对象太大,这里只截取了前面的部分:

JSON 里面就是所有暴露出来的模块信息。

executeSourceCode

该任务中会执行加载过来的 JS 代码,执行时传入之前注入的 JSON。
在调试模式下,会通过 WebSocket 给 Chrome 发送一条 message,内容大致为:

{
    id = 10305;
    inject = {remoteJSONConfig...};
    method = executeApplicationScript;
    url = "http://localhost:8081/index.ios.bundle?platform=ios&dev=true";
}

JS 接收消息后,执行打包后的代码。如果是非调试模式,则直接通过 javascriptcore 的虚拟环境去执行相关代码,效果类似。

JS 调用 Native

前面我们看到, Native 调用 JS 是通过发送消息到 Chrome 触发执行、或者直接通过 javascriptcore 执行 JS 代码的。而对于 JS 调用 Native 的情况,又是什么样的呢?

在 JS 端调用 Native 一般都是直接通过引用模块名,然后就使用了,比如:

var RCTAlertManager = require('NativeModules').AlertManager

可见,NativeModules 是所有本地模块的操作接口,找到它的定义为:

var NativeModules = require('BatchedBridge').RemoteModules;

而BatchedBridge中是一个MessageQueue的对象:

let BatchedBridge = new MessageQueue(
  __fbBatchedBridgeConfig.remoteModuleConfig,
  __fbBatchedBridgeConfig.localModulesConfig,
);

在 MessageQueue 实例中,都有一个 RemoteModules 字段。在 MessageQueue 的构造函数中可以看出,RemoteModules 就是 __fbBatchedBridgeConfig.remoteModuleConfig 稍微加工后的结果。

class MessageQueue {

  constructor(remoteModules, localModules, customRequire) {
    this.RemoteModules = {};
    this._genModules(remoteModules);
    ...
    }
}

所以问题就变为:  __fbBatchedBridgeConfig.remoteModuleConfig 是在哪里赋值的?

实际上,这个值就是 从 Native 端传过来的JSON 。如前所述,Executor 会把模块配置组装的 JSON 保存到内部:

[_javaScriptExecutor injectJSONText:configJSON
                  asGlobalObjectNamed:@"__fbBatchedBridgeConfig"
                             callback:onComplete];

configJSON 实际保存的字段为:_injectedObjects['__fbBatchedBridgeConfig']

在 Native 第一次调用 JS 时,_injectedObjects 会作为传递消息的 inject 字段。
JS 端收到这个消息,经过下面这个重要的处理过程:

'executeApplicationScript': function(message, sendReply) {
    for (var key in message.inject) {
      self[key] = JSON.parse(message.inject[key]);
    }
    importScripts(message.url);
    sendReply();
  },

看到没,这里读取了 inject 字段并进行了赋值。self 是一个全局的命名空间,在浏览器里 self===window

因此,上面代码执行过后,window.__fbBatchedBridgeConfig 就被赋值为了传过来的 JSON 反序列化后的值。

总之:
NativeModules = __fbBatchedBridgeConfig.remoteModuleConfig = JSON.parse(message.inject['__fbBatchedBridgeConfig']) = 模块暴露出的所有信息

好,有了上述的前提之后,接下来以一个实际调用例子说明下 JS 调用 Native 的过程。
首先我们通过 JS 调用一个 Native 的方法:

RCTUIManager.measureLayoutRelativeToParent(
   React.findNodeHandle(scrollComponent),
   logError,
   this._setScrollVisibleLength
);

所有 Native 方法调用时都会先进入到下面的方法中:

fn = function(...args) {
  let lastArg = args.length > 0 ? args[args.length - 1] : null;
  let secondLastArg = args.length > 1 ? args[args.length - 2] : null;
  let hasSuccCB = typeof lastArg === 'function';
  let hasErrorCB = typeof secondLastArg === 'function';
  let numCBs = hasSuccCB + hasErrorCB;
  let onSucc = hasSuccCB ? lastArg : null;
  let onFail = hasErrorCB ? secondLastArg : null;
  args = args.slice(0, args.length - numCBs);
  return self.__nativeCall(module, method, args, onFail, onSucc);
};

也就是倒数后两个参数是错误和正确的回调,剩下的是方法调用本身的参数。
在 __nativeCall 方法中,会将两个回调压到 callback 数组中,同时把  (模块、方法、参数) 也单独保存到内部的队列数组中:

    onFail && params.push(this._callbackID);
    this._callbacks[this._callbackID++] = onFail;
    onSucc && params.push(this._callbackID);
    this._callbacks[this._callbackID++] = onSucc;
    this._queue[0].push(module);
    this._queue[1].push(method);
    this._queue[2].push(params);

到这一步,JS 端告一段落。接下来是 Native 端,在调用 JS 时,经过如下的流程:

总之,就是在调用 JS 时,顺便把之前保存的 queue 作为返回值 一并返回,然后会对该返回值进行解析。
在 _handleRequestNumber 方法中,终于完成了 Native 方法的调用:

- (BOOL)_handleRequestNumber:(NSUInteger)i
                    moduleID:(NSUInteger)moduleID
                    methodID:(NSUInteger)methodID
                      params:(NSArray *)params
{
  // 解析模块和方法
  RCTModuleData *moduleData = _moduleDataByID[moduleID];
  id<RCTBridgeMethod> method = moduleData.methods[methodID];
  @try {
    // 完成调用
    [method invokeWithBridge:self module:moduleData.instance arguments:params];
  }
  @catch (NSException *exception) {
  }

  NSMutableDictionary *args = [method.profileArgs mutableCopy];
  [args setValue:method.JSMethodName forKey:@"method"];
  [args setValue:RCTJSONStringify(RCTNullIfNil(params), NULL) forKey:@"args"];
}

与此同时,执行后还会通过 invokeCallbackAndReturnFlushedQueue 触发 JS 端的回调。具体细节在 RCTModuleMethod 的 processMethodSignature 方法中。

再小结一下,JS 调用 Native 的过程为 :

  • JS 把(调用模块、调用方法、调用参数) 保存到队列中;
  • Native 调用 JS 时,顺便把队列返回过来;
  • Native 处理队列中的参数,同样解析出(模块、方法、参数),并通过 NSInvocation 动态调用;
  • Native方法调用完毕后,再次主动调用 JS。JS 端通过 callbackID,找到对应JS端的 callback,进行一次调用

整个过程大概就是这样,剩下的一个问题就是,为什么要等待 Native 调用 JS 时才会触发,中间会不会有很长延时?
事实上,只要有事件触发,Native 就会调用 JS。比如,用户只要对屏幕进行触摸,就会触发在 RCTRootView 中注册的 Handler,并发送给JS:

  [_bridge enqueueJSCall:@"RCTEventEmitter.receiveTouches"
                    args:@[eventName, reactTouches, changedIndexes]];

除了触摸事件,还有 Timer 事件,系统事件等,只要事件触发了,JS 调用时就会把队列返回。这块理解可以参看 React Native通信机制详解 一文中的“事件响应”一节。

总结

俗话说一图胜千言,整个启动过程用一张图概括起来就是:

本文简要介绍了 iOS 端启动时 JS 和 Native 的交互过程,可以看出 BatchedBridge 在两端通信过程中扮演了重要的角色。Native 调用 JS 是通过 WebSocket 或直接在 javascriptcore 引擎上执行;JS 调用 Native 则只把调用的模块、方法和参数先缓存起来,等到事件触发后通过返回值传到 Native 端,另外两端都保存了所有暴露的 Native 模块信息表作为通信的基础。由于对 iOS 端开发并不熟悉,文中如有错误的地方还请指出。

参考资料: