前言
最近由于工作的关系需要研究一下ReactNative的源码。下面简单说说作为一个iOS开发者看完代码的感受,以及介绍rn代码中一些比较关键的点。
注意:以下内容基于ReactNative 0.44.3
第一印象
按着官网的教程很容易的就创建了一个rn demo。迫不及待用xcode打开ios目录下的.xcodeproj
文件。第一感受就是庞大,复杂。说实在的,rn是我看到过的最庞大的项目之一了。
可以看到整个项目依赖了若干个工程,粗略的过了一遍,最为核心的部分是React这个工程,主要实现native到js的通信,渲染布局以及一些通用组件。网上的大多数资料也都是围绕这个工程展开的。
在AppDelegate.m
中可以看到,rn 是从创建一个RCTRootView开始运作的,而创建RCTRootView的第一步就是需要创建一个RCTBridge对象。实际上,他们也是整个rn项目中最为关键的两个部分,他们的职责分别是:
- RCTBridge:native与js交互的桥梁,实现native与js的互相调用
- RCTRootView:native组件的视图容器。以及rn 程序启动的入口。
RCTBridge
RCTBridge只是jsBridge对外暴露的壳,实际上,RCTBridge的核心实现有两种,分别是BatchedBridge和CxxBridge,Facebook官方表示后续BatchedBridge将会逐步被CxxBridge取代。但是替换的原因上网找了一圈也没找到,简单看过CxxBridge的实现,似乎线程模型有所改变,C++实现效率上应该也会高一些(具体原因欢迎知道的同学下方评论告知,万分感谢)上面说到,RCTBridge的主要作用是承载native和js的交互,这里放一张经典的 rn 通信模型:
接下来我们就来分阶段的看看rn通信的具体流程~
BatchedBridge的初始化
以下是BatchedBridge的初始化时序图,初始化过程中的一些耗时操作均是在com.facebook.react.RCTBridgeQueue
这个并发队列中进行的。
JsBundle 加载
JsBundle的加载流程比较简单,开发者可以选择实现RCTBridge delegate的loadSourceForBridge:onProgress:onComplete:
方法或loadSourceForBridge:withBlock:
来自定义bundle的加载流程。利用这个代理我们可以实现jsBundle的服务端加载等离线包逻辑。
rn默认的包加载逻辑实现比较简单,优先通过传入的bundle路径去同步读取文件数据,如果读取不到就会创建一个异步task进行网络请求和包加载。需要注意的是jsBundle的包类型有3种:
- StringBundle:普通的字符串jsBundle
- RAMBundle:random access jsBundle,实际上就是在js执行的时候按需加载各个部分的js代码,具体实现可以参考
[RCTJSExcutor registerNativeRequire]
- BCBundle:字节码 bundle,应该是js编译过后得到的二进制数据。
Native模块接口暴露及注册
rn定义并实现了native向js 暴露模块和方法的协议。当js调用native module时,jsBridge会提供一个native模块对应的js对象,这个对象会包含native模块对js暴露的方法。而js对native模块的操作就是对这个js对象的操作。
模块初始化的过程是从[RCTBatchedBridge initModulesWithDispatchGroup:]
方法开始的,这个方法中完成了与native模块一一对应的RCTModuleData对象的实例化,下面是module实例化的关键循环。
for (Class moduleClass in RCTGetModuleClasses()) {
// 略去非关键部分
// Instantiate moduleData (TODO: can we defer this until config generation?)
moduleData = [[RCTModuleData alloc] initWithModuleClass:moduleClass
bridge:self];
moduleDataByName[moduleName] = moduleData;
[moduleClassesByID addObject:moduleClass];
[moduleDataByID addObject:moduleData];
}
循环内容是RCTGetModuleClasses()
的返回值,查看代码发现这实际上是一个静态数组RCTModuleClasses
,而对这个数组进行操作的的方法只有一个RCTRegisterModule(Class moduleClass)
。再进一步的,通过搜索代码发现,这个函数唯一的调用是在RCT_EXPORT_MODULE
宏定义内。这不就就是rn模块暴露的宏么。
#define RCT_EXPORT_MODULE(js_name) \
RCT_EXTERN void RCTRegisterModule(Class); \
+ (NSString *)moduleName { return @#js_name; } \
+ (void)load { RCTRegisterModule(self); }
可以看到,这个宏会自动为类添加moduleName方法,并将模块名作为返回值,同时会在类的load
方法中去注册自身。这样一来,每一个需要暴露给js的模块就在不经意间向Bridge完成模块的注册。同理,在rn中native模块的方法,属性,常理等信息的暴露都是通过rn提供的宏。例如模块的方法暴露是通过RCT_EXPORT_METHOD
。
JSCExecutor 初始化
JSCExecutor 即js代码的运行环境。rn主要是使用JSCore作为Js的执行引擎,不过bridge这层对JsCore并没有直接依赖,而是通过宏来进行了解耦合,并且支持自定义JsExecutor(如使用v8作为Js执行引擎实现自定义的JSCExecutor)。JSCExecutor的初始化入口为[JSCExecutor setup]
,总的来说做了两件事:
- 创建js执行的上下文环境
- 向js上下文中注入js与native通信的方法
JSCExecutor初始化过程中向JsContext中注入了最基础的几个native方法用于js与native的通讯:
- nativeRequireModuleConfig:js获取native module配置表
- nativeFlushQueueImmediate : js触发native进行队列消息处理
- nativeCallSyncHook:同步调用
这几个方法的具体调用时机会在下面详细介绍。
创建Module配置表
与JSCExecutor初始化同时进行的还有module配置表的创建过程,这一步的主要目的是将所有native module的信息收集起来,并且生成配置表。最后注入到JSCExecutor当中。配置表的创建入口是[RCTBatchedBridge moduleConfig]
,可以看到方法逻辑就是简单的将moduleData.config
添加到数组中并返回,而这个config就是其中关键,链接到[RCTModuleData config]
。这个方法就是收集native module的关键方法,module信息的收集主要包括:
- 搜集常量信息,通过
constantsToExport
方法读取。 - 搜集method信息(也就是前面说到模块通过宏暴露方法信息)并实例化对应的RCTModuleMethod对象
- 对method进行分类,这里有两类,一类为promise方法,一类是同步方法。区分办法很简单,promise方法需要使用到
RCTPromiseResolveBlock
和RCTPromiseRejectBlock
的block,所以只需要检查方法定义是否包含RCTPromise字段就行了。
JsModule 初始化:
在JSExcutor和native module配置表都准备完毕之后,配置表会被注入到了JsExcutor当中,具体执行的逻辑在 [RCTBatchedBridge injectJSONConfiguration:onComplete:]
也就是说main.jsbundle的代码被执行时,js的上下文中已经包含了module配置表信息,其中每一个module的结构都遵循下面的规律:
[moduleName,constants,methods,promiseMethods,syncMethods]
具体结构如下(可以在Chrome或是Safari的interceptor查看变量__fbBatchedBridgeConfig):
在工程目录下node_modules/react-native/Libraries/BatchedBridge
下的NativeModule.js
可以找到Js上下文环境中初始化module的代码,同文件夹下还包含了与native bridge通信的另外两个关键的js文件:MessageQueue.js
、BatchedBridge.js
。这3个js文件在执行main.jsbundle时会被执行,他们负责创建js端的bridge和初始化js module。来看下js module的创建逻辑:
// NativeModule.js 中的genModule方法,非重要部分已略去
// module初始化
function genModule(config: ?ModuleConfig, moduleID: number): ?{name: string, module?: Object} {
// 解析配置信息
const [moduleName, constants, methods, promiseMethods, syncMethods] = config;
const module = {};
methods && methods.forEach((methodName, methodID) => {
const isPromise = promiseMethods && arrayContains(promiseMethods, methodID);
const isSync = syncMethods && arrayContains(syncMethods, methodID);
const methodType = isPromise ? 'promise' : isSync ? 'sync' : 'async';
module[methodName] = genMethod(moduleID, methodID, methodType);
});
Object.assign(module, constants);
return { name: moduleName, module };
}
// method 初始化
function genMethod(moduleID: number, methodID: number, type: MethodType) {
let fn = null;
if (type === 'promise') {
fn = function(...args: Array) {
return new Promise((resolve, reject) => {
// promise 是直接走BatchedBridge.enqueueNativeCall的
BatchedBridge.enqueueNativeCall(moduleID, methodID, args,
(data) => resolve(data),
(errorData) => reject(createErrorFromErrorData(errorData)));
});
};
} else if (type === 'sync') {
fn = function(...args: Array) {
// sync方法直接调用初始化时jsc注入nativeCallSyncHook方法
return global.nativeCallSyncHook(moduleID, methodID, args);
};
} else {
fn = function(...args: Array) {
// 其余均认为是异步调用走BatchedBridge.enqueueNativeCall
BatchedBridge.enqueueNativeCall(moduleID, methodID, args, onFail, onSuccess);
};
}
fn.type = type;
return fn;
}
这里面比较关键的Js module的方法声明,可以看到:
- promise方法的声明调用了BatchedBridge.enqueueNativeCall
- sync方法的声明调用的是global.nativeCallSyncHook
- 其余方法调用均为认为是异步也是通过BatchedBridge.enqueueNativeCall
Native与Js的数据通信
到目前为止,js端和native端的module都已经准备完成了,接下来bridge将开始处理js和native相互调用,这一部分算是RCTBridge的核心部分了,下面是通讯时序图:
Js 调用Native:
以js调用UIManager模块的measureLayout方法为例,js调用native的调用栈如下:
----------------------------------以下为Js端调用栈------------------------------------------
UIManager.measureLayout(params,onSuccess,onFail)
BatchedBridge.enqueueNativeCall(moduleID, methodID, args, onFail, onSuccess)
MessageQueue.enqueueNativeCall(moduleID, methodID, args, onFail, onSuccess) // 保存callback
MessageQueue_queue[PARAMS].push(params) //调用入队,如果距上一次刷新消息队列的时间间隔达到阈值,则触发更新
global.nativeFlushQueueImmediate(queue)
----------------------------------以下为Native端调用栈--------------------------------------
context[@"nativeFlushQueueImmediate"] block invoke
[RCTBatchedBridge handleBuffer:batchEnded:]
[RCTBatchedBridge handleBuffer:] // 批量处理队列中的调用消息,把调用分发到各个module对应的queue中处理
[RCTBatchedBridge callNativeModule:method:params:] // 找到nativeModule对应的方法并执行
[RCTBridgeMethod invokeWithBridge:module:arguments:] // 开始执行module对应的方法
[RCTBridgeMethod processMethodSignature] // 初始化这次调用的invocation对象 这个方法大量使用到runtime
[UIManager measureLayout] //目标方法真正被执行
RCTPromiseResolveBlock block invoke // 方法逻辑执行完毕后回调被执行
[RCTBatchedBridge enqueueCallback:args:] // 通过bridge回调结果
[RCTBatchedBridge _actuallyInvokeCallback:arguments:]
[RCTJavaScriptExecutor invokeCallbackID:arguments:callback:] //执行invokeCallbackAndReturnFlushedQueue
[RCTJavaScriptExecutor _executeJSCall:arguments:unwrapResult:callback:] // 使用jscontext触发js端处理回调
----------------------------------以下为Js端调用栈------------------------------------------
MessageQueue.invokeCallbackAndReturnFlushedQueue()
MessageQueue.__invokeCallback() //触发js端保存的callbck回调
Native 调用Js:
以native调用AppRegistry模块的的runApplication方法为例子,native调用js的调用栈如下:
----------------------------------以下为Native端调用栈--------------------------------------
[RCTBatchedBridge enqueueJSCall:method:args:completion:] //开始Js模块调用
[RCTBatchedBridge _actuallyInvokeAndProcessModule:method:arguments:] //执行模块方法
[RCTJavaScriptExecutor callFunctionOnModule:method:arguments:callback:]
[RCTJavaScriptExecutor _callFunctionOnModule:method:arguments:returnValue:unwrapResult:callback:]
[RCTJavaScriptExecutor _executeJSCall:arguments:unwrapResult:callback:] //区分是否有返回值,调用不同的方法
----------------------------------以下为Js端调用栈------------------------------------------
MessageQueue.callFunctionReturnResultAndFlushedQueue() //js端处理调用消息
MessageQueue.__callFunction() // 找到对应js module,执行方法并回调结果
----------------------------------以下为Native端调用栈--------------------------------------
onComplete block invoke
就0.44.3的rn bridge的通信模型其实是native端和js端分别维护了一个消息队列,各端的调用以及callback消息都会被存储在队列中。有意思的是,两端的数据通信并不是完全分离的调用,在native端对js端的一次调用中,js端callback的同时还会携带上js队列中的数据,而native在收到回调的时候,不仅会将结果返回给调用方,还会顺带处理js端发过来的其他消息。这样消息调用的循环就建立了起来。
同步调用:
同步调用相对于异步而言就简单了不少,nativeCallSyncHook的实现实际上就是通过runtime调用native模块。并且同步调用并不会切换到module的queue,而是直接在js线程进行处理,达到阻塞js端的效果。
RCTRootView
RCTRootView是rn渲染的关键,上面已经讲到RCTJsBridge实现了native module与js module之间的相互调用。在这个基础之上,我们写的React代码将最终被渲染成Native布局,接下来看看具体实现。
React.js与ReactNative的桥接
用过react.js的同学应该都听说过vDom,如果没听过,请先研究下这篇文章:virtual-dom原理与简单实现。
目前react将核心渲染部分抽离,定义了一套渲染需要的API标准(详见ReactFiberHostConfig),理论上所有的平台只要实现react标准的API就能够对接上react的vDom实现。例如:
- Web的实现为ReactDOMHostConfig.js。
- ReactNative的实现为ReactNativeHostConfig.js。
- ReactART的实现为ReactARTHostConfig.js。
重点关注一下ReactNativeHostConfig.js的实现,我在其中发现了这样一段代码:
// Modules provided by RN:
import UIManager from 'UIManager';
通读了下代码文件,这个js文件里面基本上是对React API标准的实现。其中大量使用到了UIMananger这个类,而且在上面的代码注释中写道,这个Modules由ReactNative提供。也就是说,React的dom diff的结果最终是通过UIManager作用到ReactNative上的。
UIManager
RCTRootView的初始化过程中会注册3个通知,比较重要的是RCTJavaScriptDidLoadNotification
,在js load结束后,会创建RCTRootContentView ,并且执行runApplication方法。其实就是创建承载内容视图的view并开始运行app的逻辑。而在RCTRootContentView的实例化方法中,我们又一次见到了rn渲染的关键module:UIMananger。
UIManager的初始化过程会获取所有继承至RCTViewManager的所有module,并将其保存在_componentDataByName的字典中。既然是module自然有对js提供的method:
注意:Native module的初始化逻辑基本都是卸载setBridge: 方法当中的,setBridge会在module实例化的时候由BatchedBridge调用。
不难发现,UIManager提供的这些方法都是用于操作view的,譬如创建和移除view,调整view的关系,设置view的属性等。而这些其实就是dom API的oc实现。rn通过UIManager实现了dom API的子集。有了这些API,js端就能够像操作dom一样操作native view tree。
下面分析下最常用的两个UIManager的API接口实现:
RCT_EXPORT_METHOD(createView:viewName:rootTag:props:
大致逻辑如下:
RCT_EXPORT_METHOD(updateView:viewName:props:)
大致逻辑如下:
看到这里很自然会产生这样的疑问:
- 什么是shadowView?
- UIBlock什么时候被执行?
ShadowView tree
上面个讲到的两个API都同时操作了view和shadowView,而在简单查看了所有的UIMananger暴露的API接口后,我发现所有的API都会对view和shadowView进行操作。到底什么是shadowView呢?在RCTShadowView.h
的注释中,我找到了答案:
/**
ShadowView tree mirrors RCT view tree. Every node is highly stateful.
- A node is in one of three lifecycles: uninitialized, computed, dirtied.
- RCTBridge may call any of the padding/margin/width/height/top/left setters. A setter would dirty
the node and all of its ancestors.
- At the end of each Bridge transaction, we call collectUpdatedFrames:widthConstraint:heightConstraint
at the root node to recursively lay out the entire hierarchy.
- If a node is "computed" and the constraint passed from above is identical to the constraint used to
perform the last computation, we skip laying out the subtree entirely.
*/
@interface RCTShadowView : NSObject
简单翻译一下:
shadowView tree 和RCT view tree一一对应,所有节点都是有状态的
1.每个节点有3种生命周期状态,uninitialized(未初始化),computed(计算完成),dirtied(未计算)
2.Bridge将可以随意调用shadow view的setter方法设置属性,这会导致节点变成一个dirtied节点
3.每当bridge的批处理结束,就会调用collectUpdatedFrames:widthConstraint:heightConstraint。从而触发从根节点开始的递归布局计算
4.如果一个节点本身处在computed状态,并且父节点的约束内容和上次相同,则会略过这个节点
接着看RCTShadowView的代码,发现shadowView持有一个YGNode,YGNode是啥?上两篇资料:
Yoga 官网
如何评价 Facebook开源的 YOGA?
一句话总结,Yoga是跨平台的FlexBox实现,主要用来实现视图布局。其实不难理解,我们写的js代码通常使用css来进行布局描述,iOS系统显然无法处理css描述的布局信息,这中间就需要Yoga这样的布局框架来进行转换。所以在rn中,每一个shadowView都持有一个YGNode,用于进行布局描述,并且在必要的时候转换成iOS系统能够理解的布局数据(iOS即是frame)
Js布局信息=>shadowView
上面简单介绍了下shadowView tree。接下来看看js传到native的布局信息是如何作用到shadowView上的。继续来看UImanager的updateView接口实现,关键代码在于setProps:forShadowView:
,整个更新过程总结下来做了下面几件事:
设置shadowView的属性
循环每一个需要设置的属性
获取设置属性需要的block,如果没有则创建一个并保存
执行设置属性的block
将一个设置view属性的block存入UIBlocks中,block逻辑和设置shadowView的相同
这里有一个疑问,既然属性都会直接设置在view上,那么为什么还需要shadowView呢,后来我在RCTViewManager的实现中发现了秘密。
RCTViewManager中定义了很多向Js暴露的属性(和module暴露方法一样,rn为暴露属性提供了宏RCT_EXPORT_VIEW_PROPERTY
和RCT_EXPORT_SHADOW_PROPERTY
)而js端也是通过设置这些属性来控制native view的布局的。这些属性中所有的和布局相关的属性如top,left全部是shadowProperty。而与布局无关的属性如shadowColor,borderColor 等属性都属性ViewProperty。也就是说,布局相关的属性都存储在shadowView中,反之存储在view中。这也和shadowView中持有YGNode的相互吻合。另外我们在createPropBlock:isShadowView:
中可以可以看到,当属性值在当前的view上无法找到时,会直接返回一个空的block。也就是说js,虽然在UIManager的dom API中同时操作了shadowView和view,但实际上只有对shadowView生效的属性才会被设置在shadowView上(即布局属性)view也一样。所以updateView的实际逻辑其实是:
设置shadowView的属性
循环每一个需要设置的属性
如果属性是shadowView持有的,那么对shadowView进行设置,如果没有则跳过
将一个设置view属性的block存入UIBlocks中block逻辑和设置shadowView的相同
ShadowView布局信息=> view
上面说到,UIMananger的API会将布局相关的属性保存在shadowView中,那么这些布局信息如和作用到view上呢。关键就在于[UIManager _layoutAndMount]
:
// 提供给所有的Component在布局前只是逻辑的机会
for (RCTComponentData *componentData in _componentDataByName.allValues) {
RCTViewManagerUIBlock uiBlock = [componentData uiBlockToAmendWithShadowViewRegistry:_shadowViewRegistry];
[self addUIBlock:uiBlock];
}
// 进行layout
for (NSNumber *reactTag in _rootViewTags) {
RCTRootShadowView *rootView = (RCTRootShadowView *)_shadowViewRegistry[reactTag];
[self addUIBlock:[self uiBlockWithLayoutUpdateForRootView:rootView]]; // 添加一个用于layout的UIblock
[self _amendPendingUIBlocksWithStylePropagationUpdateForShadowView:rootView]; // 添加用于更新view背景色属性的UIBlock
}
// 主线程通知节点bridge处理完毕
[self addUIBlock:^(RCTUIManager *uiManager, __unused NSDictionary *viewRegistry) {
for (id node in uiManager->_bridgeTransactionListeners) {
[node reactBridgeDidFinishTransaction];
}
}];
for (id observer in _uiManagerObservers) {
[observer uiManagerWillFlushUIBlocks:self];
}
// 开始执行UIBlocks队列中的所有block
[self flushUIBlocks];
具体的调用逻辑如下:
- UIManager调用
uiBlockWithLayoutUpdateForRootView
获取更新布局的UIBlock - 从rootShadowView开始递归收集所有子view的布局数据变化,并生成布局产生变化的数组,关键逻辑在
[RCTRootShadowView collectViewsWithUpdatedFrames]
和 - 生成UIBlock处理需要重置布局的view,block中区分了是否进行动画等逻辑,最终逻辑是设置view的frame。
- 递归搜集从rootView到所有子节点的背景色属性设置的block。如果view本身没有背景色并且父节点有则继承父节点。关键逻辑在
processUpdatedProperties:parentProperties:
另外值得注意的一点是,所有的frame计算操作都是在UIManangerQueue队列中进行的,而非主线程,主线程做的只是最终设置计算好的frame属性,这样能够主线程的绘制效率。
结语
移动端跨平台解决方案从最初的H5,到后来的Hybrid,再到如今的rn,weex,还有最近大火的Flutter。人们依旧在努力的寻找跨平台的最优解。rn开源至今的这三年也是快速发展迭代,到今天已经是一个庞然大物了。就我个人而言,rn是一个十分优秀的开源框架,虽然我不是一个rn的使用者,但其中涉及到的设计思以及大量的技术细节依然值得学习和借鉴。