React Native(下文简称 RN)开源已经一年多时间,国内各大互联网公司都在使用,携程也在今年 5 月份投入资源开始引入,并推广给多个业务团队使用,本文将会分享我们遇到的一些问题以及我们的优化方案。
一、背景和使用情况介绍
1. AppSize 占用
2. 用户体验佳
3. 相对成熟
4. 支持动态更新
5. 跨平台
基于 RN 0.30 版本,开发了支持携程业务团队快速便捷开发的 CRN 框架,框架主要从以下几个方面着手。
1. 工具
2. 控件
3. 稳定性、性能优化
4. 发布
除此之外,我们还从文档以及技术支持等方面,支撑其作为一个完整的产品开发框架。
下面一幅图说明了 RN 在携程业务中的使用情况,总共 4 个版本的开发时间,每个版本大约 1 个月时间。

前面 2 个版本主要是 CRN 基础功能完成和线上验证,后面 2 个版本稳定性优化和 API 跨平台抹平基本完成,业务数和页面数量猛增。
二、遇到的问题和优化
所有做 React Native 开发的团队,或多或少都面临着以下 4 个问题需要解决。
接下来,我们就这四个问题来一一探讨。

从这张图中可以看出,最大的瓶颈在 JS init + Require,这块时间就是 JSBundle 的执行时间,为了提升页面加载速度,这块时间我们需要想办法优化。
先来说一组数据,一个 Helloorld 的 App,如果使用 0.30 RN 官方命令react-native bundle打包出来的 JSBundle 文件大小大约为 531KB,RN 框架 JavaScript 本身占了 530KB,zip 压缩之后也有 148KB。
如果只有一两个业务使用,这点大小算不了什么,但是对于我们这种动辄几十个业务的场景,如果每个业务的 JSBundle 都需要这么大的一个 RN 框架本身,那将是不可接受的。
因此,我们需要对 RN 官方的打包脚本做改造,将框架代码拆分出来,让所有业务使用一份框架代码。
开始拆分之前, 我们先以 Hello World 的 RN App 为基础介绍几个背景知识。

上述是一个 Hello World RN App 代码的结构,基本分为 3 部分:

上述是 Hello World RN App 打包之后 JSBundle 文件的结构,基本分为 3 部分:
define、require等全局模块的定义;\_\_d是 RN 自定义的define,符合 CommonJS规范,\_\_d后面的数字是模块的id,是在 RN 打包过程中,解析依赖关系,自增长生成的。
如果所有业务代码,都遵照一个规则:入口 JS 文件首先 require 的都是 react/react-native, 则打包生成的 JSBundle 里面 react/react-native 相关的模块id都是固定的。
基于上面 2 点背景知识介绍,我们很容易发现,如果将打包之后的 JSBundle 文件,拆分成 2 部分(框架部分+业务模块部分),使用的时候合并起来,然后去加载,即可实现拆分功能。
具体实现步骤:
require react/react-native即可;react-native bundle命令,打包该入口文件,生成common.js;react-native bundle打包业务工程(有一点要保证,业务工程入口文件前面 2 行代码也是require react/react-native), 生成business\_all.js;business\_all.js里删除common.js的内容,剩下的就是business.js;common.js和business.js合并在一起,然后加载。貌似功能完成,可是回到 Dive into React Native performance,这么做还是优化不了 JSBundle 的执行时间。因为我们不能把拆分开的 2 个文件分别执行,加载common.js会提示找不到 RN App 的入口,先执行business.js,会提示一堆依赖的 RN 模块找不到。
显然,这种拆分方式不能满足我们这种需要。
那这个方案就完全没有价值吗?不是的,如果你做的是一个纯 RN App,Native 只是一个壳,里面业务全是 RN 开发的,完全可以使用这种方式做拆分,这种方案简单,无侵入,实现成本低,不需要修改任何 RN 打包代码和 RN Runtime 代码。
RN 框架部分文件(common.js)大小 530KB,如此大的 JS 文件,占用了绝大部分的 JS 执行时间。这块时间如果能放到后台预先做完,进入业务也只需执行业务页面的几个 JS 文件,将可以大大提升页面加载速度,参考上面的 RN 性能瓶颈图,预估可以提升 100%。
按照这个思路,能后台加载的 JS 文件, 实际上是就是一个 RN App。因此我们设计了一个空白页面的 Fake App,它做一件事情,就是监听要显示的真实业务 JS 模块,收到监听之后,渲染业务模块,显示页面。
Fake App 设计如下:

