React Native

React Native

目前我们常用的移动端应用开发方式主要为原生方式、混合开发这两种,其中原生开发运行效率高,流畅,用户体验好,可以做各种复杂的动画效果。不过我们需要去掌握不同开发平台上特定的开发语言与内建的组件框架,譬如在Android开发中开发者需要掌握Java,而iOS开发中开发者需要掌握Objective-C或者Swift;并且由于平台之间的独立性,代码无法在其他平台上运行,无法做到跨平台。而传统混合开发方式则以CordovaIonic为代表,定义好原生功能与Web界面之间的协议,拦截特定的URL Schema进行原生功能的调用,应用则调用Web提供的JavaScript方法,将数据回传给Web界面。这种方式可以满足一套代码到处运行的目标,不过受限于UIWebView等容器本身的限制,其性能体验与原生应用不可同日而语。实际上无论哪一种开发方式都致力于解决如下几个问题:找到一种能达到或者接近原生体验的开发方式、找到一种一套代码能在各个平台上运行,达到代码复用的目的、能够以热更新或者类似的方式进行快速问题修复。

随着ReactWeb领域取得的巨大成功,Facebook继续推出React Native以创建接近原生性能的跨平台移动应用,其倡导的Learn OnceWrite Anywhere的概念同时兼顾了性能与快速迭代的需求。React的核心设计理念其提供了抽象的、平台无关的组件定义范式,然后通过react-dom等库将其渲染到不同的承载体上;这些承载可以是服务端渲染中的字符串,或者客户端渲染中的DOM节点。在React Native中,我们只需要了解React组件定义规范与语法,然后利用React Native这个新的渲染库将界面渲染到原生界面组件中。在未来的客户端开发中,负责与用户交互以及存储这一部分建议采用原生的代码,而对于逻辑控制这边,建议是采用JavaScript方式实现。

React Native本质上是用JSX的语法风格编写原生的应用,它本质上还是跨平台编译性质的,并没有提供完整的类似于WebView那样的上下文,并且大量的HTML元素也是不可以直接应用的。React Native只是借用了HTML的语法风格,并且提供了JavaScript与原生的桥接。React Native使用了所谓的Native Widget APIs来调用底层的操作系统相关代码,并且处于性能的考虑它会异步批量地调用原生平台接口,其整体架构如下所示:

快速开始

  • Architecture(应用架构)

当使用react-native命令创建新的项目时,调用的即https://github.com/facebook/react-native/blob/master/react-native-cli/index.js这个脚本。当使用react-native init HelloWorld创建一个新的应用目录时,它会创建一个新的HelloWorld的文件夹,包含如下列表:

HelloWorld.xcodeproj/

Podfile

iOS/

Android/

index.ios.js

index.android.js

node_modules/

package.json

React Native最大的卖点在于(1)可以使用JavaScript编写iOS或者Android原生程序。(2)应用可以运行在原生环境下并且提供流畅的UI与用户体验。众所周知,iOS或者Android并不能直接运行JavaScript代码,而是依靠类似于UIWebView这样的原生组件去运行JavaScript代码,也就是传统的混合式应用。整个应用运行开始还是自原生开始,不过类似于Objective-C/Java这样的原生代码只是负责启动一个WebView容器,即没有浏览器界面的浏览器引擎。

而对于React Native而言,并不需要一个WebView容器去执行Web方面的代码,而是将所有的JavaScript代码运行在一个内嵌的JavaScriptCore容器实例中,并最终渲染为高级别的平台相关的组件。这里以iOS为例,打开HelloWorld/AppDelegate.m文件,可以看到如下的代码:

.....................
RCTRootView *rootView = [[RCTRootView alloc]
initWithBundleURL:jsCodeLocation
moduleName:@"HelloWorld"
launchOptions:launchOptions];
.....................

