携程React Native实践

时间:2022-04-26
本文章向大家介绍携程React Native实践,主要内容包括为什么会引入 React Native?、如何引入?、业务的使用、RN 常见问题介绍、JSBundle 文件过大 & 页面加载慢、拆分方案二、拆分方案三、页面加载优化、错误处理、Android 的 Crash 问题处理、ListView 性能问题、基本概念、基础应用、原理机制和需要注意的事项等,并结合实例形式分析了其使用技巧,希望通过本文能帮助到大家理解应用这部分内容。

React Native(下文简称 RN)开源已经一年多时间,国内各大互联网公司都在使用,携程也在今年 5 月份投入资源开始引入,并推广给多个业务团队使用,本文将会分享我们遇到的一些问题以及我们的优化方案。

一、背景和使用情况介绍

为什么会引入 React Native?

1. AppSize 占用

  • 携程旅行 App 从 11 年开始开发,至今已有 5 年多时间,随着各项业务功能的全面移动化,以及公司“Mobile first”策略的指引下,App 功能越来越多,越来越臃肿,Size 达到将近 100MB。而同样功能,使用 RN 开发,Size 远远小于 Native 开发,RN 的引入,可以支持我们 App 的可持续健康的发展。

2. 用户体验佳

  • RN 通过 JavaScript Core 解析 JavaScript 模块,转换成原生 Native 组件渲染,相比 H5 页面不再局限于 WebView、渲染性能长足提升,运行用户体验可以媲美 Native。

3. 相对成熟

  • Android 和 iOS 的 RN 都已经开源,原生提供的组件和 API 相对丰富,且跨平台基本一致,对外接口也趋于稳定,适合业务开发。

4. 支持动态更新

  • 纯原生的开发,Android 上通过插件化框架,可以实现动态加载远端代码。但是在 iOS 上,因为系统限制,不能动态执行远端下载的 Native 代码,而 RN 完全满足该需求。

5. 跨平台

  • RN 提供的 API 和组件,大多能跨平台使用,对少数不支持的组件,我们再做二次封装抹平,可以让业务开发人员开发一份代码,运行在 iOS & Android 两个平台上。这样能够大大提高开发效率,降低开发维护成本。

如何引入?

基于 RN 0.30 版本,开发了支持携程业务团队快速便捷开发的 CRN 框架,框架主要从以下几个方面着手。

1. 工具

  • cli 工具,负责 CRN 工程创建,运行;
  • pack 工具,负责打包;

2. 控件

  • 对 RN 官方提供的 API 和组件,实现跨平台支持;
  • 新增携程业务相关的 API 和组件,方便业务接入;

3. 稳定性、性能优化

  • RN 页面加载提速,实现秒开;
  • 稳定性提升,消除 RN 导致的 Crash;

4. 发布

  • 统一管理所有 RN 业务的相关发布;
  • 差分增量支持,尽可能减小文件大小;

除此之外,我们还从文档以及技术支持等方面,支撑其作为一个完整的产品开发框架。

业务的使用

下面一幅图说明了 RN 在携程业务中的使用情况,总共 4 个版本的开发时间,每个版本大约 1 个月时间。

前面 2 个版本主要是 CRN 基础功能完成和线上验证,后面 2 个版本稳定性优化和 API 跨平台抹平基本完成,业务数和页面数量猛增。

二、遇到的问题和优化

RN 常见问题介绍

所有做 React Native 开发的团队,或多或少都面临着以下 4 个问题需要解决。

  1. 打包出来的 JSBundle 过大;
  2. 首次进入 RN 页面加载缓慢;
  3. 稳定性不够,有大量因为 RN 导致的 Crash;
  4. 大数据量时 ListView 加载卡顿。

接下来,我们就这四个问题来一一探讨。

从这张图中可以看出,最大的瓶颈在 JS init + Require,这块时间就是 JSBundle 的执行时间,为了提升页面加载速度,这块时间我们需要想办法优化。

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 部分:

  • 头部:全局定义,主要是definerequire等全局模块的定义;
  • 中间:模块定义,RN 框架和业务的各个模块定义;
  • 尾部:引擎初始化和入口函数执行;

__d是 RN 自定义的define,符合 CommonJS规范,__d后面的数字是模块的id,是在 RN 打包过程中,解析依赖关系,自增长生成的。

如果所有业务代码,都遵照一个规则:入口 JS 文件首先 require 的都是 react/react-native, 则打包生成的 JSBundle 里面 react/react-native 相关的模块id都是固定的。

拆分方案一

基于上面 2 点背景知识介绍,我们很容易发现,如果将打包之后的 JSBundle 文件,拆分成 2 部分(框架部分+业务模块部分),使用的时候合并起来,然后去加载,即可实现拆分功能。

