RN 的初版架构——UI 布局与绘制

277 阅读9分钟

我们知道 RN 之所以受欢迎的其中一个原因就是把之前只有在 React 中有的 jsx 带进了 Native 开发的世界

在这一个篇章中,我们会深入了解 RN 是如何将 <View><Text> 标签转换成 UIView(IOS)、ViewGroup(Android)

当然,还有 yoga 究竟在其中做了什么?以及为什么要有 yoga


但是,在深入之前,我们要先聊聊一个方法:runApplication

runApplication 顾名思义,这是一个启动应用的方法,但这里启动的不是原生应用,而是在 JS bundle 在加载完之后,由 RN 在原生应用中启动 React 应用,它的调用过程涵盖了三个线程,其调用流程如下:

可以看到,我们的 RN 程序启动以后,会在客户端的 RootView 中调用 runApplication 方法,这个方法的调用会通过我们在通信机制中讲到的 Instance -> Bridge -> JSCExecutor 这条通道一路走进 JS 程序

当 JS 接收到 AppRegistry.runApplication 的调用后,它会去找到我们 RN 项目根目录的 index.js 注册的组件(默认在 App.js),最后调用 ReactNative.jsrender 方法

ReactNative.js 中包含着 RN 在 JS 侧的核心代码,他的主要任务是将 React diff 完的 fiber 转换成为一系列的 UIManager.xxxxx 调用

这些调用最后会触发 Native 中的 UIManager(UIManager 也是一个 Native module) 的逻辑生成原生元素(UIView,ViewGroup 等等) ,最后在 yoga 这个布局引擎的帮助下完成原生页面的渲染

createView, setChildren 与 yoga 布局

接下来我们以一个简单的例子来聊聊我们写的 RN jsx 是如何最后转变为原生元素并显示在屏幕中的

<View>
	<Text>Hello world!</Text>
</View>

当我们这个组件被 ReactNative.jsrender 执行后,会有以下方法被调用:

题外话,在具体的场景中,上述例子可能不止有下述方法被调用了,被调用的方法也可能会有区别,但是他们的目的与功能是类似的,本文为了方便理解做了部份简化

  1. UIManager.createView(tagV, 'RCTView', rootTag, {})
  2. UIManager.createView(tagT, 'RCTText', rootTag, { text: 'Hello world!' })
  3. UIManager.setChildren(tagV, [tagT])

createView 接受 4 个参数:

  • 第一个参数是一个自增的数字,会唯一标识一个创建的元素
  • 第二个参数是需要创建的元素类型,因为我们需要的是 View 元素,其对应的是 RCTView (在原生平台中,它是一个继承自各自平台 View 元素的类,其中定义了一些 RN 需要的方法)
  • 第三个参数是根容器(root container)的唯一标识符,根容器在 native 侧创建,是 RN 创建的元素的根结点。由于一个 APP 中可以创建多个根容器,createView 需要确保当前创建的元素被归类到正确的容器中
  • 最后一个参数代表元素的属性

setChildren 接受 2 个参数:

  • 第一个参数与 createView 一致,唯一标识着一个父元素
  • 第二个参数是一个数组,其中包含子元素的标签

当这两个方法通过 bridge 最后进入 Native 侧的 UIManager 时,会根据 IOS 与 Android 的平台特性区分为两套实现,分别是:

  • RCTUIManager.m:IOS 中 UIManager 的实现
  • UIManagerModule.java:Android 中 UIManager 的实现

下面我们分别聊聊这两者都做了些什么

UIManager in IOS

在 IOS 的 createView 实现中,主要做了 3 件事:

  1. 根据 RCTView 这个类型分别创建了一个 shadowView 以及一个离屏的 UIKit UIViewRCTText 类也同理,后不赘述)
  2. 根据 RCTView 这个类型的规则,从元素的属性中筛选了部份 shadowView 需要的属性赋值给 shadowViewprops
  3. 将当前的 shadowView 放进 _shadowViewsWithUpdatedProps 中等待后续消费

其中,shadowView 是 RN 为了方便 yoga 计算布局而设计的类型,而 UIView 是 IOS 正儿八经在屏幕上渲染的元素

两者的区别在于 shadowView 负责接受元素布局相关的属性(如 width, height, border, margin 等),然后交给 yoga 计算布局;UIView 只需要处理布局之外的 backgroundColor, opacity, shadow 等等属性就好

属性的分类依据每个类型不同而不同,比如 RCTView 的定义在 RCTViewManager.m 中

这样做的好处在于可以将计算量较大的布局工作交给另外一个线程防止 IOS 的主线程阻塞


在 IOS 的 setChildren 实现中,主要做了 3 件事:

  1. 将子元素的 shadowView 插入成为父元素 shadowViewsubView
  2. 将子元素插入成为父元素的 subView
  3. 将当前的 shadowView 放进 _shadowViewsWithUpdatedChildren 中等待后续消费