AppDelegate.m文件本身是iOS程序的入口,相信每一个有iOS开发经验的同学都不会陌生,这也是本地的Objective-C代码与React NativeJavaScript代码胶合的地方。而这种胶合的关键就是RCTRootView这个组件,可以从React声明的组件中加载到Native的组件。RCTRootView组件是一个由React Native提供的原生的Objective-C类,可以读取ReactJavaScript代码并且执行,除此之外,也允许我们从JavaScript代码中调用iOS UI的组件。

到这里我们可以看出,React Native并没有将JavaScript代码编译转化为原生的Objective-C或者Swift代码,但是这些在React中创建的组件渲染的方式也非常类似于传统的Objective-C或者Swift创建的基于UIKit的组件,并不是类似于WebView中网页渲染的结果。

这种架构也就很好地解释了为什么可以动态加载我们的应用,当我们仅仅改变了JS代码而没有原生的代码改变的时候,不需要去重新编译。RCTRootView组件会监听Command+R组合键然后重新执行JavaScript代码。

  • Virtual Dom的扩展

Virtual DomReact的核心机制之一,对于Virtual Dom的详细说明可以参考笔者React系列文章。在React组件被用于原生渲染之前,Clipboard已经将React用于渲染到HTMLCanvas中,可以查看render React to the HTML element这篇文章。对于React Web而言,就是将React组件渲染为DOM节点,而对于React Natively而言,就是利用原生的接口把React组件渲染为原生的接口,其大概示意图可以如下:

React Native behaves much like React, but can render to many different targets.

虽然React最初是以Web的形式呈现,但是React声明的组件可以通过bridge,即不同的桥接器转化器会将同样声明的组件转化为不同的具体的实现。React在组件的render函数中返回具体的平台中应该如何去渲染这些组件。对于React Native而言,<View/>这个组件会被转化为iOS中特定的UIView组件。

  • 载入JavaScript代码

React Native提供了非常方便的动态调试机制,具体的表现而言即是允许以一种类似于中间件服务器的方式动态的加载JS代码,即

jsCodeLocation = [NSURL URLWithString:@"http://localhost:8081/index.ios.bundle"];

另一种发布环境下,可以将JavaScript代码打包编译,即npm build

jsCodeLocation = [[NSBundle mainBundle] URLForResource:@"main" withExtension:@"jsbundle"];

如果在Xcode中直接运行程序会自动调用npm start命令来启动一个动态编译的服务器,如果没有自动启动可以手动的使用npm start命令,就如定义在package.json文件中的,它会启动node_modules/react-native/packager/packager.sh这个脚本。

React Native中的现代JavaScript代码

从上文中可以看出,React Native中使用的是所谓的JSX以及大量的ES6的语法,在打包器打包之前需要将JavaScript代码进行一些转换。这是因为iOSAndroid中的JavaScript解释器目前主要还是支持到了ES5版本,并不能完全识别React Native中提供的语法或者关键字。当然,并不是说我们不能使用ES5的语法去编写React Native程序,只是最新的一些语法细则规范可以辅助我们快速构建高可维护的应用程序。

譬如我们以JSX的语法编写了如下渲染函数:

render: function() {
  return (
    <View style={styles.container}>
      <TextInput
      style={styles.nameInput}
      onChange={this.onNameChanged}
      placeholder='Who should be greeted?'/>
      <Text style={styles.welcome}>
      Hello, {this.state.name}!</Text>
      <Text style={styles.instructions}>
      To get started, edit index.ios.js
      </Text>
      <Text style={styles.instructions}>
      Press Cmd+R to reload,{'\n'}
      Cmd+Control+Z for dev menu
      </Text>
    </View>
  );
}

JS代码载入之前,React打包器需要首先将JSX语法转化为ES5的表达式:

