前言
React Native App(后称RN App)的UI由JS端的View tree构成,在App运行时会创建相应的原生View tree。从结果看,这和安卓原生开发时用xml布局文件是一样的,最终结果都是由Java对象构成的View tree。View tree中每个节点必须拥有正确的位置和尺寸数据,才能渲染出正确的界面。安卓原生App渲染流程(测量,布局,绘制)中前两步刚好在做这个工作,那么RN App里渲染流程是怎么样的?RN采用的是Flexbox布局(实现体称为Yoga),这种布局方式如何应用到原生渲染流程中?本文先简单介绍安卓平台原生渲染流程,然后在此基础上着重分析RN在安卓平台的渲染流程。本文将从以下几个方面进行阐述:
1、安卓原生平台的渲染流程概述
2、RN渲染流程介绍
2.1、根据js端view tree,创建平台原生view tree流程概述
2.2、使用Yoga的计算结果,来跑原生渲染流程
3、Yoga布局和原生布局共存,安卓自定义View也可以正常工作
4、总结
本文的主要读者是有安卓基础同学,在有安卓知识的前提下阅读会更容易一些,比如其中的MessageQueue切换,FrameLayout等控件,原生渲染流程等内容将不会有理解负担,能直接过渡到RN部分的阅读。其他读者可能就需要先建立安卓中这些概念的理解。阅读完本文后,你将会理解RN在安卓平台的渲染流程实现原理,对RN App在渲染时都执行了哪些逻辑有一个具体的概念,理解我们开发时写的MRN代码究竟都做了什么,知其然也知其所以然。在遇到问题时也可从流程上进行分析定位,或者对这个流程的某一步进行修改来满足定制化需求。文中难免有纰漏之处,欢迎大家不吝指出,共同学习成长。
参考源码版本:RN:0.59.8,安卓:28。
1、安卓原生App UI渲染流程简介
GUI程序都用View tree来描述界面内容,不管界面多复杂,元素有多少,都可以收纳在这颗树里。View tree很好的提供了渲染所需的必要信息:位置(含尺寸)和颜色。简单来说,有了这俩信息,系统就可以生成图形库(OpenGL ES或者Skia)所需的绘制指令,绘制出由View tree所表达的一帧画面。虽然我们在写View时可以用{ flex: 1 }(react)或者android:layout_width="match_parent"等来指定组件的尺寸,但最终尺寸属性还是需要被计算成具体的数值。
在App启动过程的onResume阶段,系统会触发ViewRootImpl.performTraversals开始渲染流程,这个函数里会依次触发root view的测量、布局、绘制,最后通知系统渲染到物理屏幕上,这个过程如下图所示。测量遍历用于计算每个节点的尺寸,布局遍历时会参考尺寸进行位置摆放,算出位置数据。经过这两步以后,每个节点就拥有了位置和尺寸,接着就可以遍历绘制每个节点的内容了。view tree中父子节点的遍历衔接主要得益于measure/onMeasure,layout/onLayout,draw/onDraw的设计,如下图中measure过程所示,layout和draw同理。
以上就是安卓原生渲染的三个主要步骤,测量和布局其实是为绘制服务,前两步所计算出的位置和尺寸数据在绘制时需要用到,用于生成图形库的绘制指令。这样说来,如果view的位置和尺寸数据已经准备好,测量和布局这两步就可以省去了。这也正是RN在原生渲染流程中的切入点:把view中onMeasure和onLayout的计算逻辑都去掉,同时阻断这两步遍历,通过Yoga来计算位置和尺寸,并将这些数据亲自“交给”view tree中的每个节点,然后只需要一次绘制遍历,就完成了整个渲染流程。下面来看看RN是如何完成这个工作的。
2、RN渲染流程介绍
我们用js写成的view tree势必要翻译成安卓平台的原生view tree,才可以在安卓上正常工作。这个翻译过程并不只是简单的映射而已,RN并不是将映射后的原生view tree直接交给系统,它还接管了测量和布局工作。安卓有自己的布局方式,体现在渲染流程的measure和layout中,RN采用的Flexbox是一种完全不同的布局方式,它如何参与到原生渲染流程中呢?事实上不管哪种布局方式,他们都是为了一个目的:给出view节点的边界数据bounds(left,top,right,bottom),有了边界位置,view的尺寸也就有了,比如width = right - left,因此我们可以猜测两种布局方式的结合点就是View的边界数据bounds,在下面介绍的渲染流程中进行验证。RN在mqt_native线程中执行Flexbox布局计算,计算结果将直接用于渲染流程,省去了主线程中measure和layou的计算量。如果这两个线程是运行的不同的核心上,在执行复杂的布局动画时将会有明显的优势。下面分小节来具体看看这个过程是如何进展的。
2.1、根据js端view tree,创建平台原生view tree流程概述
RN提供的View系列组件,都有相应的原生端组件实现,js端的view tree相当于一个“剧本”,用来描述UI界面,RN会根据这个“剧本”来生成平台原生View tree。对于js端View tree中的每一个节点,都会在native端生成一个ReactShadowNode节点作为对应,同时还会创建一个原生View节点(先不考虑RN的布局优化,可认为他们是一一对应关系)。ReactShadowNode承担了Yoga布局的计算工作,其内部会创建一个YogaNode节点,YogaNode内部再创建c++端的YGNode。YogaNode本身是一个jni承载类,代表的是c++端的YGNode,两者都表示Yoga布局中的一个节点,当Yoga引擎计算完毕后,YGNode中就填充满了尺寸和位置数据,通过jni回设到java端的YogaNode中,留着给原生view使用。最终在native端会生成4颗tree:ReactShadowNode tree, YogaNode tree, YGNode tree, 原生View tree,如下图所示。
RN App会在Activity的onCreate阶段创建ReactRootView(其本质是一个FrameLayout),并由此进入RN的世界。下面我们来看看RN是如何创建native端的4颗tree结构的。在RN Bridge完成初始化后,native端通过runApplication()触发执行我们的js业务代码,根据js端view tree会执行一系列的UIManager.createView, UIManager.setChildren, UIManager.manageChildren等函数。通过RN Bridge,这些函数会在nativeQueue中添加一系列的操作,用于创建ReactShadowNode节点,并把js端view的各属性值保存在节点中,最后形成tree结构。根据之前的介绍,此时也会同步生成YoagNode tree和YGNode tree。这个过程如下图中左侧两个queue所示。
从上图还可以看出,每一次对ReactShadowNode的操作同时还会有相应的原生View操作,以runnable的形式添加到batchUIQueue中(注意它并不是安卓中和线程相关的queue,仅仅是个队列容器)。batchUIQueue的名字非常恰当的描述了它的作用:保存一系列的原生View操作,最后在App主线程中一次性批处理完。所以当ReactShadowNode tree形成时,所有对应的原生View操作(view创建,view属性赋值,addView形成tree等)都添加到了batchUIQueue中,不过此时还不到执行的时机。
js端view节点的属性可以大致分为两类,一类用于直接作用于原生view,比如背景颜色,透明度等等和布局不相关的;另一类主要是flexbox布局相关的属性,比如flex,margin,padding,alignItems等等,这些会通过ReactShadowNode保存在YogaNode和YGNode中,用于Yoga引擎计算。
到目前为止ReactShadowNode tree已经形成,原生view的操作也已经添加到batchUIQueue中等待被执行。那什么时候会执行呢?答案是尺寸和位置数据计算出来以后。在本次js代码执行完之后,jsQueue里会根据是否是endOfBatch来执行onBatchComplete,它会触发在nativeQueue中执行onBatchComplete,其中会调用dispatchViewUpdates,它的工作主要分为3步:
启动Yoga引擎对YGNode tree进行计算,履行Flexbox布局协议,并将计算后的结果递归回设到java端YogaNode节点
根据YogaNode tree中的数据,递归向batchUIQueue中添加runnable:给对应的原生View tree节点设置尺寸和位置(下文还会详细分析)
将batchUIQueue中所有的runnable,交给App主线程执行,所有的这些runnable都在主线程的一个事件循环中执行完
至此,batchUIQueue的所有view操作在App主线程执行完,UI界面也该显示出来了。其中关键代码如下:
```
// ----- file: UIImplementation.java
public void dispatchViewUpdates(int batchId) {
...
updateViewHierarchy();
...
mOperationsQueue.dispatchViewUpdates(batchId, commitStartTime, mLastCalculateLayoutTime); // 3、执行batchUIQueue中所有的view操作
}
protected void updateViewHierarchy() {
...
calculateRootLayout(cssRoot); // 1、调用YogaNode.calculateLayout启用yoga引擎,并将计算结果递归回设到java端的YogaNode tree的每个节点里
...
applyUpdatesRecursive(cssRoot, 0f, 0f); // 2、将YogaNode tree节点的数据,递归设置给原生view,将该操作添加到batchUIQueue中,最后统一执行
}
protected void applyUpdatesRecursive(...) {
...
for (int i = 0; i < cssNode.getChildCount(); i++) {
applyUpdatesRecursive(...); // 递归
}
...
cssNode.dispatchUpdates(...) --> uiViewOperationQueue.enqueueUpdateLayout(...) // updateLayout具体做什么?看下面小节的分析。
}
// ----- file: YogaNode.java
public void calculateLayout(float width, float height) {
jni_YGNodeCalculateLayout(mNativePointer, width, height);
}
// ----- file: YGJNI.cpp
void jni_YGNodeCalculateLayout(
alias_ref<jclass>,
jlong nativePointer,
jfloat width,
jfloat height) {
const YGNodeRef root = _jlong2YGNodeRef(nativePointer);
YGNodeCalculateLayout( // Yoga引擎执行计算,里面是漫长的c++代码,Flexbox布局协议的实现
root,
static_cast<float>(width),
static_cast<float>(height),
YGNodeStyleGetDirection(_jlong2YGNodeRef(nativePointer)));
YGTransferLayoutOutputsRecursive(root); // 将计算结果,以递归方式回设给java端每个YogaNode节点
}
// ----- UIViewOperationQueue.java
public void dispatchViewUpdates(final int batchId, final long commitStartTime, final long layoutTime) {
...
UiThreadUtil.runOnUiThread( // 切换到主线程处理原生View
new GuardedRunnable(mReactApplicationContext) {
@Override
public void runGuarded() {
flushPendingBatches();
}
}
);
}
private void flushPendingBatches() {
...
for (Runnable runnable : runnables) {
runnable.run();
}
}
```
2.2、使用Yoga的计算结果,来跑原生渲染流程
上文提到在Yoga计算完毕后,会递归将YogaNode tree中的数据设置给原生View tree,简单的“设置”二字实在是太过敷衍,本小节来详细看看这个过程。首先看下关键代码:
```
// ----- file: UIImplementation.java
protected void applyUpdatesRecursive(...) {
...
for (int i = 0; i < cssNode.getChildCount(); i++) {
applyUpdatesRecursive(...); // 递归调用
}
...
cssNode.dispatchUpdates(...); // 这种递归方式产生的效果是从叶子节点开始,到根节点的遍历
}
// ----- file: ReactShadowNodeImpl.java
public boolean dispatchUpdates(...) {
...
uiViewOperationQueue.enqueueUpdateLayout( // 添加到batchUIQueue中
getParent().getReactTag(),
getReactTag(),
getScreenX(), // 这些数据都是Yoga计算出来的
getScreenY(),
getScreenWidth(),
getScreenHeight());
}
// ----- file: NativeViewHierarchyManager.java
public synchronized void updateLayout(...) {
...
viewToUpdate.measure( // 看这里
View.MeasureSpec.makeMeasureSpec(width, View.MeasureSpec.EXACTLY),
View.MeasureSpec.makeMeasureSpec(height, View.MeasureSpec.EXACTLY));
...
updateLayout(...) --> viewToUpdate.layout(x, y, x + width, y + height); // 再看这里,Yoga输出给View的,就是简单的left, top, right, bottom
}
```
从上面的代码中可以看到,从YogaNode tree的根节点开始递归处理,但却是反向地将原生View tree对应节点的layout操作添加到batchUIQueue中,即先处理的是叶子结点,最后到根节点。个人分析这里的遍历顺序没有什么分别,top-down或者down-top结果是一样的,毕竟各节点的尺寸和位置已经算好,至于是先设置子节点还是父节点并无区别,最终都是在batchUIQueue中一次性执行完,再进行reqeustLayout,进而执行performTraversals完成渲染,此处若分析的不对还请大神指正。这里的重点是RN亲力亲为的调用了原生View tree里所有节点的measure和layout(仅限和YogaNode tree对应的节点,自定义view group里子节点不在此范围),来完成渲染流程的前两个阶段,将Yoga的计算结果应用进去。上文有提到过,容器节点onMeasure/onLayout会调用子节点的measure/layout,来衔接tree结构父子节点的遍历,这里是否和RN的遍历重复了呢?答案当然是没有重复。RN既然选择亲自遍历所有节点,当然就会有处理:onMeasure和onLayout中的计算逻辑都去掉,并且不调用子节点的measure和layout。代码节选如下:
```
// ----- file: ReactRootView.java RN原生端的根view,用于承载RN所有的界面
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
...
setMeasuredDimension(width, height); // 没有调用子节点的measure,仅履行了安卓的约定调用setMeasuredDimension。
}
protected void onLayout(boolean changed, int left, int top, int right, int bottom) {
// No-op since UIManagerModule handles actually laying out children.
// 这里更简单,什么都不做,上面的注释说的很明白了。
}
// ----- file: ReactViewGroup.java 这个类表示的是js端的View.js,最基本的容器view。测量和布局均没有任何计算,没有触发子节点。
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
...
setMeasuredDimension(MeasureSpec.getSize(widthMeasureSpec), MeasureSpec.getSize(heightMeasureSpec));
}
protected void onLayout(boolean changed, int left, int top, int right, int bottom) {
// No-op since UIManagerModule handles actually laying out children.
}
```
3、Yoga布局和原生布局共存,安卓自定义View也可以正常工作
RN虽然是自己负责衔接安卓渲染流程的前两步,但是接入自定view group并不需要额外做太多,只需跟安卓自定义view group一样重写onMeasure和onLayout,负责计算子节点的尺寸和位置。如下图所示,自定义节点和RN的节点能够完美合作,“各司其职”。
不过事情总是会有一些遗憾,当自定义view group进行requestLayout时会触发view tree的渲染流程遍历,但是RN的view容器并没有在onMeasure/onLayout里衔接遍历,导致自定义view group界面更新不生效。解决方式比较简单,在自定义view group里重写requestLayout,然后手动调用measure和layout,这样自定义view就可以正常工作了。
```
// ----- 某自定义view group.java
@Override
public void requestLayout() {
super.requestLayout();
post(measureAndLayout);
}
private final Runnable measureAndLayout = new Runnable() {
@Override
public void run() {
measure(MeasureSpec.makeMeasureSpec(getWidth(), MeasureSpec.EXACTLY),
MeasureSpec.makeMeasureSpec(getHeight(), MeasureSpec.EXACTLY));
layout(getLeft(), getTop(), getRight(), getBottom());
}
};
```
下面来看一个笔者之前在实际项目中遇到的例子,这个例子是电子答题,主要功能是展示一张题目图片,学生可以在下面手写作答,当答题区域不够时可以增加区域尺寸等等。该功能实现采用的是FrameLayout+自定义ImageView,如下示意图所示,自定义ImageView用于设置题目图片和实现画笔功能,FrameLayout用于移动和缩放ImageView等。初始化时ImageView和FrameLayout尺寸一样,当用户点击增加一屏画布时,在FrameLayout里增加ImageView的高度。这其中没有重写onMeasure和onLayout,FrameLayout和ImageView默认的就够用。
功能在安卓原生侧是正常的,接入到RN以后展示正常,写写画画正常,对画布进行放大缩小(scale)正常,但是点击增加一屏操作却无效。总结一下上述这些现象会发现:
onDraw里的操作执行正常,比如画笔操作,setScale和setTranslationX等
setLayoutParams操作无效,如设置ImageView高度
也就是说尺寸更新无效,绘制操作有效,原因就是当在View里进行invalidate和setLayoutParams时,都会执行requestLayout向上反馈到ViewRootImpl来触发一次渲染流程,其中RN阻断了measure和layout的遍历,没有阻断draw。解决方式就是在FrameLayout.requestLayout中主动触发measure和layout,完成子View的尺寸刷新。另外,由于FrameLayout是作为自定义View接入到RN的,他的尺寸将会受RN来控制,原生侧无需关心。
4、总结
以上就是RN在安卓端UI渲染流程的介绍,主要描述了我们在js端写的view tree,是如何一步一步转化到原生view的,这也体现了RN确实就是native的一面。笔者一直以来就有一个疑惑,安卓原生View本身有大量的属性,比如width,height,padding,margin等等,RN所采用的Flexbox也有很多的属性,flex,padding,alignItems等等,两者的差距还是很大的,这两套属性该如何对接?乍一想还是很头疼的,大量的属性剪不断理还乱,但从RN源码来看,其实两者的衔接点只是view的边界值而已(left, top, right, bottom)。分析一下可以发现,不管是Flexbox还是安卓原生布局,其他属性存在的价值就是为了计算出left,top,right,bottom。RN参与渲染流程的切入点也正是这里,刚好原生view就有一个layout(l, t, r, b)方法来接收这四个值,并且也刚好这个方法就是渲染流程中的一步,一切都恰到好处。另外,RN在一个子线程中来计算View的布局数据,省去了主线程刷新UI时的前两步遍历,减轻了负担,但从整体上来看UI的连贯性未必就有提升,线程之间的配合成本可能也不低。笔者也是在学习过程中,想法难免有欠缺或错误之处,欢迎各位大神提出宝贵建议。