最后,我们在之前讲 runApplication 的调用流程的时候留了一个伏笔:在 JSCExecutor.cpp 中调用 JS 的方法用的是 callFunctionReturnFlushedQueue ,以下是它的实现:

callFunctionReturnFlushedQueue(module: string, method: string, args: any[]) {
    this.__guard(() => {
      // 调用对应的 js 方法
      this.__callFunction(module, method, args);
    });

  	// 返回到目前为止积压在 queue 中的 native module 调用请求
    return this.flushedQueue();
  }

可以看到在执行完 js 侧的 runApplication 后,该方法会将执行过程中累积的 native module 调用一下子清空,明确告知 native 侧:我这个方法调用过程中发生的请求已经全部给你了

当 native 侧接受到这个信息之后,它会去轮询所有注册过 batchDidComplete 方法的 native module(UIManager 也是其中一员)并执行他们的 batchDidComplete 方法

UIManagerbatchDidComplete 调用了最重要的一个方法:_layoutAndMount

我们来看看实现:

// in RCTUIManager.m

- (void)_layoutAndMount
{
  // 消费上述 _shadowViewsWithUpdatedProps:把有变化的 props 经过转换后赋值给 yogaNode(后续 yoga 会根据这些节点的属性来计算布局
  [self _dispatchPropsDidChangeEvents];
  // 消费上述 _shadowViewsWithUpdatedChildren:根据不同的 view 类型做不同处理(shadowView 场景的话什么都不做)
  [self _dispatchChildrenDidChangeEvents];

  // 遍历所有的 root container(reactTag)
  for (NSNumber *reactTag in _rootViewTags) {
    // 找到每一个 root container 的 shadowView(也就是 rootShadowView),由于 view 跟 shadowView 是一一对应的关系,所以 rootShadowView 也有可能有多个)
    RCTRootShadowView *rootView = (RCTRootShadowView *)_shadowViewRegistry[reactTag];
    // 触发 yoga 的布局计算,并且把布局结果包装到一个代码片段中返回,返回的代码片段会被加到一个等待队列中等待被主线程执行(因为在 ios 中只有主线程能操纵 UIKit)
    [self addUIBlock:[self uiBlockWithLayoutUpdateForRootView:rootView]];
  }

  // 执行上述的代码片段,将计算好的布局应用给元素
  [self flushUIBlocksWithCompletion:^{}];
}

补充一点,我们说到 uiBlockWithLayoutUpdateForRootView 方法除了计算新的元素布局之外,还会返回一个代码片段,这个代码片段除了在普通情况下将计算好的布局应用给元素之外,还负责判断该元素是否需要动画,如果需要的话,还会将对应的动画效果应用给对应元素

至此,我们完成了对 IOS 中 UIManager 的部份方法与核心机制讲解

UIManager in Android

UIManager 在 Android 中的目标跟在 IOS 中是一致的,主要区别在于加入了一个 NativeViewHierarchyOptimizer 的优化机制

至于加入的原因我们会在后文描述,现在我们先来看看 Android 是如何实现 createView, setChildren, batchDidComplete

在 Android createView 的实现中,RN 也做了三件事:

  1. 根据 RCTView 这个类型创建了一个 shadowView ,并将其保存至 mShadowNodeRegistry(一个用来保存所有 shadowView 的类)
  2. 将元素属性中 shadowView 需要的属性赋值给新创建的 shadowView
  3. 将创建原生 View 元素的任务交给 NativeViewHierarchyOptimizer,它会在符合条件的情况下创建原生元素

NativeViewHierarchyOptimizer 就是 Android 与 IOS 在 UIManager 中最大的区别,它的工作主要就是将元素用是否为布局专用元素进行区分:如果是布局专用元素它将不会创建真正的原生元素;反之则会跟 IOS 一样创建原生元素


