ReactNative设计与实现之一:背景

ReactNative设计与实现之一:背景

接触RN快两年了,也用它做出了一些非常有意思的东西,但都只停留在对RN的使用阶段,而没有深入去了解其内部原理。前一段时间,一个很好的机会,我开始研究RN的源码,在之后的两三个月时间内,我断断续续地看完了RN Android侧与初始化流程有关的代码。虽然这只是RN源码中很小的一部分,仍觉得受益匪浅。

在接触RN之前,我有差不多一年的借助C/C++跨Android与iOS开发的经历。在这次阅读RN源码的过程中,我很惊奇地发现,RN的实现方式与我之前所掌握的跨Android与iOS开发的方式是一致的,由此也引发了一些关于跨平台开发的思考。这里,我将结合此次阅读RN源码的收获与之前跨Android与iOS开发的经验,和大家聊一聊RN的设计与实现。

我本来是想在一篇文章中包含所有想要分享的内容的,写着写着篇幅就变得很长,为方便大家阅读,就将文章拆成了四篇,以系列的形式发出,按照顺序,它们分别是背景实践整体架构以及Android端源码分析

希望通过这个系列的分享,能让大家对RN有一个更好、更客观的认识,下面开始第一部分:背景。


跨平台是软件开发中的一个重要概念,本质上,跨平台开发就是为了增加代码复用,减少开发者对各平台差异适配的工作量,降低开发成本。在提高业务专注度的同时,提供比web更好的体验。通俗了讲就是:省钱、偷懒。RN是Facebook推出的一套针对移动端APP开发的跨平台解决方案。

本文将从代码复用的视角,向大家介绍一些跨平台开发的背景知识,并将RN与其他跨平台解决方案做一些简单的对比。

一、Android与iOS做代码共享

现假设我们要开发一个处理音视频的APP,需同时支持Android和iOS两端,它能对音视屏文件进行编解码、格式转换等操作。此时,我们有: (1) C/C++编写的核心功能库FFmpeg,它负责具体的编解码、格式转换等操作;(2) Android与iOS的平台SDK以及它们各自的UI库。为了能够使用FFmpeg提供的音视屏处理能力,我们首先需要做一层跨语言函数调用的封装。

iOS端的同学比较幸福,因为OC(Objective-C)作为C语言的扩展,可以直接调用C/C++函数。Android端稍为麻烦一点,Java语言并非C或C++语言的扩展,它不能直接调用C/C++定义的函数。为了能够调用C/C++函数,Java需要借助JNI。JNI即Java Native Interface,它是Java虚拟机(JVM)提供的一套能够使运行在JVM上的Java代码调用native程序、以及被native程序调用的编程框架。在Android平台上开发native功能,还需要借助NDK(Native Development Kit),它主要提供对native部分代码的编译的支持。注意,此处的native是相对于Java语言来说的,即C、C++或汇编程序;需要与RN中的native区分开来,RN中的native是相对于JS(JavaScript)来说的,Java与OC都属于native。至此,我们完成了跨语言函数调用的封装,如下图所示:

接下来就是业务流程与交互界面的开发了,这部分由Android与iOS端的同学分别完成。至此,我们完成了音视频处理APP的所有开发任务,整个项目的结构大致如下图所示:

上面的方案它很有效,也能很好地满足我们的需求,但是它还不够完美。从代码复用的角度来看,在上述方案中,除核心库FFmpeg外,Android与iOS几乎没有复用任何代码。

我们应该知道,核心库FFmpeg它提供了基础的编解码、格式转换等能力,但它却不支持我们特定的业务流程。为此,我们需要在FFmpeg提供的基础功能上,进一步开发我们特定的业务流程。现假设有这样一个需求:有十个wav格式的视频文件,需要将第1、3、5、7、9个视频转换为mp4格式,第2、4、6、8、10个视频转换为rmvb格式。按照上述方案,Android端同学用Java实现了这一流程,iOS端同学需要用OC再实现一遍。

通过对流程本身的分析我们可以发现:流程它是与平台、与语言无关的。那么,针对同一流程,我们为什么要用不同的语言来实现多次呢?关于这样做的缺点,大家应该都很清楚,网上也有很多相关的讨论,在此就不再赘述了。

