随着后起之秀 Flutter 的火热,RN 渐渐失去光环。站在客户端原生跨平台的角度来看,Flutter 的确更胜一筹,但是如果要加上 Web,实现 iOS、Android、Web(小程序)三端统一,RN 方案依然是最佳方案。
即使有一天 RN 退出历史的舞台,它带来 JavaScript 与 Native 交互的思想依然会流传下去。小程序就是一个很好的代表作。
网上关于 RN 通信原理的文章,几乎都是站在客户端的角度来讲解,这篇文章想站在前端的角度聊一聊,JS 与 Native 是如何交互的。
如果你想阅读 RN 的源码,建议不要选择最新的版本,新版本对部分底层代码采用 C++ 进行了重写,阅读和调试的体验都不是很好。在通信方式上其实并没有发生什么变化。
我阅读的 RN 版本是 0.43.4,基于该版本写了一个可运行的 Demo,仅包含 JavaScript 和 Objective-C 通信的核心部分,代码量就几百行。
JS call Native
JS 端通信的核心部分,主要由 NativeModules.js
、MessageQueue.js
、BatchedBridge.js
构成。其中 BatchedBridge 是 MessageQueue 实例化的对象。
我把它们都放在Arch.js
中。BatchedBridge 对象提供了调用 Native 的方法:
var enqueueNativeCall = function(moduleID, methodID, params, onFail, onSuccess) {
if (onFail || onSuccess) {
// 如果存在 callback 回调,添加到 callbacks 字典中
// OC 根据 callbackID 来执行回调
if (onFail) {
params.push(this.callbackID);
this.callbacks[this.callbackID++] = onFail;
}
if (onSuccess) {
params.push(this.callbackID);
this.callbacks[this.callbackID++] = onSuccess;
}
}
// 将 Native 调用存入消息队列中
this.queue[MODULE_INDEX].push(moduleID);
this.queue[METHOD_INDEX].push(methodID);
this.queue[PARAMS].push(params);
// 每次都有 ID,没啥用
this.callID++;
const now = new Date().getTime();
// 检测原生端是否为 global 添加过 nativeFlushQueueImmediate 方法
// 如果有这个方法,并且 5ms 内队列还有未处理的调用,就主动调用 nativeFlushQueueImmediate 触发 Native 调用
if (global.nativeFlushQueueImmediate && now - this.lastFlush > MIN_TIME_BETWEEN_FLUSHES_MS) {
global.nativeFlushQueueImmediate(this.queue);
// 调用后清空队列
this.queue = [[], [], [], this.callID];
}
}
复制代码
该函数将调用 Native 实例模块 ID,方法ID,以及回调 ID 分别存入三个队列中。this.queue 存储了所有 JS 想要调用 Native 的信息。当 queue 存储的数据越多,也就代表 JS call Native 的延迟越大。
if (global.nativeFlushQueueImmediate && now - this.lastFlush > MIN_TIME_BETWEEN_FLUSHES_MS) {
global.nativeFlushQueueImmediate(this.queue);
// 调用后清空队列
this.queue = [[], [], [], this.callID];
}
复制代码
这段代码是 JS 主动调用 Native 的关键所在,它有一个条件当 now - this.lasFlush > 5ms
时才执行。也就是队列上一次被清空的时间如果已经超过 5ms 就执行nativeFlushQueueImmediate
函数。这是为什么?
在下一节 Native call JS 我们将会讲到每次 Native 调用 JS 时,会将 queue 作为返回值传给 Native 执行,假设没有 Native call JS,那么所有 JS call Native 都不会被执行,如果只是这么做的话,JS 端会非常的被动。
所以这里设定了一个 5 ms 的门限,如果在这段时间内,没有 Native 调用 JS,JS 就会主动触发 Native 调用。
global 中的 nativeFlushQueueImmediate
函数是在原生端注入的,执行时,会触发原生端 block 的调用拿到参数 queue,执行 native 调用。
self->_context[@"nativeFlushQueueImmediate"] = ^(NSArray<NSArray *> *calls) {
AHJSExecutor *strongSelf = weakSelf;
[strongSelf->_bridge handleBuffer:calls];
};
复制代码
现在的问题是,JS 如何知道 Native 有哪些方法可以调用的呢?是 Native 在开始执行 JS 代码前,提前注入到 JS 环境的,保存在 global 的__batchedBridgeConfig
属性中。
它包含所有支持 JS 调用的模块,以及方法名,同时会区分每个方法是同步、异步、还是 Promise,这写信息在 Native 初始化时会提供。
每个模块和方法,都会关联一个 ID,这个 ID 其实就是模块和方法在各自列表中所处的下标,JS 端和 Native 端都是如此。发起调用时,JS 端将 ID 存入消息队列即可,Native 拿到 ID 会将它们转换为原生的类(实例)和方法,并进行调用。
对象和方法约定好了,那如何约定参数呢?参数其实也好办,将参数值按顺序放入一个数组中。使用者需要注意参数的个数和顺序,保持与 Native 端的方法匹配,否者会报错。
最后是 JS callback 的处理,JS 和 Native 通信是无法传递事件的,所以选择将事件序列化,给每个事件一个 ID,并将 ID 传给 Native,当 Native 要执行这个回调时,通过invokeJSCallback
函数把这个 ID 回传给 JS,JS 根据 ID 查找对应的 callback 并执行。
Native call JS
Native call JS 依赖于 JavaScriptCore,该框架提供创建 JS 上下文环境,以及执行 JS 代码的接口,相对来说直接很多,不过因为 Native 端是多线程的环境,所以需要分情况来讨论,主要可以分为三种:
- 同步调用 JS;
- 异步调用 JS;
- 异步执行 JS 的 callback
同步调用的场景非常少,因为它仅限于在 JS 线程调用,而实际情况是,Native 和 JS 的通信几乎都是跨线程的。因为页面刷新和事件回调都发生在主线程。
对于 Native 端来说,JS 线程是普通的一个线程,跟其他线程没有区别,只不过是用这个线程来初始化 JS 的上下文环境,以及注入 JS 代码。
同步调用支持有返回值,而异步调用是 api 没有返回值的,只能使用 callback。
Native 异步调用 JS 主要由callFunctionReturnFlushedQueue
函数分发:
var callFunctionReturnFlushedQueue = function(module, method, args) {
this.__callFunction(module, method, args);
return this.flushedQueue();
}
复制代码
该函数定义在BatchedBridge
对象,并由 global 的__batchedBridge
属性所持有。
Native 在调用时把 moduleName 和各参数值都放在一个数组中,使用 JSValue 包装,再通过 JavaScriptCore 的JSObjectCallAsFunction
函数触发 JS 调用。
这里跟 JS call Native 不一样的是,不需要使用 ID,而是直接执行的。
在上述方法中 return 了this.flushedQueue()
,其实就是前面提到的清理 JS call Native 消息队列,并将队列中的信息返回给 Native 执行。
Native 调用 JS 如果有返回值,会有两种形式,一种是等待 JS 方法执行完成,拿到 return 的返回值;另一种是不等待 JS 方法执行完成,在 JS 线程拿到返回值后通过 callback 回调给调用方。
其实不管是异步还是同步 Native 都是通过下面的方法执行 JS 调用的:
- (void)_executeJSCall:(NSString *)method
arguments:(NSArray *)args
unwrapResult:(BOOL)unwrapResult
callback:(AHJSCallbackBlock)onComplete
复制代码
如果该方法是在 JS 线程调用的,那么会同步返回返回值;如果是其他线程调用该方法,返回值是通过异步 callback 返回的。
最后是异步调用 JS callback 的情况,其实跟异步调用类似,只是在 JS 端定了一个新的函数:
var invokeCallbackAndReturnFlushedQueue = function(callbackId, args) {
this.__invokeCallback(callbackId, args);
return this.flushedQueue();
}
复制代码
接受一个 callbackId,找到对应的 callback 并执行。
那么,Native 是怎么知道 JS 有哪些模块可以调用的呢?其实这只要 RN 框架内部知道就好,对于使用 RN 开发,不需要关心,只要知道 Native 有哪些功能提供给 JS 调用就好。
思考题
- 为什么 JS 和 Native 的通信只能是异步?
本质原因是 JS 是单线程执行的。而 Native 端负责 UI 展示的又只能是主线程,如果想要实现同步通信,只能加锁等待,就会浪费 CPU 资源。
- 为什么 JS call Native 要设计一个消息队列,等待 Native 调用时才执行,而不像 Native call JS 每次调用都直接去执行呢?
我认为这仍旧跟 JS 的单线程执行环境有关。Native 和 JS 在一条道上如果能自由的通信,势必可能出现同时触发调用,就会发生消息碰撞。所以必须对某一方发起调用进行管控。采用的方式就是在 JS 端加一层消息队列,等待 Native 的调用,如果 Native 5ms 内都没有调用,说明处于空闲状态,就主动触发调用。
- 为什么 JS call Native 要使用 ID,而不是直接传递字符串?
我想这主要是出于性能的考虑,传递数字会比字符串数据量小很多,效率更高。
感谢阅读。如果你对实现细节感兴趣,可以看一看我写的 Demo。