## 携程 React Native 工程实践和优化
对于业务复杂的中大型 App 而言,通常会有一套 Hybrid 解决方案用于快速开发部分业务功能,实现 iOS 和 Android 平台一套业务代码,甚至 App 与 Mobile HTML5 Web 端一套代码(有部分额外桥接层逻辑)。携程无线基础工程团队在2013年研发了 Hybrid 框架,虽然已经逐步发展成熟(诸如本地包和差分增量更新等特性),但其用户体验相比 Native 仍有较大差距,原有的技术方案很难再有性能提升。
2015年开始,React Native(后续统称为 RN)逐步成为国内无线尤其前端领域的热门开发技术(2016年又出现了 Weex 热门框架),其好处在于以下几点:
- **App Size 占用**——携程旅行 App 从11年开始开发,至今已有近6年多时间。随着各项业务功能的全面移动化,以及公司 Mobile First 策略影响,App 功能越来越多,体积越来越大,iOS App Size 将近100MB。而同样的功能,使用 RN 开发,Size 会远远小于 Native 开发,引入 RN 可以支持我们 App 的可持续发展。
- **用户体验佳**——RN 通过 JavaScriptCore 解析 JS 模块,转换成原生 Native 组件渲染,相比 HTML5 页面不再局限于 WebView、渲染性能长足提升,运行用户体验可以媲美 Native。
- **相对成熟**——Android 和 iOS 的 RN 都已经开源,原生提供的组件和 API 相对丰富,且跨平台基本一致,对外接口也趋于稳定,适合业务开发。
- **支持动态更新**——纯 Native 的开发,Android 平台通过插件化可以实现动态加载更新代码。但是在 iOS 平台上,由于系统限制,不能动态执行远端下载的 Native 代码,而 RN 完全满足该需求。
- **跨平台**——RN 提供的 API 和组件,大部分能跨平台使用,对少数不支持的组件,我们再做二次封装抹平平台差异,可以让业务研发人员开发一份代码,运行在 iOS 和 Android 两个平台上。这样能够提升开发效率,降低维护成本。
### 工程引入 RN
#### 如何引入?
我们基于 RN 0.30 版本开发了符合携程的 CRN 框架,主要包括以下几部分:
- 工具——CLI 工具,负责 CRN 工程创建,运行;Pack 工具,负责工程打包;
- 组件——对 RN 官方提供的 API 和组件进行封装和统一,实现跨平台支持;新增携程业务相关的 API 和组件,方便业务接入;
- 稳定性和性能优化——提升稳定性,消除 RN 导致的 Crash 问题;RN 页面加载提速,实现秒开;
- 发布支持——统一管理所有 RN 业务的热发布功能;差分增量支持,尽可能减小更新文件大小。
除此之外,也包含完善文档及技术支持等方面,从而支撑其作为一个完整的技术开发框架。
#### 业务使用
图1说明了 RN 在携程业务中的引入进度,历经四个版本的开发,每个版本周期大约为1个月时间。
图1 携程业务 RN 引入进度
前两个版本主要完成 CRN 基础功能和线上验证,后两个实现稳定性优化和抹平 API 平台差异,业务数和页面数量猛增。
### 遇到的问题和优化
#### RN 常见问题介绍
所有做 React Native 开发的团队,或多或少都会面临着以下四个需要解决的问题:
- 打包出的 JSBundle 过大;
- 首次进入 RN 页面加载缓慢;
- 稳定性不够,有大量因为RN导致的 Crash 问题;
- 大数据源时 ListView 加载卡顿。
##### **JSBundle 文件过大**
先看一组数据,一个 Hello World 的 RN App,如果使用 RN0.30 官方命令“react-native bundle”打出的 JSBundle 文件大小约为531KB,RN 框架 JavaScript 本身占了530KB,ZIP 压缩后仍有148KB。
如果只有一两个业务使用,这点大小算不了什么,但对于携程这种动辄几十个业务的场景,如果每个业务的 JSBundle 都需要这么大的一个 RN 框架本身,那将是不可接受的。
为此,我们对 RN 官方的打包脚本做改造,将框架代码拆分出来,让所有业务统一使用同一套框架代码。
开始拆分之前, 我们先以 Hello World 的 RN App 为基础,介绍几个背景知识:
```
'use strict';
/* 1. 头部 */
import React, { Component } from 'react';
import { AppRegistry } from 'react-native';
import { HelloWorldView } from './HelloWorldView'
/* 2. 中间 */
class Index extends Component {
function render() {
return ()
}
}
/* 3. 尾部 */
AppRegistry.registerComponent('HelloWorld', () => Index);
```
上述是一个 HelloWorld RN App 代码的结构,基本分为3部分:
- 头部:各依赖模块引用部分;
- 中间:入口模块和各业务模块定义部分;
- 尾部:入口模块注册部分。
```
/*1. 头部*/
(function(global) {
global.__DEV__=true;
global.__BUNDLE_START_TIME__=Date.now();
})(typeof global !== 'undefined' ? global : typeof self !== 'undefined' ? self : this);
/*2. 中间*/
__d(0 /* HelloWorld/index.android.js */, function(global, require, module, exports) {
'use strict';
module.exports=require(12 /* ./src/index */);
}, "HelloWorld/index.android.js");
/*3. 尾部*/
;require(188);
//InitializeJavaScriptAppEngine
;require(0);//入口模块
```
上述是 HelloWorld RN App 打包之后 JSBundle 文件的结构,基本分为三部分:
- 头部:全局定义,主要是 define、require 等全局模块的定义;
- 中间:模块定义,RN 框架和业务的各个模块定义;
- 尾部:引擎初始化和入口函数执行。
`__d` 是 RN 自定义的 define,符合 CommonJS 规范,`__d` 后面的数字为模块的 ID,是在 RN 打包过程中,解析依赖关系,自增长生成的。
如果所有业务代码,都遵照一个规则,即入口 JS 文件首先 require 的都是 react/react-native, 则打包生成的 JSBundle 里 react/react-native 相关的模块 ID 都是固定的。
#### 拆分方案一
基于上面两点背景知识介绍,我们很容易发现,如果将打包之后的 JSBundle 文件,拆分成两部分(框架部分+业务模块部分),使用时合并起来,然后去加载,即可实现拆分功能。
具体实现步骤为:
- 创建一个空工程,入口文件只需要两行代码,require react/react-native 即可;
- 使用 react-native bundle 命令,打包该入口文件,生成 common.js;
- 使用 react-native bundle 打包业务工程(有一点需保证,业务工程入口文件前两行代码也是 require react/react-native),生成 business_all.js;
- 开发工具,从 business_all.js 里删除 common.js 的内容,剩下的就是 business.js;
- App加载时将 common.js 和 business.js 合并,然后加载。
貌似功能完成,但回到 Dive into React Native Performance,这么做还是优化不了 JSBundle 的执行时间。因为我们不能把拆分开的两个文件分别执行,否则加载 common.js 会提示找不到 RN App 的入口,而执行 business.js 会提示一堆依赖的 RN 模块找不到。
显然,这种拆分方式无法满足我们这种需求。
那这个方案就完全没有价值吗?也不尽然,如果你做的是一个纯 RN App,Native 只是一个壳,里面业务全为 RN 开发,完全可以使用这种方式做拆分,既简单、无侵入,实现成本低,也无需修改任何RN打包代码和 RN Runtime 代码。
#### 拆分方案二
RN 框架部分文件 common.js 大小530KB,体积如此大的 JS 文件,占用了绝大部分的 JS 执行时间,这块如果能放到后台预先做完,进入业务也只需执行业务页面的几个 JS 文件,将大大提升页面加载速度。
按照这个思路,能后台加载的 JS 文件, 实际上就是一个 RN App。因此我们设计了一个空白页面的 FakeApp,它能够监听要显示的真实业务 JS 模块,收到监听后,渲染业务模块,显示页面。
FakeApp 设计如下:
```
import React, { Component } from 'react';
import {
AppRegistry,
View,
DeviceEventEmitter
} from 'react-native';
//添加监听
var mainComponent = null;
DeviceEventEmitter.removeAllListeners();
DeviceEventEmitter.addListener("ToggleLoadModule", function(event) {
console.log("event moduleId: " + event.moduleId);
if (event && event.moduleId) {
mainComponent = require(event.moduleId);
}
});
var StartComponent = React.createClass({
getInitialState: function() {
return {trigger: false};
},
render: function() {
var _content = null;
if (mainComponent) {
//需将this.props传给component
_content = React.createElement(mainComponent, this.props);
}
return _content || ;
},
});
AppRegistry.registerComponent('FakeApp', () => StartComponent);
```
为了实现该拆包方案,需要改造 react-native 的打包命令:
- 基于 FakeApp 打 common.js 包时,需记录 RN 各个模块名和模块 ID 之间的 mapping 关系;
- 打业务模块包时进行判断,已经在 mapping 文件里面的模块勿打包到业务包中。
改造页面加载流程:
- 因为要能够后台加载,所以需分离 UI 和 JS 加载引擎(iOS-RCTBridge, Android-ReactInstanceManager);
- 进入业务 RN 页面时,获取预加载好的 JS 引擎,然后发送消息给 FakeApp,告知该渲染的业务 JS 模块;
通过后台预加载,省去了绝大部分的 JS 加载时间,似乎问题已经完美解决。但如果随着业务不断膨胀,一个 RN 业务 JS 代码也达到500KB,进入这个业务页面,500+KB的 JS 文件读取、执行,整个 JS 执行的时间瓶颈会再次出现。
#### 拆分方案三
正在此时,我们研究 RN 在 Facebook App 里面的使用情况,发现了 Unbundle,简单点说,就是将所有的 JS 模块都拆分成独立的文件,如图2所示,其中:
- entry.js 为 global 部分定义+ RN App 入口;
图2 Unbundle 打包的文件格式
- UNBUNDLE 文件是用于标识这是一个 Unbundle 包的 flag;
- 12.js、13.js 即为各个模块,文件名就是模块 ID;
- 在业务执行,需要加载模块(require)时,就去磁盘查找该文件,读取、执行。
RN里加载模块流程说明,以 require(66666) 模块为例:
- 首先从 `__d` (即前文提到的 define)的缓存列表里查找是否有定义过模块66666,如果有,直接返回,否则走到下面第二步的 nativeRequire;
- nativeRequire 根据模块 ID,查找文件所在路径,读取文件内容;
- 定义模块,`_d(66666)=eval(JS文件内容)`,会将这个模块 ID 和 JS 代码执行结果记录在 define 的缓存列表中;
通过 react-native unbundle 命令,可以给 Android 平台打出这样的 Unbundle 包。
顺便提一下,这个 Unbundle 方案,只在 Android 上有效,打 iOS 的 Unbundle 包,是打不出来的,在 RN 的打包脚本上有一行注释,大意是在 iOS 上众多小文件读取文件 IO 效率不够高,Android 上没有这样的问题,如果判断是打 iOS 的 Unbundle 包会直接 return。
相对应的,iOS 开发了一个 prepack 的打包模式,简单点说,就是把所有的 JS 模块打包到一个文件里面,打包成一个二进制文件,并固定 0xFB0BD1E5 为文件开始,这个二进制文件里有个 meta-table,记录各个模块在文件中的相对位置,在加载模块(require)时,通过 fseek 找到相应的文件开始、读取、执行。
在 Unbundle 的启发下,我们修改打包工具,开发了 CRNUnbunle,进行简单优化,将众多零散的 JS 文件做简单合并。将 common 部分的 JS 文件,合并成一个 common_ios(android).js(见图3)。
图3 CRNUnbunle 优化
图4 `_crn_config`文件
`_crn_config`记录了这个 RN App 的入口模块 ID 及其他配置信息,可见图4,其中:
- main_module 为当前业务模块入口模块 ID;
- module_path 为业务模块 JS 文件所在当前包的相对路径;
- 666666=0.js 说明666666这个模块在 0.js 文件里面。
完成这个拆包和加载优化后,我们用自己的几个业务进行了测试,如图5所示。可以看出,iOS 和 Android 基本都比官方打包方式的加载时间减少了50%。
图5 测试验证数据
这是自己单机测试的数据,那上线之后,数据如何呢?
图6是我们分析一天的数据,得出的平均值(排除掉了5s以上的异常数据,后面实测下来5s以上数据极少)。
图6 上线数据分析
看到这个数据,发现和我们自己测试的基本一致,但还有一个疑问,加载的时间分布是否服从正态分布,会不会很离散,快的设备很快,慢的设备很慢呢?
然后进一步分析这一天的数据,按照页面加载时间区间分布统计,如图7所示。很明显,iOS&Android 基本一致,将近98%的用户都能在1s内加载完成页面,符合我们期望的正态分布,所以 Bundle 拆分到此基本完成。
图7 数据分布统计
关于这个数据,补充一下,先前看到一篇58同城同学分享的[ RN 实践文章](http://geek.csdn.net/news/detail/105028),里面也曾提到他们业务页面加载时间的数据,有兴趣的同学可以进行比较。
### 页面加载优化
在继续探讨问题之前,我们先看下 Facebook RN 团队博客《Dive into React Native Performance》,该文章介绍了 Facebook 消息流 RN 页面加载过程中的时间分布,见图8。从中可以看出,最大的瓶颈在 JS Init+Require,这部分时间即为 JSBundle 的执行时间,为了提升页面加载速度,我们需要做的就是将该部分时间尽可能降低。
图8 Facebook RN 消息流页面加载耗时分布
按照上述的拆包方案实现后,我们的 RN 页面加载流程大致如图9所示。
图9 携程 RN 页面加载逻辑
从上文的优化可以看出,缓存了 common.js 部分的 JS 执行引擎(iOS RCTBridge, Android React
InstanceManager),页面加载可以大大提速,那对于已经被业务使用过的JS执行引擎,该如何处理呢?
缓存,还是缓存,不要立即释放,等符合一定条件之后,再释放。对于 JS 执行引擎,我们定义了如图10所示的生命周期状态。
图10 加载逻辑
- JS 执行引擎加载 common.js 时,处于 loading 状态,如果加载出错,处于 Error 状态;
- 框架 common.js 加载结束,JS 执行引擎状态设置为 Ready;
- Ready 状态的 JS 执行引擎被使用,则修改状态为 Dirty;
- Dirty 状态的 JS 执行引擎达到一定条件(比如 Dirty 的 JS 执行引擎总数达到2个时候),开始回收;
- 回收过程很简单,就是将加载(require)的业务代码,从 `__d`(前文提到的 define)的缓存模块数组里删掉就可以了,回收完成之后,又变成还原状态。
正式上线后,CRN 页面的平均加载耗时比原先的 Hybrid 技术方案有70%的提升(见图11)。
图11 页面加载耗时对比
### 错误处理
RN 刚上线的前两个版本,我们发现有大量因为RN导致的 Crash,常见的错误有以下几种。
#### iOS 的 Crash 问题处理
iOS 的 Crash 基本都来自 RCTFatalException,是 RCTFatal 抛出错误信息所致,处理也相对简单, 设置自己的 Error Handler 即可。
```
void RCTSetFatalHandler(RCTFatalHandler fatalHandler);
```
一般初次开发RN应用的开发人员,都没有留意这一点,其实查阅下 RN 的源代码,RCTFatal 的注释比较清楚,分析源码也可以发现,在生产环境时,RCTFatal 会直接 Raise Exception,从而直接导致 Crash。
图12 iOS Crash
#### Android的Crash问题处理
Android 的 Crash 根源相对较多,大致会出现在以下几个场景:
- bundle 加载过程中的 RuntimeException;
- JS 执行过程中的,处理 NativeExceptions ManagerModule;
- native 模块执行出错,处理 NativeModuleCallExceptionHandler;
- so lib 加载失败,经典的 java.lang.Unsatisfied LinkError,这种问题,解决方案很简单,给 System.load 添加 try catch,并且在 catch 里做补偿,可以大大降低由此导致的 Crash;
对于第一点提到的 RuntimeException,可见图13我们收集到的日志。
图13 日志
问题的解决很简单,这些 RuntimeException,都是从 ReactInstanceManagerImp.java 的 createReactContext 抛出来的,处理掉就可以了。
再补充一点,这些错误处理之后,都需要一层一层地传递到最上层的 UI 界面,这样才能友好地给用户提示。
### ListView性能问题
图14是从 RN 提供的 UIExplore Demo 跑出来的,可以清楚地看到,超出屏幕的条目依然被渲染了。RN 原框架没有实现 Cell 重用,导致数据量大时,UI 非常卡顿。
为适应大数据量 ListView 的场景,我们开发了可重用 Cell 的 CRNListView,iOS 借鉴了第三方的 ReactNativeTableView 的实现,开发了可重用 Cell 的 Listview,接口和官方原生实现基本一致,Android 借鉴 iOS 的方案,采用 RecyclerView 实现了类似的可重用 Cell 的 ListView,同时我们还做了一些扩展,加入了常用的下拉刷新,载入更多,右侧字母索引栏等功能。
图14 RN 原生 ListView 性能
实际测试下来,数据量少时,和RN提供的 ListView 性能基本一致,但当数据量大时,CRNListView 优势明显,图15是我们在 Android 上的测试数据。
图15 CRN 优化之后的ListView
### 未来规划
目前 CRN 框架的组件和 API 已经基本稳定,正在携程内部进行推广和使用,在携程旅行最新的7.2版本中已经发展为全业务线使用 RN,150+页面完全采用 RN 技术实现,我们对 RN 技术的推广态度相对比较激进,将 CRN 框架作为原有 Hybrid 技术的替代方案进行推广。除了推广落地,我们还会从以下几个方面完善 CRN 框架:
#### CRN-Web
同样的功能,CRN 一套代码可以在 iOS 和 Android 两个平台运行。但对于业务开发团队,他们还需要维护 HTML5 端的相同功能,如果我们能够将 CRN 代码通过类似 Webpack 这样的工具,直接转换过去就能在 HTML5 端运行起来,就可以做到一套代码三端运行,从而大大降低业务团队的开发成本。
目前,我们已经实现了一些业务的 CRN 代码做转换验证,初步验证可行,正在线上验证。
#### 单 JS 执行引擎
RN 还有一个比较大的性能瓶颈在于内存耗用大,我们做过测试,在一个 HelloWorld 的 RN 工程中,分别打开一个 Native、RN、Hybrid 的 HelloWorld 页面,Native 显示页面内存占用0.2MB,RN 占用10MB,Hybrid 占用20MB。如果大量业务都使用 RN 开发,JS 执行引擎被大量创建,就会耗费大量内存。但从 JS 执行引擎的执行过程、运行逻辑来说,只要做好业务隔离,完全是可以在一个执行引擎里面运行多个业务功能的 JS 代码的。我们正在做测试验证,预计在未来1-2个版本完成线上验证。
#### AMD模式
RN 打包默认是采用 CommonJS 规范,整个 JSBundle 一次读入内存,一次全部执行完成,所以会耗费大量时间。如果能够用 AMD 模式改造,JSBundle 读取到内存,但是只执行用到的模块,真正做到按需加载,相信对页面加载效率,会有更近一步的提升。