render: function() {
  return (
  	React.createElement(View, {style: styles.container},
    React.createElement(TextInput, {
    style: styles.nameInput,
    onChange: this.onNameChanged,
    placeholder: "Who should be greeted?"}),
    React.createElement(Text, {style: styles.welcome},
    "Hello, ", this.state.name, "!"),
    React.createElement(Text, {style: styles.instructions},
    "To get started, edit index.ios.js"
    ),
    React.createElement(Text, {style: styles.instructions},
    "Press Cmd+R to reload,", '\n',
    "Cmd+Control+Z for dev menu"
    )
  )
);
}

另一些比较常用的语法转换,一个是模块导入时候的结构器,即我们常常见到模块导入:

var React = require("react-native");
var { AppRegistry, StyleSheet, Text, TextInput, View } = React;

上文中的用法即是所谓的解构赋值,一个简单的例子如下:

var fruits = { banana: "A banana", orange: "An orange", apple: "An apple" };
var { banana, orange, apple } = fruits;

那么我们在某个组件中进行导出的时候,就可以用如下语法:

module.exports.displayName = "Name";
module.exports.Component = Component;

而导入时,即是:

var { Component } = require("component.js");

另一个常用的ES6的语法即是所谓的Arrow Function,这有点类似于Lambda表达式:

AppRegistry.registerComponent("HelloWorld", () => HelloWorld);

会被转化为:

AppRegistry.registerComponent("HelloWorld", function () {
  return HelloWorld;
});

RN需要一个JS的运行环境,在IOS上直接使用内置的javascriptcore,在Android则使用webkit.org官方开源的jsc.so。此外还集成了其他开源组件,如fresco图片组件,okhttp网络组件等。

RN会把应用的JS代码(包括依赖的framework)编译成一个js文件(一般命名为index.android.bundle),, RN的整体框架目标就是为了解释运行这个js脚本文件,如果是js扩展的API,则直接通过bridge调用native方法;如果是UI界面,则映射到virtual DOM这个虚拟的JS数据结构中,通过bridge传递到native,然后根据数据属性设置各个对应的真实nativeViewbridge是一种JSJava代码通信的机制,用bridge函数传入对方modulemethod即可得到异步回调的结果。

对于JS开发者来说,画UI只需要画到virtual DOM中,不需要特别关心具体的平台,还是原来的单线程开发,还是原来HTML组装UI(JSX),还是原来的样式模型(部分兼容)RN的界面处理除了实现View增删改查的接口之外,还自定义一套样式表达CSSLayout,这套CSSLayout也是跨平台实现。RN拥有画UI的跨平台能力,主要是加入Virtual DOM编程模型,该方法一方面可以照顾到JS开发者在html DOM的部分传承,让JS开发者可以用类似DOM编程模型就可以开发原生APP,另一方面则可以让Virtual DOM适配实现到各个平台,实现跨平台的能力,并且为未来增加更多的想象空间,比如react-cavas, react-openGL。而实际上react-native也是从react-js演变而来。

对于Android开发者来说,RN是一个普通的安卓程序加上一堆事件响应,事件来源主要是JS的命令。主要有二个线程,UI main thread, JS thread。UI thread创建一个APP的事件循环后,就挂在looper等待事件,事件驱动各自的对象执行命令。JS thread运行的脚本相当于底层数据采集器,不断上传数据,转化成UI事件,通过bridge转发到UI thread,从而改变真实的View。后面再深一层发现,UI main threadJS thread更像是CS模型,JS thread更像服务端,UI main thread是客户端,UI main thread不断询问JS thread并且请求数据,如果数据有变,则更新UI界面。

利用Create React Native App快速创建React Native应用

Create React Native App 是由FacebookExpo 联合开发的用于快速创建React Native应用的工具,其深受我们在前文介绍的 Create React App 的影响。很多没有移动端开发经验的Web开发者在初次尝试React Native应用开发时可能会困扰于大量的原生依赖与开发环境,特别对于Android开发者而言。而Create React Native App则能够让用户在未安装Xcode或者Android Studio时,即使是在Linux或者Windows环境下也能开始React Native的开发与调试。这一点主要基于我们可以选择将应用运行在Expo的客户端应用内,该应用能够加载远端的纯粹的JavaScript代码而不用进行任何的原生代码编译操作。我们可以使用NPM快速安装命令行工具:

