概述

React Native 渲染器通过一系列加工处理,将 React 代码渲染到宿主平台。这一系列加工处理就是渲染流水线(pipeline),它的作用是初始化渲染和 UI 状态更新。 接下来介绍的是渲染流水线,及其在各种场景中的不同之处。

渲染流水线可大致分为三个阶段:

渲染(Render)

  • 在 JavaScript 中,React 执行那些产品逻辑代码创建 React 元素树(React Element Trees)。然后在 C++ 中,用 React 元素树创建 React 影子树(React Shadow Tree)。

    提交(Commit)

  • 在 React 影子树完全创建后,渲染器会触发一次提交。这会将 React 元素树和新创建的 React 影子树的提升为“下一棵要挂载的树”。 这个过程中也包括了布局信息(在c++中)计算,在更新时会有dom diff计算。

    挂载(Mount)

  • React 影子树有了布局计算结果后,它会被转化为一个宿主视图树(Host View Tree)。

初始化渲染

渲染阶段

想象一下你准备渲染一个组件:

  1. function MyComponent() {
  2. return (
  3. <View>
  4. <Text>Hello, World</Text>
  5. </View>
  6. );
  7. }
  8. // <MyComponent />

在上面的例子中, 是 React 元素。React 会将 React 元素简化为最终的 React 宿主组件。每一次都会递归地调用函数组件 MyComponet ,或类组件的 render 方法,直至所有的组件都被调用过。现在,你拥有一棵 React 宿主组件的 React 元素树。
image.png

名词解释: React 组件(React Component):React 组件就是 JavaScript 函数或者类,描述如何创建 React 元素。

React 复合组件(React Composite Components):复合组件就是开发者声明的组件,上边的MyComponent。

React 宿主组件(React Host Components):React 组件的视图是通过宿主视图,比如 ,实现的。在 Web 中,ReactDOM 的宿主组件就是

标签、

标签代表的组件。

在元素简化的过程中,每调用一个 React 元素,渲染器同时会同步地创建 React 影子节点。这个过程只发生在 React 宿主组件上,不会发生在 React 复合组件上。比如,一个 会创建一个ViewShadowNode 对象,一个会创建一个TextShadowNode对象。注意,并没有直接对应的 React 影子节点。

提交阶段

在 React 影子树创建完成后,渲染器触发了一次 React 元素树的提交。RN渲染过程 - 图2
提交阶段(Commit Phase)由两个操作组成:布局计算和树的提升。

布局计算(Layout Calculation)

  • 这一步会计算每个 React 影子节点的位置和大小。在 React Native 中,每一个 React 影子节点的布局都是通过 Yoga 布局引擎来计算的。实际的计算需要考虑每一个 React 影子节点的样式,该样式来自于 JavaScript 中的 React 元素。计算还需要考虑 React 影子树的根节点的布局约束,这决定了最终节点能够拥有多少可用空间。

RN渲染过程 - 图3

树提升,从新树到下一棵树(Tree Promotion,New Tree → Next Tree)

  • 这一步会将新的 React 影子树提升为要挂载的下一棵树。这次提升代表着新树拥有了所有要挂载的信息,并且能够代表 React 元素树的最新状态。下一棵树会在 UI 线程下一个“tick”进行挂载。(译注:tick 是 CPU 的最小时间单元)

更多细节

  • 这些操作都是在后台线程中异步执行的。
  • 绝大多数布局计算都是 C++ 中执行,只有某些组件,比如 Text、TextInput 组件等等,的布局计算是在宿主平台执行的。文字的大小和位置在每个宿主平台都是特别的,需要在宿主平台层进行计算。为此,Yoga 布局引擎调用了宿主平台的函数来计算这些组件的布局。

挂载阶段

RN渲染过程 - 图4
挂载阶段(Mount Phase)会将已经包含布局计算数据的 React 影子树,转换为以像素形式渲染在屏幕中的宿主视图树。请记住,这棵 React 元素树看起来是这样的:

<View>
  <Text>Hello, World</Text>
</View>

站在更高的抽象层次上,React Native 渲染器为每个 React 影子节点创建了对应的宿主视图,并且将它们挂载在屏幕上。在上面的例子中,渲染器为 创建了android.view.ViewGroup 实例,为 创建了文字内容为“Hello World”的 android.widget.TextView实例 。iOS 也是类似的,创建了一个 UIView 并调用 NSLayoutManager 创建文本。然后会为宿主视图配置来自 React 影子节点上的属性,这些宿主视图的大小位置都是通过计算好的布局信息配置的。
RN渲染过程 - 图5
更详细地说,初始化渲染的挂载阶段由三个步骤组成:

树对比(Tree Diffing)

  • 这个步骤完全用的是 C++ 计算的,会对比“已经渲染的树”(previously rendered tree)和”下一棵树”之间的差异。计算的结果是一系列宿主平台上的原子变更操作,比如 createView, updateView, removeView, deleteView 等等。在这个步骤中,还会将 React 影子树拍平,来避免不必要的宿主视图创建。关于视图拍平的算法细节可以在后文找到。

    树提升

  • 树提升,从下一棵树到已渲染树(Tree Promotion,Next Tree → Rendered Tree):在这个步骤中,会自动将“下一棵树”提升为“先前渲染的树”,因此在下一个挂载阶段,树的对比计算用的是正确的树。

    视图挂载(View Mounting)

  • 这个步骤会在对应的原生视图上执行原子变更操作,该步骤是发生在原生平台的 UI 线程的。

