【译】带你进入React Native 内部通信

原文地址 : https://tadeuzagallo.com/blog/react-native-bridge/

@ Tadeu Zagallo

这篇文章主要希望大家能够理解 React Native的一些基本原理,我们会聚焦在 React Native内部 native 与 JS之间的通信问题。

主线程

再说其他事情前,我们需要知道在 React Native中有这样三个 主线程:

shadow queue: 用于界面 的布局

main thread: UIKit 做自己的事情

JavaScript thread: 运行JS的地方

每个 Native 模块 都有一个 GCD Queue, 除非它特意指定。

实际上 shadow queue 也是一个 GCD Queue 而不是一个线程

Native 模块

如果你不知道如何创建 Native Module,可以移到这里阅读详情。

@interface Person : NSObject <RCTBridgeModule>
@end

@implementation Logger

RCT_EXPORT_MODULE()

RCT_EXPORT_METHOD(greet:(NSString *)name)
{
  NSLog(@"Hi, %@!", name);
  [_bridge.eventDispatcher sendAppEventWithName:@"greeted"
                                           body:@{ @"name": name }];
}

@end

我们主要关注两个宏,RCT_EXPORT_MODULERCT_EXPORT_METHOD,去了解它们是去扩展什么,它们会承担什么样的工作。

RCT_EXPORT_MODULE([js_name])

如同名称你所暗示的一样,它会导出你的模块。但是在这样的上下文中导出究竟是什么?

实际上就是建筑一个模块之间的bridge。

它的定义非常简单:

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

那么它做了什么:

  • 它首先声明了RCTRegisterModule作为一个外部函数,这也就意味着它的执行在编译阶段是不可见的,但是在链接时却是可用的。

  • 声明 一个 moduleName方法,它会将参数 js_name作为返回(如果有的话)。这样的话,你就可以有个在JS中使用的名称而不是一个object-C的类名。

  • 最后 声明一个 load的方法(当app在内存中加载完成后,对于每个类而言都有一个load方法)它会去调用前面声明的 RCTRegisterModule 函数,去让模块建立一个bridge连接。

RCT_EXPORT_METHOD(method)

这个宏会更加有趣,

+ (NSArray *)__rct_export__120
{
  return @[ @"", @"log:(NSString *)message" ];
}

Runtime

建立的整个过程中都只是提供了这些信息,诸如需要导出的模块或者方法。但是所有都发生在加载的时候,接下来我们会详细说明在运行时,这些是如何使用的。

初始化模块

所有的 RCTRegisterModule 都会添加一个 类 到数组中,这样当一个桥梁建立之后,就可以找到它了。它会浏览整个模块数组,然后创建每个模块的实例,将模块的引用存在bridge 中,也会将这个引用给回这个bridge,并且会判断是否有指定在某个队列中需要运行时,反之则会给它一个新的队列,从其他模块中分离。

 NSMutableDictionary *modulesByName; // = ...
for (Class moduleClass in RCTGetModuleClasses()) {
  // ...
  module = [moduleClass new];
  if ([module respondsToSelector:@selector(setBridge:)]) {
    module.bridge = self;
  }
  modulesByName[moduleName] = module;
  // ...
}

配置模块

一旦我们在背后的线程中有一个模块,我们会列出每个模块的所有方法,然后调用 以 __rct_export__开头的方法。我们可以用一个字符串去作为方法函数的标示。明确参数类型时非常重要的,因为在运行的时候某个参数时ID,而这种方式我们可以明确知道它是一个NSString *.

unsigned int methodCount;
Method *methods = class_copyMethodList(moduleClass, &methodCount);
for (unsigned int i = 0; i < methodCount; i++) {
  Method method = methods[i];
  SEL selector = method_getName(method);
  if ([NSStringFromSelector(selector) hasPrefix:@"__rct_export__"]) {
    IMP imp = method_getImplementation(method);
    NSArray *entries = ((NSArray *(*)(id, SEL))imp)(_moduleClass, selector);
    //...
    [moduleMethods addObject:/* Object representing the method */];
  }
}

建立JS执行

JS执行器 有一个-setUp的方法,可以让你做一些比较高层本的事情,比如悄悄的在背后初始化JavaScriptCore,它也可以保存一些工作,

JSGlobalContextRef ctx = JSGlobalContextCreate(NULL);
_context = [[RCTJavaScriptContext alloc] initWithJSContext:ctx];

注入JSON配置

JSON的配置只包含了我们的模块:

{
  "remoteModuleConfig": {
    "Logger": {
      "constants": { /* If we had exported constants... */ },
      "moduleID": 1,
      "methods": {
        "requestPermissions": {
          "type": "remote",
          "methodID": 1
        }
      }
    }
  }
}