具体实现步骤:

  1. 创建一个空工程,入口文件只需要2行代码,require react/react-native即可;
  2. 使用react-native bundle命令,打包该入口文件,生成common.js;
  3. 使用react-native bundle打包业务工程(有一点要保证,业务工程入口文件前面 2 行代码也是require react/react-native), 生成business_all.js
  4. 开发工具,从business_all.js里删除common.js的内容,剩下的就是business.js;
  5. App 加载时将common.jsbusiness.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 的打包命令;

  1. 基于 Fake App 打common.js包时,需要记录 RN 各个模块名和模块id之间的mapping关系;
  2. 打业务模块包时,判断,如果已经在mapping文件里面的模块,不要打包到业务包中。

改造页面加载流程:

  1. 因为要能够后台加载,所以需分离 UI 和 JS 加载引擎<iOS-RCTBridge, Android-ReactInstanceManager>;
  2. 进入业务 RN 页面时候,获取预加载好的 JS 引擎,然后发送消息给 Fake App,告知该渲染的业务 JS 模块;

通过后台预加载,省去了绝大部分的 JS 加载时间,似乎问题已经完美解决。

但是,如果随着业务不断膨胀,一个 RN 业务 JS 代码也达到 500KB,进入这个业务页面,500 多KB 的 JS文件读取出来,执行,整个 JS 执行的时间瓶颈会再次出现。

拆分方案三

正在此时,我们研究 RN 在 Facebook App 里面的使用情况,发现了Unbundle,简单点说,就是将所有的 JS 模块都拆分成独立的文件。

下面截图就是Unbundle打包的文件格式:

  1. entry.js就是 Global 部分定义 + RN App 入口;
  2. Unbundle文件是用于标识这是一个Unbundle包的 flag;
  3. 12.js13.js就是各个模块,文件名就是模块id
  4. 在业务执行,需要加载模块(require)的时候,就去磁盘查找该文件,读取、执行。

RN 里面加载模块流程说明,以 require(66666) 模块为例:

  1. 首先从__d<就是前文提到的define>的缓存列表里面查找是否有定义过模块66666,如果有,直接返回,如果没有走到下面第二步的nativeRequire
  2. nativeRequire根据模块id,查找文件所在路径,读取文件内容;
  3. 定义模块,_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以及其他配置信息,详见下图:

  1. main_module为当前业务模块入口模块id
  2. module_path为业务模块 JS 文件所在当前包的相对路径;
  3. 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执行引擎,我们定义了以下的一些生命周期状态。

  1. JS 执行引擎加载common.js的时候,处于Loading状态,如果加载出错,处于Error状态;
  2. 框架common.js加载结束,JS 执行引擎状态设置为Ready
  3. Ready状态的 JS 执行引擎被使用,则修改状态为Dirty
  4. Dirty状态的 JS 执行引擎达到一定条件<比如Dirty的JS执行引擎总数达到2个时候>,开始回收;
  5. 回收过程很简单,就是将加载(require)的业务代码,从__d<前文提到的define>的缓存模块数组里面删除掉就可以了,回收完成之后,又变成还原状态;

错误处理

RN 刚上线的前 2 个版本,我们发现有大量因为 RN 导致的 Crash,常见的错误有以下几种。

iOS 的 Crash 问题处理

iOS 的 Crash,基本都来自RCTFatalException,都是RCTFatal抛出错误信息所知,处理也相对简单,设置自己的Error Handler即可。

void RCTSetFatalHandler(RCTFatalHandler fatalHandler);

一般初次开发 RN 应用的开发人员,都没有留意这一点,其实查阅下 RN 的源代码,RCTFatal的注释写的还比较清楚,分析源码也可以发现在生产环境的时候,RCTFatal会直接Raise Exception,然后 Crash。

Android 的 Crash 问题处理

Android 的 Crash 点相对较多,大致会出现在以下几个场景:

  1. bundle加载过程中的RuntimeException
  2. JS 执行过程中的,处理NativeExceptionsManagerModule
  3. Native 模块执行出错,处理NativeModuleCallExceptionHandler
  4. so lib 加载失败,经典的java.lang.UnsatisfiedLinkError,这种问题,解决方案很简单,给System.load添加try catch,并且在catch里面做补偿,可以大大降低由此导致的 Crash;

对于第一点提到的RuntimeException,我们收集到的日志如下:

不能连接到dev server,看到之后很不明白,明明是生产环境,怎么会报这样的错误呢?

偶现的 JavaScript 执行出错,怎么会走到RuntimeException呢?

问题的解决很简单,这些RuntimeException,都是从ReactInstanceManagerImp.javacreateReactContext抛出来的,处理掉就可以了。

再补充一点,这些错误处理之后,都需要一层一层的传递到最上层的 UI 界面,这样才能友好地给用户提示。

ListView 性能问题

先来看一张截图,是从 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 读取到内存,但是只执行用到的模块,真正做到按需加载,相信对页面加载效率,会有更近一步的提升。