写点什么

近万字长文详述携程大规模应用 RN 的工程化实践

  • 2020-02-15
  • 本文字数:11174 字

    阅读完需:约 37 分钟

近万字长文详述携程大规模应用RN的工程化实践

一、RN 在携程的使用情况

2015 年 3 月 React Native iOS 开源,半年之后 Android 开源。携程于 2016 年 6 月份投入资源在 React Native 框架的预研,并于 8 月份正式上线,至今已有 2 年多。


随着业务使用的复杂度增加,各种问题随之而来,我们就这些问题一一提供解决方案,并建设相关配套系统来支撑业务开发团队使用。本文将从携程内部对 RN(ReactNative 简称,下同)的性能稳定性优化以及相关基础设施的建设来做分享。


截止 2018 年 9 月底,使用情况大致如下:



广泛使用: 生产环境总共有 104 个 RN 业务 Bundle,其中携程旅行 App 中运行的有 83 个,其它 21 个运行在公司内其它独立 App 中,比如 Trip.com、铁友智行等。从 2016 年 8 月份上线至今,PV 以同比 300%的增速增长,其日 PV 量已是传统 H5 Hybrid 技术的近 2 倍。



深度使用: 全流程使用,比如特价机票、特价酒店、国际机票、租车、旅拍等,已是全流程使用 RN 开发。复杂度高,火车票模块,5.8MB 的 js 代码(uglify 压缩后),超过 100 个页面,都打包在一个业务 Bundle 中。


总的来说,RN 在携程已经广泛使用于生产环境,并得到业务和用户的认可。

二、CRN 框架

我们基于 React Native 框架优化,定制成适合携程业务的跨平台开发框架 - CRN,提供从开发、发布、运维的全生命周期支持。



  • 开发框架,主要是提供在开发阶段的支持。包括工具 &文档、组件和解决方案、跨平台打通和代码托管功能。 工具主要包括 CLI 和 Packer,文档包括 API 文档和设计文档,跨平台主要是抹平平台差异组件间的 API,代码托管是为了方便业务团队,特别是新加入 CRN 开发的团队,可以参考已有业务代码快速上手。

  • 性能优化,主要是为了解决首屏渲染的性能问题和 RN 框架的稳定性问题。为了解决首屏渲染性能问题,我们先后开发了框架拆分和预加载、业务按需加载、业务预加载和渐进式渲染方案,稍后会就这些方案做详细介绍。

  • 发布运维,主要是提供发布系统和性能、错误监控平台,让业务开发同事能够有完备的系统去发现和解决线上问题。


下面会从这几个方面详细介绍。

2.1 开发框架

以下是我们的 crn-cli 脚手架,对 RN 原始的 CLI 进行二次包装,提供从工程创建,服务启动,在已集成框架的 App 运行 RN 代码等常用功能,方便开发人员快速上手。


Commands:   init                   建立并初始化CRN工程,可指定appId,默认为携程App   start                  启动CRN服务,默认端口5389   run-ios                运行指定appId的IOS App   run-android            运行指定appId的Android App   run-patch              执行patch,替换CRN修改过的lib文件   cli-update             更新cli版本   example                创建CRN组件和API调用Demo工程   aux                    增强型功能入口:log Server、本地打包、上传开发包等Options:   -h, --help             显示命令帮助   -v, --version          显示版本
复制代码


文档方面,我们提供 API 文档和设计文档



API 文档采用 YUI doc 根据代码注释自动生成,该文档中主要记录新增组件以及使用示例。



设计文档,主要包含一些组件/API 的设计文档,常见问题解决方案,业务开发常见问题都可以再该文档站点找到对应的解决方案。

2.2 组件和解决方案

提供 100 多个业务和公共组件支持,并保证跨平台提供一致 API。


三、CRN 性能优化

在具体介绍性能优化方案之前,先看 2 段 Demo 视频,两段视频是同一份代码的运行效果,一份使用原始版本 RN 打包运行,另一份使用 CRN 打包运行。选择同一台测试机(2015 年老款 SumSung S6 Edge+,Android7.0 系统),为确保环境尽可能一直,在每次运行 Demo 前,均清空所有后台程序。


https://v.qq.com/x/page/z081264pygu.html


原始版本 RN 运行 Demo


https://v.qq.com/x/page/n08125f8cs1.html