针对上述项目结构的缺点,我们可以做如下调整:(1) 将特定业务流程的实现逻辑下沉到C/C++层,(2) Android与iOS层通过跨语言的封装来调用这些业务流程。此时整个项目的结构如下图所示:

第二种方案相对于第一种,它通过将业务流程下沉到C/C++层,实现了较大程度的代码复用。这样已经很好了,但能不能更好呢?此时,Android与iOS的同学依然需要分别去实现各自端的UI,我们能不能复用UI部分的实现呢?

我们先来分析一下UI系统的特定。首先,UI系统需要有组件库,每个组件都向用户传达特定的信息,并接收特定的用户操作;不同的组件传递不同的信息,接收不同的用户操作。例如按钮组件,它向用户传达点击的信息,并接收用户的点击操作。所谓HTML标签的语义化,表达的就是这个意思。简单扩展一下HTML标签语义化的概念,我们可以提出组件的语义化这一概念。组件的语义是与平台无关的,它是程序开发者与程序用户之间所达成的一种共识。

Android与iOS有各自的组件库,它们采用不同的语言实现,有各自特定的内部机制;但是,因为需要向用户传递同样的信息,它们绝大部分的组件都是可以一一对应的,例如Android的Button和iOS的UIButton

其次是布局,布局即是指定各个组件在屏幕上的位置的过程,它确定了组件与屏幕边框、组件与组件之间的相对位置。例如在屏幕的左上角放置一个按钮,按钮距离屏幕顶部边框10个像素单位,距离屏幕左侧边框15个像素单位。显然,这也是一个与平台、与语言无关的过程。

既然Android与iOS的组件可以认为是一一对应的,且布局是一个与平台、与语言无关的过程,那么我们可不可以将布局的过程抽离出来,作为一个控制中心,我们通过这个控制中心发与出布局相关的指令,然后由一个中间转换系统,根据当前的宿主环境,将这些指令转换为平台相关的组件与布局语法呢?这样,我们就可以跨平台复用UI与业务逻辑了。如下图所示:

在上图中,UI逻辑统一的组件与布局两部分组成了控制中心,我们通过它指定需要在屏幕上显示的组件以及该组件在屏幕上显示的位置,同时它还负责处理用户与组件之间的交互。理论上,控制中心可以使用任意语言来实现。中间转换系统一般会涉及到跨语言调用和对平台相关组件以及组件的属性和方法的封装。最底层的UI系统负责具体的渲染操作。

上述流程中最核心的概念是控制,我们不是要实现一个跨平台的UI系统,我们要实现的是一个借助各平台既有的UI系统的跨平台UI控制系统。RN就是这么做的,它的控制中心由JavaScript语言实现,然后通过由NativeModule、C++ Bridge等组成的中间转换系统,将布局指令翻译成平台相关的组件与布局语法。简而言之,RN是通过JS来控制native的渲染,请注意,RN的JS层代码是控制native渲染而不是负责渲染。

而最近比较火的Flutter,与RN不同,它采用的思路是实现一个跨平台的UI系统。采用自实现的UI系统而不使用平台自身UI系统的一个缺点就是:安装包的体积会变大非常多。这里有一篇对比RN与Flutter的文章:React Native VS Flutter评测,感兴趣的同学可以看一下。

二、在Android与iOS上支持JavaScript

我们都知道,在Android上运行的是Java或者Kotlin程序,iOS上则是OC和Swift。那么,如何在Android与iOS上运行JS程序呢?目前有两种主流的实现方式:(1) 利用系统的浏览器组件,即Android上的WebView和iOS上的UIWebView,(2) 编译并集成一个全功能的JavaScript引擎。

RN是通过第二种方式来支持JS的。采用第一种方式来支持JS的应用主要有Adobe PhoneGap和Apache Cordova,它们也是目前主流的跨平台解决方案之一。与RN和Flutter不同的是,PhoneGap与Cordova是借助浏览器的跨平台特性来实现跨平台的。