这个是作为全区变量存在JS的虚拟机中,当JS端初始化后,我们可以创建一些这些模块的信息。

加载JS代码

加载非常直接,从定义的地方直接加载过来,在开发的时候一般都是从包里面下载或者在生产环节中就是从硬盘里读取。

执行JS代码

当一切就绪后,我们就可以在JavaScriptCore 虚拟容器中加载 源码,然后解析,执行。在初始化的时候,它会注册所有common.js模块,然后引入入口文件。

JSValueRef jsError = NULL;
JSStringRef execJSString = JSStringCreateWithCFString((__bridge CFStringRef)script);
JSStringRef jsURL = JSStringCreateWithCFString((__bridge CFStringRef)sourceURL.absoluteString);
JSValueRef result = JSEvaluateScript(strongSelf->_context.ctx, execJSString, NULL, jsURL, 0, &jsError);
JSStringRelease(jsURL);
JSStringRelease(execJSString);

完整周期

下面是一个完整的模块调用周期

调用首先会从native * 起,在执行过程中,它会调用 NativeModules 中的方法。然后它会加入队列,在native端进行执行。当JS完成执行后,native会穿过队列的调用,如同执行这些一样,通过Bridge 的回调和调用将会再一次JS中调用。一个 native模块通过调用enqueueJSCall:args:从而使用the _bridge 实例。

参数类型

从native调用JS比较容易,参数是被当作。NSArray ,而我们把它编码成 JSON,但是JS向native调用,我们需要native的类型。为了实现那样,我们需要准确的检测原始的类型 (i.e. ints, floats, chars, etc…),任何对象(或者其他结构) runtime 并不会给我们足够的信息从NSMethodSignature ,所以我们会将这些类型保存为字符串。

我们通过正则表达式去提取方法签名里的信息。然后我们使用RCTConvert类去转换成对象,该类提供了对每个类型的支持。它可以将JSON转换成需要的类型。

我们使用objc_msgSend 去动态调用这个方法,除非它是一个struct 。因为在arm64上没有任何版本的objc_msgSend_stret 所以我们回推到NSInvocation.

一旦我们转化所有的参数,我们使用另一个 NSInvocation去调用目标的方法带上这些转化后的参数。

// If you had the following method in a given module, e.g. `MyModule`
RCT_EXPORT_METHOD(methodWithArray:(NSArray *) size:(CGRect)size) {}

// And called it from JS, like:
require('NativeModules').MyModule.method(['a', 1], {
  x: 0,
  y: 0,
  width: 200,
  height: 100
});

// The JS queue sent to native would then look like the following:
// ** Remember that it's a queue of calls, so all the fields are arrays **
@[
  @[ @0 ], // module IDs
  @[ @1 ], // method IDs
  @[       // arguments
    @[
      @[@"a", @1],
      @{ @"x": @0, @"y": @0, @"width": @200, @"height": @100 }
    ]
  ]
];

// This would convert into the following calls (pseudo code)
NSInvocation call
call[args][0] = GetModuleForId(@0)
call[args][1] = GetMethodForId(@1)
call[args][2] = obj_msgSend(RCTConvert, NSArray, @[@"a", @1])
call[args][3] = NSInvocation(RCTConvert, CGRect, @{ @"x": @0, ... })
call()

线程

上面提到的,每个模块都有自己的 GCD队列。默认情况下,在没有特殊指定需要执行的队列,比如执行 -methodQueue 或者 合成的 methodQueue属性里指定的队列。而异常通常是 View Managers * (继承自RCTViewManager)) 会使用 Shadow Queue。以及 RCTJSThread,它是一个预置的线程。

线程的规则如下:

  • -init and -setBridge 确保能够在主线程调用;

  • 所有导出的模块都能确保被目标队列调用

  • 如果你调用 RCTInvalidating 协议,invalidate也能够被目标队列调用。

  • 这里并不能确保 -dealloc 可以被正常调用

当接受到JS一系列的的调用,这些调用会被分组到各个目标的队列,然后进行平行调度。

// group `calls` by `queue` in `buckets`
for (id queue in buckets) {
  dispatch_block_t block = ^{
    NSOrderedSet *calls = [buckets objectForKey:queue];
    for (NSNumber *indexObj in calls) {
      // Actually call
    }
  };

  if (queue == RCTJSThread) {
    [_javaScriptExecutor executeBlockOnJavaScriptQueue:block];
  } else if (queue) {
    dispatch_async(queue, block);
  }
}

尾声

大致这就是React Native bridge如何工作。如果帮助到部分希望构建更加复杂的模块的用户,也希望他们可以向React Native贡献自己的力量。如果对这篇文章有什么疑问可以 twiiter联系作者@ Tadeu Zagallo