React Native渲染原理深入探索

摘要:本篇文章是对Nicolas Couvrat 在2018年3月20的《Wait… What Happens When my React Native Application Starts? — An In-depth Look Inside React Native》这篇文章做的翻译,没有掺杂任何个人色彩。目的是为了帮助对React Native感兴趣的人,更好的理解它。

标题:探索React Native内部如何运行,为您呈现您所不知道的秘密。

声明:这篇文章假设读者对React Native和Native Modules有一个基本的认识,对于初学者建议先看一下官方文档。

作者按:这篇文章是基于Nicolas在2018年3月在伦敦举行的一次ReactFest峰会上的讨论,可以通过油管查看峰会视频。

当我(以下统指Nicolas)开始使用React Native时,很快就被一个疑问绊住了,是什么疑问呢?事实上,仅仅从大家使用React Native编译应用的起步上来说,有时候事情就会看起来相当魔幻。想要从JavaScript中使用一些原生代码吗?只需要使用@ReactMethod (或者 RCT_EXPORT_METHOD 在 iOS平台)! 想要从原生端向JavaScript发送一个事件吗?没问题,只需要拿到合适的JavaScript module,然后像在原生方法中一样调用它即可。更何况,同样的原生代码可以在Android和iOS双平台运行…

跟大家猜的一样,这些功能并不简单,而且相当复杂。那么是什么让React Native获得如此殊荣呢?我们容易得到普遍的答案:

The React Native “Bridge”,duh !

换句话说:(作者给出了下面这张图片)


React Native渲染原理深入探索_第1张图片
一些人对React Native的解释

但是,谁想要这么简单的答案呢?接下来的时间,我们将要讲一下细节的东西。但是要注意:这件事可能会变得一团糟。

React Native基础架构

让我们一针见血的说吧:为了运行起来,React Native在运行时要依赖一整套基础架构。

等等,你不是要给我另外一种神秘的解释吧?

当然不是。但是有两点需要注意。首先是infrastructure这个基础架构:我们所熟知的桥,只是他的一部分——我敢说,这并不是最令人难以置信的地方。再者就是runtime这个运行时:在每一次启动应用的时候,上面提到的基础架构都要基于这个运行时环境才能编译,而这一切都发生在任何用户代码被执行之前。换句话说,在你的RN应用运行起来之前,它需要经历一个过渡性的状态,在这个状态下,应用一刻不停地在搭建基础设施。而你什么都看不见~
你可能会问,这个所谓的基础架构长什么样?让我们通过画一张图来展示它的启动步骤。(箭头表示:引用)

React Native渲染原理深入探索_第2张图片
React Native基础架构——Java 商标在iOS环境中需要用Object-C替换

哇哦,有点复杂啊!不是吗?并且,这只是一个简化版……为了弄清楚这个混乱的基础架构,我们按照它们在启动时被创建的顺序,逐步介绍。开始吧!

启动一个React Native应用

记住,无论何时一个React Native应用启动时,唯一做的事就是:编译。我们通过几个步骤来分解你按下手机上的应用图标那一刻到看到视觉效果这一段时间到底做了什么?

首先,应用只做了两件事:

a. 应用代码
b. 独立的线程,the main Thread。主线程是由手机操作系统自动分配的。Nicolas还专门做了解释:主线程,也被称为UI 线程,除了初始化之外,该线程专门负责UI相关的工作。

为了简单地解释一下,我们将会从概念上把代码分成两部分:the framework code,框架层代码——你不必每次都写的代码;the custom code,自定义代码——实际上就是用户代码。这两部分都要通过JavaScript和Native原生语言编码。这就要求我们需要通过4个步骤去理解整个启动流程。要处理的第一件事情就是在主线程中framework层代码的原生部分。

创建原生基础架构

有件重要的事情你必须意识到,尽管大部分UI代码——,…——是由JavaScript语言写的,但是最终只有原生组件被渲染。这就意味着React Native framework框架层需要做以下操作:

  1. 创建原生组件,建立原生组件与JavaScript组件的映射关系。
  2. 把原生组件存储起来并且展示。

第一步是由UIManagerModule(稍后会介绍)来处理的,而RootView比较关心第二步操作。RootView只是一个容器,在RootView内部原生组件被编制成一棵大树——如果你喜欢,可以称之为一个JavaScript树的原生代言人——并且,屏幕上显示的一切东西都在这棵树上存储着。

回到我们的初始化进程中来:一切事情都起源于RootView——现在还是个空容器——的创建。创建过程在移动到桥接口Bridge Interface上之前进行。

桥不是在原生端和JavaScript端都支持的吗?为什么这里还需要接口呢?