为了实现该拆包方案,需要改造 React-Native 的打包命令;
common.js包时,需要记录 RN 各个模块名和模块id之间的mapping关系;mapping文件里面的模块,不要打包到业务包中。改造页面加载流程:
\<iOS-RCTBridge, Android-ReactInstanceManager\>;通过后台预加载,省去了绝大部分的 JS 加载时间,似乎问题已经完美解决。
但是,如果随着业务不断膨胀,一个 RN 业务 JS 代码也达到 500KB,进入这个业务页面,500 多KB 的 JS文件读取出来,执行,整个 JS 执行的时间瓶颈会再次出现。
正在此时,我们研究 RN 在 Facebook App 里面的使用情况,发现了Unbundle,简单点说,就是将所有的 JS 模块都拆分成独立的文件。
下面截图就是Unbundle打包的文件格式:

entry.js就是 Global 部分定义 + RN App 入口;Unbundle文件是用于标识这是一个Unbundle包的 flag;12.js、13.js就是各个模块,文件名就是模块id;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。
\_crn\_config记录了这个 RN App 的入口模块id以及其他配置信息,详见下图:

main\_module为当前业务模块入口模块id;module\_path为业务模块 JS 文件所在当前包的相对路径;666666=0.js,说明666666这个模块在0.js文件里面;做完这个拆包和加载优化之后,我们用自己的几个业务做了下测试,下图是当时的测试验证数据。

可以看出,iOS 和 Android 基本都比官方打包方式的加载时间,减少了 50%。
这是自己单机测试的数据,那上线之后,数据如何呢?
下图,是我们分析一天的数据,得出的平均值\<排除掉了 5s 以上的异常数据,后面实测下来 5s 以上数据极少>;

看到这个数据,发现和我们自己测试的基本一致,但是还有一个疑问,加载的时间分布,是否服从正态分布,会不会很离散,快的设备很快,慢的设备很慢呢?
然后我又进一步分析这一天的数据,按照页面加载时间区间分布统计。

看图上数据,很明显,iOS & Android 基本一致,将近 98% 的用户都能在 1s 内加载完成页面,符合我们期望的正态分布,所以 bundle 拆分到此基本完成。
关于这个数据,补充一下,先前已看到一篇58同城同学分享的RN实践的文章,里面也曾提到他们业务页面加载时间的数据,有兴趣的同学可以去比较下。
按照上述的拆包方案实现后,我们的 RN 页面加载流程大致是这样的。

从上文的优化可以看出,缓存了common.js部分的 JS 执行引擎(iOS RCTBridge, Android ReactInstanceManager),页面加载可以大大提速,那对于已经被业务使用过的 JS 执行引擎,该如何处理呢?
缓存,还是缓存,不要立即释放,等符合一定条件之后,再释放。
对JS执行引擎,我们定义了以下的一些生命周期状态。

common.js的时候,处于Loading状态,如果加载出错,处于Error状态;common.js加载结束,JS 执行引擎状态设置为Ready;Ready状态的 JS 执行引擎被使用,则修改状态为Dirty;Dirty状态的 JS 执行引擎达到一定条件\<比如Dirty的JS执行引擎总数达到2个时候>,开始回收;RN 刚上线的前 2 个版本,我们发现有大量因为 RN 导致的 Crash,常见的错误有以下几种。