CRN 优化后运行 Demo


分享具体性能优化措施前,先来解释几个基本概念。


  • React Native 打包是符合 commonjs 规范的,参考下面的代码:


// moduleA.jsmodule.exports = function( value ){return value * 2;}
// moduleB.jsvar multiplyBy2 = require('./moduleA');var result = multiplyBy2(4);
复制代码


简单地说,模块必须通过 module.exports 导出对外的接口或者变量,通过 require()导入其他模块,并同步加载该导入的模块。


  • define


简化版 define 实现如下


function define(moduleId, factory) {    if (moduleId in modules) {        return;    }    modules[moduleId] = {        factory:factory,        hasError:false,        isInitialized:false,        exports: undefined    }}
复制代码


可以看到 define 仅仅是将模块代码嵌入到 factory 中,cache 到 modules 对象内部,并未真正执行。


  • require


简化版 require 实现如下:


function require(moduleId) {var module = modules[moduleId];return (module && module.isInitialized)?module.exports:    guardedLoadModule(moduleId, module);//代码执行,并赋值给module.exports}
复制代码


可以看到,require 是真正模块代码执行的点,JS 模块数越多,耗时越长。guardedLoadModule 内部会使用 try/catch 包裹去执行模块代码,此处可以捕获所有模块的代码异常,RN 内部的 js 错误,都是从此处抛出。


  • import


import 在 bundle 编译前后的示例代码如下:


/*源码*/import page1 from './src/Page1.js'import page2 from './src/Page2.js'
/*编译后*/var _Page = require(662); //662=./src/Page1.jsvar _Page2 = _interopRequireDefault(_Page);var _Page3 = require(663); //662=./src/Page2.jsvar _Page4 = _interopRequireDefault(_Page3);
复制代码


简单地说,编译后 import 等价于 require。


  • 页面加载流程



以上是一个 RN 页面加载的全流程,首选是 Native 容器的创建,接着是下载安装最新包(如果有的话),之后开始 CRN 框架(包含 Native 和 JS 组件)加载,框架加载完成之后,加载业务代码,计算页面虚拟 dom,通知 Native 进行页面首次渲染,如果有网络请求,请求完成之后,再次渲染。


灰色部分是可选的,真实 RN 页面的渲染性能包含 4、5、6 三部分,针对这三部分,我们提供了不同的性能优化方案。


  • CRN 框架加载:框架和业务代码拆分、框架代码预加载、JSC 执行引擎缓存

  • 业务代码加载:业务代码按需加载、业务代码预加载

  • 业务页面渲染:渐进式渲染、骨架图预渲染


接下来我们一一介绍。

3.1 CRN 框架加载的优化

先看下 react-native bundle 命令打包之后的 bundle 文件结构


//头部 - 全局变量定义(function(global) {global.require = _require;global.__d = define; /*...code... */})(typeof global !== 'undefined' ? global : typeof self !== 'undefined' ? self:this);
//中间 -- 各模块定义部分__d(/* demo/index.ios.js */function(global, require, module, exports) { var _React Native = require(12); // 12 = react-native var theCompnent = require(524); // 524 = ./main _React Native.AppRegistry.registerComponent('Demo', function () {return theCompnent;}); module.exports = theCompnent; }, 0, null, "crn-demo/index.ios.js");
__d(/* react-native-implementation */function(global, require, module, exports) { var React Native = {/*...code... */} module.exports = React Native; }, 12, null, "react-native-implementation");
// 尾部 -- 引擎初始化和入口模块执行;require(50); //50为InitializeJavaScriptAppEngine模块;require(0); //0为入口Component模块
复制代码


结构可以简化为三部分:


  • 为头部全局变量定义;

  • 中间框架/业务模块定义;

  • 尾部引擎初始化/入口函数调用;

3.1.1 框架和业务代码拆分

先来看看我们打包之后的文件目录结构


//框架包rn_common目录结构rn_common       ├── common_android.js  //Android CRN框架代码,包括RN+CRN扩展JS组件+常用第三方组件    ├── common_ios.js      //iOS CRN框架代码,包括RN+CRN扩展JS组件+常用第三方组件    └── pack.config        //打包日志文件,记录打包时间,RN版本,App版本等信息
//业务包rn_flight_booking目录结构rn_flight_booking ├── _crn_config_v2 //配置文件,记录业务代码所在文件夹,默认是js-modules,同时记录业务代码入口模块文件名 ├── _crn_unbundle //CRN打包格式标识文件,该文件存在时候,才当做CRN包格式加载 ├── assets/ //图片资源目录,定制过资源打包/加载流程,iOS/Android目录一致 ├── fonts/ //字体文件目录,每个js模块一个文件,文件名为模块ID.js ├── js-diffs/ //Android和iOS平台差异代码,Android优先加载该文件夹中的业务代码 ├── js-modules/ //业务js代码目录 └── pack.config //打包日志文件,记录打包时间,RN版本,App版本等信息
复制代码


rn_common 为框架包,可以再后台线程加载,业务包在进入业务的时候才开始加载。


打包部分:


  • 生成框架 jsbundle


业务代码拆分主要是把中间框架/业务模块定义给拆分开来,拆分的思路很简单,用一个空白页面作为入口点,AppRegistry.registerComponent 加载这个入口点。进入业务时,通过这个入口点页面去加载真实的业务代码。把这个空白的入口点页面作为框架的一部分,通过 react-native bundle 命令打包成框架 jsbundle。


  • 抽取业务 js 代码


对 React Native unbundle 的打包过程进行定制,首先让 iOS 支持 unbundle 打包(默认是不支持的), 将生成的业务 js 模块代码单独保存,每个 js 模块一个文件,文件名即为模块 ID.js;


  • js 模块加载优化


空白页面入口组件,要能加载(require)真实的业务代码,我们需要改造 RN 的 require 方法,简单修改 Native SDK 中的 JSCExecutor(RCTJSCExecutor.mm/JSCExecutor.cpp)文件,调整 nativeRequire 实现即可。

3.1.2 框架代码预加载

RN 框架 instance

RCTBridge/ReactInstanceManager(后文统称为 instance)是 RN 框架中核心的 2 个类,这个类分别控制不同平台的 JavaScriptCore 的执行,同时又都是各自平台 ReactView 的属性,View 的显示于事件靠它来驱动。


所以为了能做到后台预加载 js 代码,首先要做的就是解开台 ReactView 和 instance 之间的耦合解开,能让 instance 在后台独立加载。处理起来不复杂,只需要对 ReactRootView/RCTRootView 接口做简单调整即可。



上图是我们定义的 CRN 框架 instance 的生命周期状态:


  • 框架加载过程,标记为 Loading 状态

  • 框架加载完成,标记为 Ready 状态

  • 框架引擎被业务使用,标记为 Dirty 状态

  • 框架在加载或者业务使用过程中出了异常,会被标记为 Error 状态


App 启动,我们就会预创建一个框架引擎的 instance,创建完成,状态标记为 Ready 并缓存起来,进入业务时候,会优先使用这个缓存的 instance 去加载业务代码,这个时候进入业务页面,只有业务代码的加载执行时间。当这个缓存的 Ready 状态的 instance 被使用之后,后台立即再创建一个,以备后续业务使用。


根据线上数据统计,我们发现 95%的场景,都能直接使用到后台预创建好的框架 instance,或者是已经加载过业务的 instance。也就是说,进入业务页面,只有 5%的用户,需要耗时间加载 RN 框架代码。

3.1.3 业务 instance 缓存

对于加载过业务代码的框架 instance,在用户离开业务时候,会暂时缓存住,这样如果重复进入页面,少了业务代码的加载执行,打开速度提升明显。当暂存的加载过业务的 instance 数量超过 2 个时,会按照创建时间顺序,回收掉最早创建的 instance。根据线上数据统计,有 15%的场景,都会使用到的加载了业务代码的 instance。


框架代码的加载优化已基本完成,来看我们当时测试的一组数据。

3.1.4 一组数据


上图是 2016 年 10 月,基于 RN 0.30 版本,在 iPhone 6 和 Sony Xperia Z5 机型上,多次测试的平均数据。可以看到,优化后,首屏时间比原来都减少 45%左右。后续我们升级 0.41,0.51 版本,该优化都一直在做,方案和思路都是一样的。

3.2 业务代码加载优化

业务代码加载优化我们主要从 2 个方面考虑,业务代码按需加载和预加载,先简单解释两者的差别


按需加载:是进入业务模块时候,只加载对应页面的代码


预加载: 是尚未进入业务模块前,即把需要进入业务页面的代码在后台加载执行掉

3.2.1 业务代码按需加载

LazyRequire 按需加载方案


先来看一段我们初始化页面路由表的代码


import PageA from ("pages/PageA");import PageB from ("pages/PageB");import PageC from ("pages/PageC");import PageD from ("pages/PageD");
//设置页面路由表let pageList = [PageA, PageB, PageC, PageD];App.startApp(pageList);
复制代码


早期业务简单,页面数量少,上面的优化方案已经可以是 RN 基本达到 native 的体验,但是随着业务越来越复杂(当时有业务 bundle,包含 70 多个 Page js 代码 uglify 之后达到 3MB),首屏加载慢的问题又出来,为此我们实现一种懒加载的方案,进入业务时候,只加载当前需要显示的 Page 的代码, 对业务的使用非常简单,下面是我们懒加载的页面路由代码写法。


const PageA = lazyRequire("pages/PageA");const PageB = lazyRequire("pages/PageB");const PageC = lazyRequire("pages/PageC");const PageD = lazyRequire("pages/PageD");//设置页面路由表let pageList = [PageA, PageB, PageC, PageD];App.startApp(pageList);
复制代码


对业务开发来说,切换成本非常低,只需要使用 lazyRequire 函数替代 import 指令。怎么做到的呢,其实也很简单。


//LazyRequire函数定义,返回lazyModule对象LazyModule lazyRequire(path)
LazyModule = { load(); //代码真正执行的点,返回执行结果}
复制代码


细心的同学可能发现这里有个问题,lazyRequire 函数传入的文件相对路径,打包之后,还是相对路径,而打包完成之后,每个业务 js 模块都被打成模块 ID.js 文件,这会导致运行时查找不到这些业务页面的模块。是的,在打包过程中,需要开发一个 babel 插件,将 lazyRequire 函数例的文件路径,转换成模块 ID,实现方式和 import 的 babel 插件基本一致。


随着业务代码增加,进入首屏需要加载(require)的代码会增加,前面分析过,require 会导致 JS 代码的执行,是耗时的操作,最终导致首屏变慢。所以,我们就想,进入业务的时候,只加载第一个 Page 相关的代码,其他页面的,路由跳转过去的时候再加载。

Getter API 导出模块

我们先来看看 React Native 模块内的组件导出方式:


//原始代码如下//Module1.jsconsole.log("Start load module1");module.exports = {doJob:()=> {console.log("doJob called in module1");    }}
//Module2.jsimport Module1 from "./Module1";//执行结果:Start load module1
复制代码


这是最常见的模块导出和引用方式,和我们前面说的一样,import 的时候,实际上会去执行对应的代码。接下来,我们创建一个 common.js(文件名无限制),修改下模块的导出方式,参考下面的代码。


//common.jsmodule.exports = {get Module1() {return require('./Module1');    }}
//回到Module2.js的引用import Module1 from "./common";//执行结果:没打印任何日志
Module1.doJob();//执行结果: 打印以下两条日志//Start load module1//doJob called in module1
复制代码


可以看到,通过 ES5 的 getter API 来导出模块,在引用时,代码不会立即执行,直到导出对象真正使用时候,才开始执行。所以如果我们有自己的公共组件,多个业务都需要用到,那么使用 getter API 导出模块是一种不错的选择。其实 RN 里面的 ReactNative 模块导出方式也是这样,参考下面的代码。


const ReactNative = {get ActivityIndicator() { return require('ActivityIndicator'); },get ART() { return require('React NativeART'); },get DatePickerIOS() { return require('DatePickerIOS'); },get DrawerLayoutAndroid() { return require('DrawerLayoutAndroid'); },get Image() { return require('Image'); },get ListView() { return require('ListView'); },//...}module.exports = React Native;
复制代码


通过 getter API 导出模块实现按需加载是 ES5 默认支持的,对原始 RN 没有任何侵入性修改,是比较推荐的一种方案。


那我们为何需要 LazyRequire 呢?很明显,使用 getter API 导出替换 LazyRequire 是可行的,只是达到不了按需加载的功效了,因为在赋值页面路由表的时候,需要用到所有的 Page 对象,用到这些对象的时候,会直接触发所有 Page 的代码加载执行。

inlineRequire 方案

方案很简单,预先定义模块对象,赋值为 null,在使用时候判断对象是否为 null,null 时候则做真正的 require,进行模块加载。看一段简单示例代码。


let VeryExpensiveModule = null;
export default class Optimized extends Component { someEvent = ()=>{ if (VeryExpensiveModule == null) { //require('path').default, 动态加载模块代码 VeryExpensiveModule = require('./VeryExpensive').default; } }}
复制代码

3.2.2 工具和数据

为了能方便业务开发同事快速定位到具体是哪个 js 模块加载耗时长,以及具体的调用链是怎样的,我们开发了 CRN Require Profile Tool



如上图所示,业务开发同学,很容易就发现是哪个模块加载耗时长,需要使用按需加载。


按需加载方案是 2017 年,基于 RN 0.41 版本开发的,当时上线前我们也做过首屏性能测试, 数据是 iOS 模拟器上跑出来的,由于首次进入业务加载的页面数量猛降,所以首屏时间减少了 2/3。由于这个优化是在 JS 层做的优化,iOS、Android 性能提示基本一致。


3.3 业务代码预加载

经常有这样的业务场景,A 流程订单完成之后,有 B 产品推荐,A、B 业务代码在不同的 RN bundle 里面,A 业务开发完,希望能把 B 业务在后台加载掉,这样用户打开 B 业务首屏速度会更快。为此,我们提供了业务预加载方案。主要两个点,预加载和缓存。


预加载有前面框架代码拆分和预加载的基础,实现起来非常简单,基本没有改造成本。为了能让尽可能多的代码实现预加载,我们在 LazyRequire 里面添加逻辑,让在预加载状态模式下,LazyRequire 等价于 Require,强制加载。


缓存,先前业务代码的缓存是按照业务的 URL 作为 key 来存储的,预加载模式下为了尽可能提高缓存的命中率,我们将缓存的 key 统一成业务 bundle 名,同一业务,同一缓存,这么操作需要业务开发代码也要注意,避免全局变量的使用。


缓存的另外一个问题就是内存占用,我们在提供业务预加载的时候,用一个全局数组来缓存业务 instance,超过限制,或者内存警告时候,会按照 LRU 策略清理没有使用的 instance。实际测试下来,Android 平台,预加载一个业务,会增加 2MB 左右内存(包括框架和业务代码都加载完),而渲染一个正常页面,占用约 20MB 内存,其中最主要的内存被图片占用。


先前同事在开发这个方案的时候我没在意性能数据,简单测试了下,发现效果非常不错,对于一般页面,业务代码提前预加载后,性能可以达到和 native 基本一致。我们使用了荣耀 7X(千元机,性能偏中低端)进行测试,已经基本感知不到首屏加载和 native 有什么差别了。

3.4 业务页面渲染

我们发现,随着页面复杂度增加,渲染耗时逐渐增加,这也可以理解,要完成页面渲染,需要计算 vitrual dom 的 diff,传输数据给 native,如果数据传输有延迟,就会出现掉帧,为了让页面尽可能快的显示,我们需要简化首次渲染。


渐进式渲染


策略很简单,先渲染 header 部分,setTimeout 去渲染其余部分,如果是 listview/scrollview,先渲染屏幕可视区域,在滑动时候,再渲染其他区域。下面一个 demo 视频,我们看下。


https://v.qq.com/x/page/u08125tcr7w.html


骨架图


先渲染骨架图,由于骨架图相对简单,渲染很快,待请求数据返回后重新渲染界面。骨架图目前没有好的自动渲染框架,需要页面开发同学,根据页面样式,自行开发。

四、发布与运维

一个成熟完善的开发框架,是需要各种配套系统支撑的。我们也为 CRN 开发框架提供了多个内部系统,下面来介绍其中的主要几个。

4.1 发布系统


上图是我们的发布系统页面截图,除了常规的按照版本/平台/环境发布、灰度、回滚支持,我们还增加了发布结果和实时到达率的的报表,方便发布之后,对发布效果评估。


几点说明


1、不同环境,按照顺便发布,首先发布 FAT(开发环境)、测试通过再发 UAT(跨业务测试环境)、测试通过再发 PRD(生产)。真正的打包只在第一个环境打包,后续的环境都是直接发布前一环境的打包产物,避免重复打包导致的不一致问题,同时也提高发布效率;


2、跨 RN 版本,不支持同时发布,避免新版本 RN 代码发布到老的 RN 版本上,直接在发布系统选择版本的时候做了控制,不能选择 2 个不同 RN 版本的 App;


3、控制发布版本数量,创建发布单时候,可以选择多个版本,经常有发布的同学为了简单,一键勾选所有版本,实际上老版本可能用户量非常小,而回归测试却覆盖不到所有版本,为了避免老版本因为测试不重复导致的问题,我们将版本选择功能做了优化,按照 UV 数量排序,并在版本后面显示 UV 比例,同时默认只能选择 Top5 版本,如果要发布更多版本,需要点击更多,展开其他版本。


监控指标


1、发布结果:发布之后,分平台、App 版本展示下载到这个包的成功、失败次数,以及失败的原因分布。


2、实时到达率:这个是业务最应该关注的数据,数据直观的展示,发布之后,实时的有多少比例的用户已经用到最新包。



为了提高实时到达率,我们在打包过程中记录业务模块 ID 和文件名之间的映射,这样可以避免新增文件出现的的大量 JS 文件的文件名(即为模块 ID)变化,从而导致的差分包过大问题。做到只下发真实变更和新增的文件内容。通过线上数据分析,所有首页入口的 RN 模块,新版本发布之后,有 85%的实时到达率,二级及以上入口,实时到达率可以达到 97%。

4.2 性能报表

统计线上业务首屏加载的耗时趋势、分布和使用量,可以支持按照 App/版本/系统过滤查看。



首屏首次渲染完成的时间点,可以在在下面 2 个点添加事件,抛给外层统计。


//基于RN 0.51版本//Android ReactRootView.java 添加dispatchDraw方法protected void dispatchDraw (Canvas canvas){//相对准确,可能会调用多次,内部要做好判断}
//iOS RCTRootContentView.java- (void)insertReactSubview:(UIView *)subview atIndex:(NSInteger)atIndex{//准确,RN自带的profile工具里面的TT时间,也是以此处为结束点}
复制代码

4.3 错误报表

用于收集客户端上报的 RN 错误,包括 JS 执行异常,或者是 native runtime 的一些异常,在业务模块发布之后,必须要到此平台确认自己的发布稳定性是否正常。


除了常规的版本、业务、平台功率,我们在错误堆栈详情页面,还将当前出错的业务包版本和打包记录关联起来,方便开发人员排查问题。


五、其他实践经验

5.1 版本升级

从 2016 年 8 月至今,总共更新 0.28-0.30-0.41-0.51 四个官方 RN 版本,除 0.28 是调研阶段仅使用两个月,其他都使用半年以上。整体升级相对可控,除 0.41 升级 0.51,因为有 PopertyType 组件的移除,需要业务做些适配,其他版本升级对业务都是基本透明的,仅需常规回归测试。


升级流程上,首先是框架团队前期验证(包括打包,SDK 定制,发布,监控全流程确认)、制定升级方案和时间点,接下来是业务团队配合升级和新版本发布,最后是框架团队确认所有业务都在新的 RN 版本重新打包发布过。


升级成本来说,框架团队大约需要 3 名工程师(iOS/Android/前端各 1 人),2-3 周时间,业务升级和回归测试,一般可在一周内完成。


升级频率上,由于使用的业务团队太多,频繁的升级会对业务造成影响,为了尽可能对业务开发友好,大约 8-12 个月会升级一个 RN 重要版本。当然,如果是有重大的性能升级,比如 RN frabic 的重构版本,我们也会第一时间跟进升级。

5.2 第三方组件版本管理

先看看 npm 模块的版本规则:major.minor.patch, package.json 支持模糊版本,比如>,>=,<,<=,~,^,.x,*, 其他都比较好理解,~,^简单解释下(完整本的版本说明参考 semver )


//举例说明~0.2.0 匹配 [0.2.0, 0.3.0), 有minor, 最大版本为minor+1, major不变,patch为0~0     匹配 [0.0.0, 1.0.0), 无minor, 最大版本为major+1, minor,patch为0^0.2.0 匹配 [0.2.0, 0.3.0), 最大版本为左侧第一个不为0的版本号+1^1.2.0 匹配 [1.2.0, 2.0.0),
复制代码


我们再看下 react-native-recyclerview-list 这个组件, 组件版本和依赖的 RN 版本关系如下。



如果我们使用的 RN 是 0.47 版本,对这个库的依赖方式写成^0.2.0, 当组件版本发布到 0.2.2 时候,都使用的很正常,一旦 0.2.3 版本发布,如果再打包发布,则会出现不兼容问题,线上会出大量 JS 报错。


我们就在生产环境出现过类似问题。为了避免类似问题,我们在打包之前做了 preBuildCheck,检测第三方组件的依赖版本,凡是不使用固定版本的,直接报错。

5.3 分平台打包

目的是抹平组件的平台差异,解决资源加载路径不一致的问题。很长一段时间,我们 iOS/Android 的业务代码,只打一次包,以 iOS 平台打包。因为涉及到 Native 代码的新组建的引入,都是由框架团队控制,所以一直以来都没出什么问题。直到公司内部独立 App,他们引入的第三方组件 iOS/Android 有差异,导致发布之后在 Android 上运行有问题。


分平台打包之后,先打包 iOS,再打包 Android,将差异代码存储在 js-diff 目录,加载时,Andorid 先在 js-diff 中查找模块,查找得到直接使用,如果查找不到,再在默认的 js-modules 文件夹中查找。iOS 则只在 js-modules 文件夹中进行模块查找。

5.4 稳定性优化

iOS 平台相对简单,注意解决以下两个 API 相关问题后,绝大部分问题都好处理。


//自己注册错误handler,在此处去进行日志上报,并持续优化void RCTSetFatalHandler(RCTFatalHandler fatalHandler);
//iOS所有错误都是通过此次抛出void RCTFatal(NSError *error);
复制代码


iOS 所有错误都是通过此次抛出 void RCTFatal(NSError *error); ``` iOS RN 注意事项:


  • 必须要自己注册错误处理 handler,否则一旦有 RCTFatal 抛出错误,生产环境会有 Crash

  • 所有的错误都是 RCTFatal 抛出,为了方便排查问题,需要记录 error 的来源


Android RN 相对复杂,主要注意事项:


  • so 加载失败。简单处理可以在原有的 LoadLibrary 加上 try/catch,并在 catch 中再 load 一次,能大幅度降低该问题导致的 Crash;

  • ReactInstanceManager 创建过程中的 Native 异常,是通过 DevSupportManager 传递出去,需要处理 DefaultNativeModuleCallExceptionHandler 的 handleException 方法

  • JS 执行出错,都是通过 ExceptionsManagerModule 模块抛出,所以需要将该错误和 ReactInstanceManager 相关联,并抛给上层;

  • libjsc.so Crash,如果有做 Native Crash 收集,会在后台系统看到不少 libjsc 相关报错,这是由于 RN 自带的 JavaScriptCore 版本为 2014 年的版本,兼容性和稳定性较差,建议参考开源的 jsc-android-buildscrips 项目,将 JavaScriptCore 升级到 2017 年 11 月的版本(WebkitGTK Revision 225067),我们升级到该版本后,发现该错误降低了 90%;

六、总结

CRN 框架对原生 RN 的大量底层改造优化,解决了性能和稳定性两大核心问题,从落地效果来看,其性能可以做到和 Naitve 基本一致水平,而开发成本却大幅降低。


CRN 框架已在业务团队中广泛使用,为业务的快速迭代提供了强有力支持。对于规模化业务开发团队,使用 RN 作为跨平台开发的解决方案,是切实可行的选择。


2019 年,我们计划根据开发资源情况,适时开源 CRN 框架的部分模块。


作者介绍


赵辛贵,携程无线平台研发部开发总监。2013 年加入携程,主要负责 App 基础框架研发相关工作,目前重点关注 React Native 技术在公司的推广和研发支持、无线框架和工程架构升级。


本文转载自公众号携程技术(ID:ctriptech)。


原文链接


https://mp.weixin.qq.com/s/Z1GUJW3qBqDGH1jnGt5qAg


2020-02-15 17:281859

评论

发布
暂无评论
发现更多内容
近万字长文详述携程大规模应用RN的工程化实践_技术管理_赵辛贵_InfoQ精选文章