尽管桥Bridge确实在两端都支持!但是大部分时候原生端——包括RootView——都是由平台语言(Java或object-C)来编程的,然而桥Bridge却完全是由C++来实现的。因此,桥接口Bridge Interface扮演着API的角色,它允许原生端跟JavaScript交互。桥本身由两部分组成:JavaScript对原生端的调用业务和原生端对JavaScript的调用业务。

然而,没有终端执行调用任务的时候,桥Bridge什么都不是。这些终端就是原生模块Native Modules,他们是JavaScript环境最终唯一可访问到的原生数据。换句话说,除了Native Modules,JavaScript应用层最终什么也看不到。由于这个原因,除了你创建自定义模块Custom Modules外,framework框架层也要封装核心模块Core Modules。后者有一个例子就是UIManagerModule,它存储了所有的JavaScript视觉组件(Javascript UI Components)和相应的原生组件(Native Views)的映射关系。每次JavaScript视觉组件被创建、更换或删除,UIManagerModule相应地会使用这种映射关系创建、更换或删除相关的原生组件。进而将这种改变通知到存储在RootView中的原生树上,并呈现出来。

从初始化的角度讲,所有的原生模块Native Modules都会被做相同的处理:对于每一个Native Module来说,创建实例之后,实例的引用将被存储在JavaScript调用原生端的桥Bridge上——这样该实例就可以被JavaScript调用。同样地,桥接口Bridge Interface的引用(图中Core Modules和Custom Modules持有的箭头)都被传递给每个Native Module原生模块(也就是图中的Core Modules和Custom Modules),允许这些原生模块直接调用JavaScript。
注意:Native Module里封装了JS调原生和原生调JS两种业务接口。
相互调用的引用创建完成之后,系统创建了两个线程,他们是:JS ThreadNativeModulesThread。Nicolas说,在iOS平台上的React Native实现严格上讲并不是一个独立线程,而是一个线程池。

协奏曲:搭建JavaScript虚拟机(JavaScript Virtual Engine)

在继续执行之前,我们快速的总结下目前已经运行过的流程:

  1. 一大堆原生成员已经被创建
  2. 我们现在已经有三个线程在同时运行
  3. 目前还没有任何JavaScript代码被执行
    回到我们刚开始画的那张流程图,目前已经执行的部分如下:


    React Native渲染原理深入探索_第3张图片
    React Native原生侧

意思是说,现在是时候加载JavaScript Bundle包了——framework和用户的RN工程代码都在里面。
作为一种解释性脚本语言,JavaScript不能被直接运行,它需要被转换成字节码,然后被执行。而这个转换工作则是由Javascript virtual machine (也就是前文提到的JavaScript虚拟机)来完成的。JavaScript virtual machine有很多种:Chrome的 V8, Mozilla的 SpiderMonkey 和 Safari的 JavaScriptCore… 在Debug调试模式下,默认使用的是Chrome的V8内核,直接运行在浏览器上。而在设备上或者模拟器上运行时,使用的Safari的JavaScriptCore。还要注意的一点是,JavaScriptCore默认安装在iOS系统上,Android系统默认没有JavaScriptCore,所以打包时ReactNative自动bundle了一份拷贝在Android包中——这就使得Android应用包比iOS包稍微大一点儿。

在任何情况下,在成功启动JavaScript虚拟机之前,React Native都要提供一个ReactContext作为它的运行环境。这个运行环境中包括了JavaScript global object,也就是意味着global object全局对象事实上是创建和储存在C++ Bridge桥上的。为什么这样的安排呢?因为global object全局对象,不仅可以从JavaScript环境内部访问,而且可以从外部被原生访问。因此C++(原生层)和JavaScript通信的基本方式是通过全局对象global object,也就是说通过global object一些原生函数可以被JavaScript访问——同样,这些原生函数也可以被JavaScript拿来传递数据给原生端。

全局对象global object中存储了很多成员,而ModuleConfig数组和flushQueue()函数尤其重要。ModuleConfig数组中每一个元素都详细描述了一个原生模块(包括custom module 自定义原生模块和core module 核心原生模块),涵盖了模块的名字、被暴露出的常量和方法…… 而flushQueue()函数在确保JavaScript和Native原生环境通信方面扮演着重要角色,它还被用于定期地将调用从第一个元素的位置移动到第二个元素的位置。

一旦JavaScript Context这个运行时环境创建完成,它就会被用于JavaScript engine的运行,并开始加载React Native bundle包,这个过程是在the JS Thread线程进行的。

加载JavaScript bundle包