下面将对针对RN与PhoneGap、Cordova这两种跨平台方案做一个简单的对比。

1. React-Native vs. Cordova, PhoneGap

我们将从JS的运行环境UI的渲染Native扩展的实现以及JS与Native侧的通信这四个方面将RN与Cordova, PhoneGap进行了对比,如下表所示:

Cordova, PhoneGap利用了系统的浏览器组件,其好处是很容易实现,但是它不灵活且效率低下。Android上的WebView提供了一个叫addJavascriptInterface的方法,它可以将Java类插入到JS的上下文中,但是它只支持传递原始数据类型(primitive data type),这限制了API的设计。同时这个方法也不稳定,由issue #12987可知,它在Android 2.3的模拟器以及一些真实设备上可能会导致崩溃。而iOS上的情况更加糟糕,UIWebView没有公有API来支持从JS到OC的直接交互,你必须使用UIWebView的私有API来实现与 addJavascriptInterface相同的功能(最新的WKWebView提供了一个更好的解决方案,它借助WKUserContentController的`add`方法,向Webview添加一个WKScriptMessageHandler,从而实现JS到OC的交互,详见这里)。利用系统浏览器组件的另一个缺点是开发者们不得不使用回调的方式来获取JS API的返回值,这对于开发游戏程序来说是复杂且低效的。

RN中的JS代码是直接运行在JS引擎上的,故不存在系统浏览器组件的兼容性问题。对于UI的渲染,JS代码只负责指定需要渲染的组件以及各组件之间的相对位置,而实际的渲染工作由native侧完成,能达到接近原生的渲染效果。RN的native扩展通过它的NativeModule机制来实现,可以支持Map、Array等复杂类型参数的传递。RN中与native相关的组件、包括官方提供的,都是通过NativeModule来实现的。最后,RN中利用C++实现了一个桥,通过这个桥,支持JS与原生端的双向通信交互,它在Android上的表现是Java与JS代码的相互调用,在iOS上则是OC与JS代码的相互调用。关于这个C++ Bridge和NativeModule,会在系列文章第四篇《ReactNative设计与实现之四:Android端源码分析》中详细介绍。

2. JS引擎的选择

下一个需要解决的问题是,如何选择JS引擎?筛选的指标主要有性能和兼容性。目前主流的JS引擎有JavaScriptCore,SpiderMonkey,Chrome V8和Rhino,它们在Android与iOS上的兼容性,以及其他情况如下表所示:

从兼容性的角度来看,首先排除Chrome V8和Rhino。Rhino由Java编写,不支持iOS平台;而Chrome V8只兼容越狱后的iOS设备。

JavaScriptCore和SpiderMonkey在性能等方面基本相同,RN使用JavaScriptCore作为它的内置JS引擎。JSC是Webkit引擎的一部分,iOS系统会默认包含JSC,iOS程序不需要做任何特殊处理就可以直接使用JSC API,这大概就是RN选择JSC作为JS引擎的原因。而在Android上,就需要手动编译并继承JSC,因此Android上的每个RN程序都包含一份独立的JSC。

三、三端代码共享

三端是指Android,iOS与Web这三端。在Web端,我们利用JS来调用DOM API,以此来控制浏览器对界面的渲染,在这里,JS本身也不负责渲染,具体的渲染工作由浏览器完成。这与上文中描述的RN的渲染行为是一致的。对于这三端的代码复用,其整体结构大致如下图所示:

目前,React与RN就可以在三端上实现绝大部分JS代码的服用。

四、小结

通过上面的讨论,我们了解到,RN是通过提供一套跨平台的UI控制系统来实现跨平台的,RN的JS层只负责指定需要渲染的UI,而具体的渲染工作由各平台的UI系统完成。同时,RN为了在Android与iOS上支持JS程序,编译并集成了一个全功能的JS引擎,即JavaScriptCore。最后,RN的这种跨平台思路还可以扩展到Web端,实现跨三端的代码共享。

在系列的下一篇文章《ReactNative设计与实现之二:实践》中,我们将通过一些具体的例子,来看看RN与原生开发的异同点。

编辑于 2018-10-09

文章被以下专栏收录