iOS 的 Crash,基本都来自RCTFatalException,都是RCTFatal抛出错误信息所知,处理也相对简单,设置自己的Error Handler即可。
void RCTSetFatalHandler(RCTFatalHandler fatalHandler);一般初次开发 RN 应用的开发人员,都没有留意这一点,其实查阅下 RN 的源代码,RCTFatal的注释写的还比较清楚,分析源码也可以发现在生产环境的时候,RCTFatal会直接Raise Exception,然后 Crash。

Android 的 Crash 点相对较多,大致会出现在以下几个场景:
bundle加载过程中的RuntimeException;NativeExceptionsManagerModule;NativeModuleCallExceptionHandler;java.lang.UnsatisfiedLinkError,这种问题,解决方案很简单,给System.load添加try catch,并且在catch里面做补偿,可以大大降低由此导致的 Crash;对于第一点提到的RuntimeException,我们收集到的日志如下:
不能连接到dev server,看到之后很不明白,明明是生产环境,怎么会报这样的错误呢?
偶现的 JavaScript 执行出错,怎么会走到RuntimeException呢?
问题的解决很简单,这些RuntimeException,都是从ReactInstanceManagerImp.java的createReactContext抛出来的,处理掉就可以了。
再补充一点,这些错误处理之后,都需要一层一层的传递到最上层的 UI 界面,这样才能友好地给用户提示。
先来看一张截图,是从 RN 提供的 UIExplore Demo 跑出来的:

可以清楚的看到,超出屏幕的条目,依然被渲染了。没有实现 cell 重用,导致数据量大时候,卡顿。
为适应大数据量 ListView 的场景,我们专门安排资源,开发了可重用 cell 的CRNListView,iOS 借鉴了第三方的ReactNativeTableView的实现,开发了可重用 cell 的 ListView,接口和官方原生的基本一致,Android 借鉴 iOS 的方案,采用RecyclerView实现了类似的可重用 cell 的 ListView,同时我们还做了一些扩展,把常用的下拉刷新,载入更多,右侧字母索引栏等功能,都增加了进去。
实际测试下来,数据量少时候,和 RN 提供的 ListView,性能基本一致,但当数据量大时候,CRNListView优势明显,下面这张图,是我们在 Android 上的测试数据。

三、下一阶段的规划
1. CRN-Web 的开发
同样的功能,CRN 一套代码可以在 iOS 和 Android 2 个平台运行。但对于业务开发团队,他们还需要维护 H5 平台同样的功能。如果我们能够将 CRN 代码,通过类似 webpack 这样的工具,直接转换过去就能在 H5 平台上运行起来,就可以做到一套代码,三端运行,可以大大降低业务团队的开发维护成本。
目前,我们已经再拿一些业务的 CRN 代码做转换验证,初步验证可行,还在持续优化完善中。
2. 单JS执行引擎的实现
RN 还有一个比较大的性能瓶颈在于内存耗用大。做过这样的测试,在一个 Hello World 的 RN 工程里面,打开一个 Native/RN/H5 Hybrid 的 Hello World 页面,Native 显示页面内存占用 0.2MB,RN 占用 10MB,H5 Hybrid 占用 20MB。如果大量业务都使用 RN 开发,JS 执行引擎大量创建,会耗费大量内存,但是从 JS 执行引擎的执行过程。运行逻辑来说,只要做好业务隔离,完全是可以在一个执行引擎里面运行多个业务功能的 JS 代码的。我们正在做相关尝试,相信在未来 1-2 个版本时间,可以完成线上验证。
3. AMD模式的加载尝试
RN 打包默认是CommonJS规范,整个 JSBundle 一次读入内存,一次全部执行完成,所以耗费大量时间。如果能够用 AMD 模式改造,JSBundle 读取到内存,但是只执行用到的模块,真正做到按需加载,相信对页面加载效率,会有更近一步的提升。