在JavaScript虚拟机开始执行framework层的JavaScript代码的同时,它还创建了BatchedBridge。尽管有这样看似滑稽的名字,但它就是一个队列queue,队列里存储的是从JavaScript到原生Native的调用。这里的调用是名词,它是一个对象。该对象封装了native module ID——原生模块ID,method ID for the specified native module——特定原生模块的方法ID,和他们一起的还有原生方法的参数一起被调用。BatchedBridge还定期的(默认是5微秒)调用global.flushQueue()函数,传递它的内容——“调用”数组——给JavaScript调用原生的C++ Bridge桥终端。像分批次处理一样,给这些小数组都标注index指引,这样做确保了包含在一个批次中的所有UI变化同时可见(这样做是必要的,因为整个过程是异步的)。桥的JavaScript调用原生终端,最终将会迭代执行每个批次,并且通过指定的模块ID把“调用”分配给相应的原生模块——注意:原生模块既支持JS调用原生,也支持原生调用JS。

下一步就是创建NativeModules对象了——是的,就是这个每次你想要调用原生模块时,不得不从‘react-native’ 库导入的对象。NativeModules对象是由前面提到过的ModuleConfig数组构成的。这里不再详述细节,初略的讲它近似如下操作:对于每一个包含在ModuleConfig中的module_name来说,NativeModules[module_name]={}。那么,对于每一个指定原生模块的方法名来说,NativeModules[module_name][method_name]=fillerMethod,fillerMethod方法中存储了模块在BatchedBridge桥上接收到的所有方法参数,他们和原生方法、模块ID一起完成了一次JavaScript对原生方法的调用(像这样fillerMethod = function(...args) { BatchedBridge.enqueueNativeCall(moduleID, methodID, args)})。也就是说,你写了MyNativeModule.myMethod(args)这行代码,实际上是触发了fillerMethod的执行。

快到终点了。最后要做的事情是,创建核心JJavaScript模块core JS Module,DeviceEventEmitter和AppRegistry都是JavaScriptModule的派生类。前者用于从Native原生端向JavaScript端发送事件。后者则存储着指向你的JavaScript应用主组件的引用。为了可以被Native原生模块调用,这些JavaScriptModule都注册在JavaScript的global object全局对象上。

至此,整个基础架构infrastructure创建完成。

让React Native Application可见

尽管整个初始化工作完成了,可你啥也看不见!事实上,正在加载的JavaScript bundle包是在JS thread线程中执行的,它是独立于主线程(也叫UI线程)的。这样的话,JS thread线程就必须通知主线程一切准备就绪,作为回应,主线程通过AppRegistry请求JS thread运行自定义主模块custom main component——通常指的是App.js这个文件的渲染。

从线程的角度,React Native App启动的流程如下图:


React Native渲染原理深入探索_第4张图片
React Native的启动流程图

包含在你主模块(如,App.js)中的JavaScript组件树将会被遍历,每遇到一个UI组件就会调用一次UIManagerModule。在主线程中,UIManagerModule将依次创建原生组件Native Views,并将其存储在RootView中:恭喜你!你的应用可见了。

还要说的话

这篇文章是基于2018年3月Nicolas参加在伦敦举行的ReactFest峰会上的讨论,下面是在会场上被问及的相关问题的答案。

创建一个NativeModuleThread线程的意义是什么,如果我们不使用它的话?

是的,在启动过程中这个NativeModuleThread线程没有被广泛使用,然而后期它确实重要。每次JavaScript对原生Native的调用都会使用这个线程——调用在被分发给相应的原生模块Native Module之后完成。实现细节:在iOS平台上,并不存在这样的React Native线程。作为替代,每一个原生模块Native Module实例化时都被赋予GCDQueue,由系统来进行线程管理。

你好,为什么在C++中桥Bridge有两个终端?JavaScript和原生Native不都各自支持一个终端吗?

这确实有点儿令人困惑。但是它确实很有用,因为我们确定React Native需要通过桥接解决隔离gap。隔离gap主要包括两个方面:

  1. 编程语言间隔(Java/C++、JavaScript)
  2. 线程间隔(JS thread, main thread, NativeModuleThread)

正如我们前面提到的一样,语言隔离主要通过JavaScript全局对象global object来解决,Javascript global object可以支持被JavaScript环境和C++环境访问。所以,我们常说的React Native中的“Bridge”桥,只是承担不同线程的分派工作。在这方面,原生调JS(“native to Javascript”) 和JS调原生( “Javascript to native” )将产生完美的效果,尽管他们两个都是用同一种语言实现:前者的调用始于原生线程native thread(主线程或NativeModuleThread线程)并将这种调用任务延伸到JS thread线程;然而后者的调用来自于* JS thread*(通过global.flushQueue()函数)并将这种调用分发给原生线程。

好了,这就是对React Native强大功能的详细概述。我希望能激起你的兴趣,让你愿意对这个项目做贡献。但是谁知道呢?

你可能感兴趣的:(React Native渲染原理深入探索)