setChildren 中,则是:

  1. 将子元素的 shadowView 插入成为父元素 shadowViewmChildren(对应 IOS 中的 subView
  2. 将插入原生子元素的任务交给 NativeViewHierarchyOptimizer,在其中会判断父元素是否为布局专用元素,如果是,则会将子元素插入到最近的不是布局专用元素的父元素上

最后,在 JS 侧所有请求结束后,Android 会执行 dispatchViewUpdates 方法(对应 IOS 中的 _layoutAndMount

// in UIImplementation.java

public void dispatchViewUpdates(int batchId) {
    try {
      // 1. 调用 yoga 计算布局
      // 2. 将布局结果转换成一些对元素的操作并将这些操作入栈等待执行
      // 3. 执行 JS 侧的 onLayout 回调
      updateViewHierarchy();
      // 清理布局过程中使用到的一些标识
      mNativeViewHierarchyOptimizer.onBatchComplete();
      // 将操作一一出栈并应用布局(调用元素的 measure 以及 layout 方法)
      mOperationsQueue.dispatchViewUpdates(batchId, commitStartTime, mLastCalculateLayoutTime);
    }
  }

Android vs IOS

在上文中,我们说到 Android 会比 IOS 多一个 NativeViewHierarchyOptimizer 用来防止为一些布局专用元素创建真正的元素,为什么呢?

首先,什么是布局专用元素?布局专用元素需要同时满足个条件:

  1. 该元素是 RCTView
  2. 该元素的 collapsable 元素是 true(也就是默认值)
  3. 该元素所有属性都是布局专用属性(LAYOUT_ONLY_PROPS),包含:
// in ViewProps.java

private static final HashSet<String> LAYOUT_ONLY_PROPS =
      new HashSet<>(
          Arrays.asList(
              ALIGN_SELF,
              ALIGN_ITEMS,
              COLLAPSABLE,
              FLEX,
              FLEX_BASIS,
              FLEX_DIRECTION,
              FLEX_GROW,
              FLEX_SHRINK,
              FLEX_WRAP,
              JUSTIFY_CONTENT,
              ALIGN_CONTENT,
              DISPLAY,

              /* position */
              POSITION,
              RIGHT,
              TOP,
              BOTTOM,
              LEFT,
              START,
              END,

              /* dimensions */
              WIDTH,
              HEIGHT,
              MIN_WIDTH,
              MAX_WIDTH,
              MIN_HEIGHT,
              MAX_HEIGHT,

              /* margins */
              MARGIN,
              MARGIN_VERTICAL,
              MARGIN_HORIZONTAL,
              MARGIN_LEFT,
              MARGIN_RIGHT,
              MARGIN_TOP,
              MARGIN_BOTTOM,
              MARGIN_START,
              MARGIN_END,

              /* paddings */
              PADDING,
              PADDING_VERTICAL,
              PADDING_HORIZONTAL,
              PADDING_LEFT,
              PADDING_RIGHT,
              PADDING_TOP,
              PADDING_BOTTOM,
              PADDING_START,
              PADDING_END));

在这种情况下,NativeViewHierarchyOptimizer 将不会创建真正的原生元素

为什么要在 Android 中应用这个优化呢?这个我们要从 Android 的 Choreographer 开始说起:

对于非 RN 的 Android app来说,当 app 接受到硬件传来的 vsync 信号之后,他会启动 choreographer 程序:

ChoreographerViewRootImpl.performTraversals()	// 开始从程序根节点向下遍历所有元素performMeasure()   // 执行元素 measure 方法performLayout()    // 计算元素布局performDraw()      // 绘制元素

其中 measure 以及 layout 这两步只有当元素本身判断需要(元素调用了 requestLayout )之后才会启动,由于 RN 引入了 yoga 引擎来计算布局(取代了 performMeasure 与 performLayout 的功能),所以 RN 的目标就是让 Android 本身的 performMeasure 以及 performLayout 尽可能少的被启动

所以在 Android 中才需要 NativeViewHierarchyOptimizer 来尽可能减少多余的节点被挂在渲染树上


那么为什么 IOS 不需要呢?

因为 IOS 用的是完全不同的机制,IOS 提供了两种渲染机制:Frame-Based Layout 和 Constraint-Based Layout

RN 选用了 Frame-Based Layout,它的好处就是:系统会直接根据我们计算好的结果来渲染下一帧,不会有多余的操作

如何保证状态更新在一帧内完成

聊完了 RN 的渲染后,我们要来聊聊一个容易被忽略但又至关重要的细节:RN 如何在同一轮 Native -> JS 调用期间尽可能把产生的 UI 更新指令合并进同一批 UI 提交中?(最大程度避免用户感知到中间状态)

在上文中我们分别介绍 IOS 与 Android 的 UIManager 的时候,也一并介绍了 batchDidComplete 这个方法,这个方法被触发意味着两件事:

  1. JS 的逻辑已经结束,所有的状态更新都发送给我了
  2. 我可以开始执行 layout 了

这两点其实很直觉,但是在 RN 语境下实现起来却很有难度,尤其是当状态更新涉及到微任务的时候:

setState(A)
await Promise.resolve()
setState(B)

在浏览器中,这次更新非常直觉:

【宏任务】状态 A 更新
		↓
【微任务】状态 B 更新
		↓
【浏览器】执行绘制任务

这是因为浏览器中依据 HTML Standard 的 event-loop 规定实现了 宏任务 -> 清空微任务队列 -> 更新渲染 的流程

但在 RN 中,却缺乏类似的事件循环系统,这导致 RN 在原始设计上无法处理微任务的更新(背后原因是 JS 与 Native 只能通过 bridge 异步通信,无法同步感知互相的状态)

所以 RN 采用了一个迂回策略:模拟微任务系统 + 在触发 batchDidComplete 之前清空 “微任务”

源码探索

首先我们来看看 JS 侧是怎么向 Native 发送状态更新发送完毕的消息的:

// in MessageQueue.js

callFunctionReturnFlushedQueue(module: string, method: string, args: any[]) {
    this.__guard(() => {
      this.__callFunction(module, method, args);
    });
		// 关键方法,这个方法最后触发了 batchDidComplete
    return this.flushedQueue();
}

我们进一步看看它的实现:

// in MessageQueue.js

flushedQueue() {
  this.__guard(() => {
    // 关键方法
    this.__callImmediates();
  });

  const queue = this._queue;
  this._queue = [[], [], [], this._callID];
  return queue[0].length ? queue : null;
}

__callImmediates() {
  Systrace.beginEvent('JSTimers.callImmediates()');
  if (this._immediatesCallback != null) {
    // 关键方法
    this._immediatesCallback();
  }
  Systrace.endEvent();
}

可以看到,后面关于 queue 处理的方法很简单,重点在于 _immediatesCallback 方法

这个方法的最终实现在:

// in Libraries/Core/Timers/JSTimers.js

/**
 * This is called after we execute any command we receive from native but
 * before we hand control back to native.
 */
callImmediates() {
  errors = null;
  // 关键方法,可以看到这里如果返回 true 会持续执行
  while (_callImmediatesPass()) {}
  if (errors) {
    errors.forEach(error =>
      JSTimers.setTimeout(() => {
        throw error;
      }, 0),
    );
  }
}

/**
 * Performs a single pass over the enqueued immediates. Returns whether
 * more immediates are queued up (can be used as a condition a while loop).
 */
function _callImmediatesPass() {
  // The main reason to extract a single pass is so that we can track
  // in the system trace
  if (immediates.length > 0) {
    const passImmediates = immediates.slice();
    immediates = [];
    
    for (let i = 0; i < passImmediates.length; ++i) {
      _callTimer(passImmediates[i], 0);
    }
  }
  return immediates.length > 0;
}

_callImmediatesPass 中执行的 immediates 其实就是 RN 模拟的 “微任务” 了

_callImmediatesPass 执行后返回 immediates 的长度配合上面的 while 方法可以处理 “微任务中产生微任务” 的场景,保证在这次执行中全部消化掉

至此我们讲完了 “微任务” 是怎么消化的,接下来我们来讲讲 “微任务” 是怎么变成 immediates


首先,RN 的 Promise 用的并非浏览器的实现,它用了一个 fbjs 包的实现:

// in Libraries/Promise.js

const Promise = require('fbjs/lib/Promise.native');

有趣的是,fbjs 套娃了 promise 这个包,promise 这个包有两个 build 版本,主要区别在于 handleResolved 这个方法的实现(一个用的 asap、一个用的 setImmediate)

// in promise/setimmediate/core.js

function handleResolved(self, deferred) {
  // 用了全局的 setImmediate 方法
  setImmediate(function() {
    var cb = self._y === 1 ? deferred.onFulfilled : deferred.onRejected;
    if (cb === null) {
      if (self._y === 1) {
        resolve(deferred.promise, self._z);
      } else {
        reject(deferred.promise, self._z);
      }
      return;
    }
    var ret = tryCallOne(cb, self._z);
    if (ret === IS_ERROR) {
      reject(deferred.promise, LAST_ERROR);
    } else {
      resolve(deferred.promise, ret);
    }
  });
}

接下来的思路就清晰了,我们只需要 mock 这个 setImmediate,用来把 “微任务” 放进 immediates ,最后把它挂到全局上即可:

// in Libraries/Core/Timers/JSTimers.js

/**
 * @param {function} func Callback to be invoked before the end of the
 * current JavaScript execution loop.
 */
setImmediate: function(func: Function, ...args: any) {
  const id = _allocateCallback(
    () => func.apply(undefined, args),
    'setImmediate',
  );
  // 把 “微任务” push 进 immediates
  immediates.push(id);
  return id;
},
// in Libraries/Core/InitializeCore.js

const defineLazyTimer = name => {
  polyfillGlobal(name, () => require('JSTimers')[name]);
};
// 把 setImmediate 挂全局上
defineLazyTimer('setImmediate');

大功告成🥳


最后我们梳理一下整个链路:

  1. 开发者将更新状态的代码放进微任务队列中
  2. 因为 Promise 被 “调包” 了,所以更新状态这个操作最终被放进 immediates
  3. flushedQueue 被触发后,调用了 callImmediates 清空并执行了所有的 immediates
  4. 执行过程中所有更新视图的指令都会被放进 MessageQueue 的 _queue 中
  5. 执行完毕后,一次性把所有的 _queue 发送给 Native 侧,触发 batchDidComplete,执行视图更新