更多细节

  • 挂载阶段的所有操作都是在 UI 线程同步执行的。如果提交阶段是在后台线程执行,那么在挂载阶段会在 UI 线程的下一个“tick”执行。另外,如果提交阶段是在 UI 线程执行的,那么挂载阶段也是在 UI 线程执行。
  • 挂载阶段的调度和执行很大程度取决于宿主平台。例如,当前 Android 和 iOS 挂载层的渲染架构是不一样的。
  • 在初始化渲染时,“先前渲染的树”是空的。因此,树对比(tree diffing)步骤只会生成一系列仅包含创建视图、设置属性、添加视图的变更操作。而在接下来的 React 状态更新场景中,树对比的性能至关重要。
  • 在当前生产环境的测试中,在视图拍平之前,React 影子树通常由大约 600-1000 个 React 影子节点组成。在视图拍平之后,树的节点数量会减少到大约 200 个。在 iPad 或桌面应用程序上,这个节点数量可能要乘个 10。

React 状态更新

接下来,我们继续看 React 状态更新时,渲染流水线(render pipeline)的各个阶段是什么样的。假设你在初始化渲染时,渲染的是如下组件:

function MyComponent() {
  return (
    <View>
      <View
        style={{ backgroundColor: 'red', height: 20, width: 20 }}
      />
      <View
        style={{ backgroundColor: 'blue', height: 20, width: 20 }}
      />
    </View>
  );
}

应用我们在初始化渲染部分学的知识,你可以得到如下的三棵树:
RN渲染过程 - 图6
请注意,节点 3 对应的宿主视图背景是红的,而节点 4 对应的宿主视图背景是蓝的。假设 JavaScript 的产品逻辑是,将第一个内嵌的的背景颜色由红色改为黄色。新的 React 元素树看起来大概是这样:

<View>
  <View
    style={{ backgroundColor: 'yellow', height: 20, width: 20 }}
  />
  <View
    style={{ backgroundColor: 'blue', height: 20, width: 20 }}
  />
</View>

React Native 是如何处理这个更新的?
从概念上讲,当发生状态更新时,为了更新已经挂载的宿主视图,渲染器需要直接更新 React 元素树。 但是为了线程的安全,React 元素树和 React 影子树都必须是不可变的(immutable)。这意味着 React 并不能直接改变当前的 React 元素树和 React 影子树,而是必须为每棵树创建一个包含新属性、新样式和新子节点的新副本。

让我们继续探究状态更新时,渲染流水线的各个阶段发生了什么。

渲染阶段

RN渲染过程 - 图7
React 要创建了一个包含新状态的新的 React 元素树,它就是要复制所有变更的 React 元素和 React 影子节点。 复制后,再提交新的 React 元素树。
React Native 渲染器利用结构共享的方式,将不可变特性的开销变得最小。为了更新 React 元素的新状态,从该元素到根元素路径上的所有元素都需要复制。 但 React 只会复制有新属性、新样式或新子元素的 React 元素,任何没有因状态更新发生变动的 React 元素都不会复制,而是由新树和旧树共享。
在上面的例子中,React 创建新树使用了这些操作:

  1. CloneNode(Node 3, {backgroundColor: ‘yellow’}) → Node 3’
  2. CloneNode(Node 2) → Node 2’
  3. AppendChild(Node 2’, Node 3’)
  4. AppendChild(Node 2’, Node 4)
  5. CloneNode(Node 1) → Node 1’
  6. AppendChild(Node 1’, Node 2’)

操作完成后,节点 1’(Node 1’)就是新的 React 元素树的根节点。我们用 T 代表“先前渲染的树”,用 T’ 代表“新树”。
RN渲染过程 - 图8
注意节点 4 在 T and T’ 之间是共享的。结构共享提升了性能并减少了内存的使用。

提交阶段

RN渲染过程 - 图9
在 React 创建完新的 React 元素树和 React 影子树后,需要提交它们。

布局计算(Layout Calculation)

  • 状态更新时的布局计算,和初始化渲染的布局计算类似。一个重要的不同之处是布局计算可能会导致共享的 React 影子节点被复制。这是因为,如果共享的 React 影子节点的父节点引起了布局改变,共享的 React 影子节点的布局也可能发生改变。

    树提升(Tree Promotion ,New Tree → Next Tree)

  • 和初始化渲染的树提升类似。这一步会将新的 React 影子树提升为要挂载的下一棵树。这次提升代表着新树拥有了所有要挂载的信息,并且能够代表 React 元素树的最新状态。下一棵树会在 UI 线程下一个“tick”进行挂载。(译注:tick 是 CPU 的最小时间单元)

    树对比(Tree Diffing)

  • 这个步骤会计算“先前渲染的树”(T)和“下一棵树”(T’)的区别。计算的结果是原生视图的变更操作。

    • 在上面的例子中,这些操作包括:UpdateView(**'Node 3'**, {backgroundColor: 'yellow'})

      挂载阶段

      RN渲染过程 - 图10

      树提升(Tree Promotion ,Next Tree → Rendered Tree)

  • 在这个步骤中,会自动将“下一棵树”提升为“先前渲染的树”,因此在下一个挂载阶段,树的对比计算用的是正确的树。

    视图挂载(View Mounting)

  • 这个步骤会在对应的原生视图上执行原子变更操作。在上面的例子中,只有视图3(View 3)的背景颜色会更新,变为黄色。

RN渲染过程 - 图11

总结

初始化渲染与更新渲染的不同

在初始化渲染时,“先前渲染的树”是空的。因此,树对比(tree diffing)步骤只会生成一系列仅包含创建视图、设置属性、添加视图的变更操作。

初始化渲染时diff发生在挂载mount阶段(因为没什么需要对比的)
更新时发生在提交commit阶段