## 携程 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引入进度 图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打包的文件格式 图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优化 图3 CRNUnbunle 优化 图4  _crn_config文件 图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  测试验证数据 图5 测试验证数据 这是自己单机测试的数据,那上线之后,数据如何呢? 图6是我们分析一天的数据,得出的平均值(排除掉了5s以上的异常数据,后面实测下来5s以上数据极少)。 图6  上线数据分析 图6 上线数据分析 看到这个数据,发现和我们自己测试的基本一致,但还有一个疑问,加载的时间分布是否服从正态分布,会不会很离散,快的设备很快,慢的设备很慢呢? 然后进一步分析这一天的数据,按照页面加载时间区间分布统计,如图7所示。很明显,iOS&Android 基本一致,将近98%的用户都能在1s内加载完成页面,符合我们期望的正态分布,所以 Bundle 拆分到此基本完成。 图7  数据分布统计 图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消息流页面加载耗时分布 图8 Facebook RN 消息流页面加载耗时分布 按照上述的拆包方案实现后,我们的 RN 页面加载流程大致如图9所示。 图9  携程RN页面加载逻辑 图9 携程 RN 页面加载逻辑 从上文的优化可以看出,缓存了 common.js 部分的 JS 执行引擎(iOS RCTBridge, Android React InstanceManager),页面加载可以大大提速,那对于已经被业务使用过的JS执行引擎,该如何处理呢? 缓存,还是缓存,不要立即释放,等符合一定条件之后,再释放。对于 JS 执行引擎,我们定义了如图10所示的生命周期状态。 图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  页面加载耗时对比 图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 图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  日志 图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性能 图14 RN 原生 ListView 性能 实际测试下来,数据量少时,和RN提供的 ListView 性能基本一致,但当数据量大时,CRNListView 优势明显,图15是我们在 Android 上的测试数据。 图15  CRN优化之后的ListView 图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 读取到内存,但是只执行用到的模块,真正做到按需加载,相信对页面加载效率,会有更近一步的提升。