$ npm i -g create-react-native-app
$ create-react-native-app my-project
$ cd my-project
$ npm start

命令行中会输出如下界面,我们可以在Expo移动端应用中扫描二维码,即可以开始远程调试。我们也可以选择使用Expo的桌面端辅助开发工具 XDE,其内置了命令行工具与发布工具,同时支持使用内部模拟器:

除此之外,Expo还提供了 Sketch 这个在线编辑器,提供了组件拖拽、内建的ESLint等功能,允许开发者直接在网页中进行快速开发与共享,然后通过二维码在应用内预览。

Expo支持标准的React Native组件,目前已经内置了相机、视频、通讯录等等常用的系统API,并且预置了Airbnb react-native-mapsFacebook authentication等优秀的工具库,未来也在逐步将常用的微信、百度地图等依赖作为预置纳入到SDK中。我们也可以使用 npm run eject 来将其恢复为类似于 react-native init 创建的包含原生代码的初始化项目,这样我们就能够自由地添加原生模块。我们也可以使用Expo提供的 exp 命令行将项目编译为独立可发布的应用。我们需要使用 npm install -g exp 安装命令行工具,然后配置exp.json文件:

 {
   name: "Playground",
   icon: "https://s3.amazonaws.com/exp-us-standard/rnplay/app-icon.png",
   version: "2.0.0",
   slug: "rnplay",
   sdkVersion: "8.0.0",
   ios: {
     bundleIdentifier: "org.rnplay.exp",
   },
   android: {
     package: "org.rnplay.exp",
   }
 }

配置完毕之后在应用目录内使用 exp start 命令来启动Expo打包工具,然后选择使用 exp build:android 或者 exp build:ios 分别构建Android或者iOS独立应用。

除此之外,我们还可以使用 PepperoniAppKit,或者Deco

开发第一个应用程序

在安装React Native开发环境时官方就推荐了Flow作为开发辅助工具,Flow是一个用于静态类型检查的JavaScript的开发库。Flow依赖于类型推导来检测代码中可能的类型错误,并且允许逐步向现存的项目中添加类型声明。如果需要使用Flow,只需要用如下的命令:

flow check

一般情况下默认的应用中都会包含一个*.flowconfig文件,用于配置Flow的行为。如果不希望flow检查全部的文件,可以在.flowconfig*文件中添加配置进行忽略:

[ignore]
.*/node_modules/.*

最终检查的时候就可以直接运行:

$ flow check
$ Found 0 errors.

React Native支持使用Jest进行React组件的测试,Jest是一个基于Jasmine的单元测试框架,它提供了自动的依赖Mock,并且与React的测试工具协作顺利。

npm install jest-cli --save-dev

可以将test脚本加入到package.son文件中:

{
  ...
  "scripts": {
    "test": "jest"
   }
   ...
}

直接使用npm test命令直接运行jest命令,下面可以创建tests文件夹,Jest会递归搜索tests目录中的文件,这些测试文件中的代码如下:

"use strict";

describe("a silly test", function () {
  it("expects true to be true", function () {
    expect(true).toBe(true);
  });
});

而对于一些复杂的应用可以查看React Native的官方文档,以其中一个getImageSource为例:

**
 * Taken from https://github.com/facebook/react-native/blob/master/Examples/Movies/__tests__/getImageSource-test.js
 */

'use strict';

jest.dontMock('../getImageSource');
var getImageSource = require('../getImageSource');

describe('getImageSource', () => {
  it('returns null for invalid input', () => {
    expect(getImageSource().uri).toBe(null);
  });
  ...
});

因为Jest是默认自动Mock的,所以需要对待测试的方法设置dontMock.