携程是如何做 React Native 优化的

bnxj5550 7年前
   <p>React Native(下文简称RN)开源已经一年多时间,国内各大互联网公司都在使用,携程也在今年5月份投入资源开始引入,并推广给多个业务团队使用,本文将会分享我们遇到的一些问题以及我们的优化方案。</p>    <h2><strong>一、背景和使用情况介绍</strong></h2>    <p><strong>为什么会引入React Native?</strong></p>    <p><strong>1. AppSize占用</strong></p>    <p>携程旅行App从11年开始开发,至今已有5年多时间,随着各项业务功能的全面移动化,以及公司mobile first策略的指引下,App功能越来越多,越来越臃肿,Size达到将近100MB,而同样功能,使用RN开发,Size远远小于Native开发,RN的引入,可以支持我们App的可持续健康的发展。</p>    <p><strong>2、 用户体验佳</strong></p>    <p>RN通过JavascriptCore解析Javascript模块,转换成原生native组件渲染,相比H5页面不再局限于WebView、渲染性能长足提升,运行用户体验可以媲美native。</p>    <p><strong>3. 相对成熟</strong></p>    <p>Android和iOS的RN都已经开源,原生提供的组件和API相对丰富,且跨平台基本一致,对外接口也趋于稳定,适合业务开发。</p>    <p><strong>4. 支持动态更新</strong></p>    <p>纯原生的开发,android上通过插件化框架,可以实现动态加载远端代码。但是在iOS上,因为系统限制,不能动态执行远端下载的Native代码,而RN完全满足该需求。</p>    <p><strong>5. 跨平台</strong></p>    <p>RN提供的API和组件,大多能跨平台使用,对少数不支持的组件,我们再做二次封装抹平,可以让业务开发人员开发一份代码,运行在iOS&Android 两个平台上。这样能够大大提供开发效率,降低开发维护成本。</p>    <h3><strong>如何引入?</strong></h3>    <p>基于RN 0.30版本,开发了支持携程业务团队快速便捷开发的CRN框架,框架主要从以下几个方面着手。</p>    <p>1. 工具</p>    <ul>     <li> <p>cli工具,负责CRN工程创建,运行;</p> </li>     <li> <p>pack工具,负责打包;</p> </li>    </ul>    <p>2. 控件</p>    <ul>     <li> <p>对RN官方提供的API和组件,实现跨平台支持;</p> </li>     <li> <p>新增携程业务相关的API和组件,方便业务接入;</p> </li>    </ul>    <p>3. 稳定性、性能优化</p>    <ul>     <li> <p>RN页面加载提速,实现秒开;</p> </li>     <li> <p>稳定性提升,消除RN导致的crash;</p> </li>    </ul>    <p>4. 发布</p>    <ul>     <li> <p>统一管理所有RN业务的相关发布;</p> </li>     <li> <p>差分增量支持,尽可能减小文件大小;</p> </li>    </ul>    <p>除此之外,我们还从文档以及技术支持等方面,支撑其作为一个完整的产品开发框架。</p>    <h3><strong>业务的使用</strong></h3>    <p>下面一幅图说明了RN在携程业务中的使用情况,总共4个版本的开发时间,每个版本大约1个月时间。</p>    <p style="text-align: center;"><img src="https://simg.open-open.com/show/dd122dacc334f04783e25366d6bd8737.png"></p>    <p>前面2个版本主要是CRN基础功能完成和线上验证,后面2个版本稳定性优化和API跨平台抹平基本完成,业务数和页面数量猛增。</p>    <h2><strong>二、遇到的问题和优化</strong></h2>    <h3><strong>RN常见问题介绍</strong></h3>    <p>所有做ReactNative开发的团队,或多或少都面临着以下4个问题需要解决。</p>    <p>1. 打包出来的JSBundle过大;</p>    <p>2. 首次进入RN页面加载缓慢;</p>    <p>3. 稳定性不够,有大量因为RN导致的crash;</p>    <p>4. 大数据量时候listview加载卡顿;</p>    <p>接下来,我们就这四个问题来一一探讨。</p>    <p style="text-align: center;"><img src="https://simg.open-open.com/show/fa48e4e25a1d32eac850e1c36437489a.png"></p>    <p>从这张图中可以看出,最大的瓶颈在JS init+Require,这块时间就是JSBundle的执行时间,为了提升页面加载速度,这块时间我们需要想办法优化。</p>    <h3><strong>JSBundle文件过大&页面加载慢</strong></h3>    <p>先来说一组数据,一个Helloorld的App,如果使用0.30RN 官方命令react-native bundle打包出来的JSBundle文件大小大约为531KB,RN框架JavaScript本身占了530KB, zip压缩之后也有148KB。</p>    <p>如果只有一两个业务使用,这点大小算不了什么,但是对于我们这种动辄几十个业务的场景,如果每个业务的JSBundle都需要这么大的一个RN框架本身,那将是不可接受的。</p>    <p>因此,我们需要对RN官方的打包脚本做改造,将框架代码拆分出来,让所有业务使用一份框架代码。</p>    <p>开始拆分之前, 我们先以HelloWorld的RNApp为基础介绍几个背景知识。</p>    <p style="text-align: center;"><img src="https://simg.open-open.com/show/71c0d5c32f8bb80c5a2729de4be5d3b4.jpg"></p>    <p>上述是一个HelloWorld RNApp代码的结构,基本分为3部分:</p>    <p>头部:各依赖模块引用部分;</p>    <p>中间:入口模块和各业务模块定义部分;</p>    <p>尾部:入口模块注册部分;</p>    <p style="text-align: center;"><img src="https://simg.open-open.com/show/6bfdd988a16bcf041593fd7123c9b5a9.jpg"></p>    <p>上述是HelloWorld RNApp打包之后JSBundle文件的结构,基本分为3部分 头部:全局定义,主要是define,require等全局模块的定义; 中间:模块定义,RN框架和业务的各个模块定义; 尾部:引擎初始化和入口函数执行;</p>    <p>__d是RN自定义的define,符合 ,__d后面的数字是模块的id,是在RN打包过程中,解析依赖关系,自增长生成的。</p>    <p>如果所有业务代码,都遵照一个规则:入口JS文件首先require的都是react/react-native, 则打包生成的JSBundle里面react/react-native相关的模块id都是固定的。</p>    <h3><strong>拆分方案一</strong></h3>    <p>基于上面2点背景知识介绍,我们很容易发现,如果将打包之后的JSBundle文件,拆分成2部分(框架部分+业务模块部分),使用的时候合并起来,然后去加载,即可实现拆分功能。</p>    <p>具体实现步骤:</p>    <p>1、创建一个空工程,入口文件只需要2行代码,require react/react-native即可;</p>    <p>2、使用react-native bundle命令,打包该入口文件,生成common.js;</p>    <p>3、使用react-native bundle打包业务工程(有一点要保证,业务工程入口文件前面2行代码也是require react/react-native), 生成business_all.js;</p>    <p>4、开发工具,从business_all.js里面删除common.js的内容,剩下的就是business.js;</p>    <p>5、App加载的时候将common.js和business.js合并在一起,然后加载;</p>    <p>貌似功能完成,可是回到Dive into React Nativeperformance, 这么做还是优化不了JSBundle的执行时间,因为我们不能把拆分开的2个文件分别执行,因为加载common.js会提示找不到RNApp的入口,先执行business.js,会提示一堆依赖的RN模块找不到。</p>    <p>显然,这种拆分方式不能满足我们这种需要。</p>    <p>那这个方案就完全没有价值吗?不是的,如果你做的是一个纯RNApp,native只是一个壳,里面业务全是RN开发的,完全可以使用这种方式做拆分,这种方案简单,无侵入,实现成本低,不需要修改任何RN打包代码和RN Runtime代码。</p>    <h3><strong>拆分方案二</strong></h3>    <p>RN 框架部分文件(common.js)大小530KB,如此大的js文件,占用了绝大部分的JS执行时间,这块时间如果能放到后台预先做完,进入业务也只需执行业务页面的几个JS文件,将可以大大提升页面加载速度,参考上面的RN性能瓶颈图,预估可以提升100%。</p>    <p>按照这个思路,能后台加载的JS文件, 实际上是就是一个RNApp,因此 我们设计了一个空白页面的FakeApp,这个FakeApp做一件事情,就是监听要显示的真实的业务JS模块,收到监听之后,渲染业务模块,显示页面。</p>    <p>FakeApp 设计如下:</p>    <p style="text-align: center;"><img src="https://simg.open-open.com/show/7f086afa45c9457c9ed187be4aa28853.jpg"></p>    <p>为了实现该拆包方案,需要改造react-native的打包命令;</p>    <p>1、基于FakeApp打common.js包的时候, 需要记录RN各个模块名和模块id之间的mapping关系;</p>    <p>2、打业务模块包的时候,判断,如果已经在mapping文件里面的模块,不要打包到业务包中</p>    <p>改造页面加载流程:</p>    <p>1、因为要能够后台加载,所以需分离UI和JS加载引擎<iOS-RCTBridge, Android-ReactInstanceManager>;</p>    <p>2、进入业务RN页面时候,获取预加载好的JS引擎,然后发送消息给FakeApp,告知该渲染的业务JS模块;</p>    <p>通过后台预加载,省去了绝大部分的JS加载时间,似乎问题已经完美解决。</p>    <p>但是,如果随着业务不断膨胀,一个RN业务JS代码也达到500KB,进入这个业务页面,500多KB JS文件读取出来,执行,整个JS执行的时间瓶颈会再次出现。</p>    <h3><strong>拆分方案三</strong></h3>    <p>正在此时,我们研究RN在非死book App里面的使用情况,发现了Unbundle,简单点说,就是将所有的JS模块都拆分成独立的文件。</p>    <p>下面截图就是unbundle打包的文件格式:</p>    <p style="text-align: center;"><img src="https://simg.open-open.com/show/60e6c24e93e29eba33e46c7ec3bbaa55.jpg"></p>    <p>1、entry.js就是global部分定义+RNApp入口;</p>    <p>2、UNBUNDLE文件是用于标识这是一个unbundle包的flag;</p>    <p>3、12.js,13.js就是各个模块,文件名就是模块id;</p>    <p>4、在业务执行,需要加载模块(require)的时候,就去磁盘查找该文件,读取、执行。</p>    <p>RN里面加载模块流程说明,以require(66666)模块为例:</p>    <p>1、首先从__d<就是前文提到的define>的缓存列表里面查找是否有定义过模块66666,如果有,直接返回,如果没有走到下面第二步的nativeRequire;</p>    <p>2、nativeRequire根据模块id,查找文件所在路径,读取文件内容;</p>    <p>3、定义模块,_d(66666)=eval(JS文件内容),会将这个模块ID和JS代码执行结果记录在define的缓存列表里面;</p>    <p>打包通过react-native unbundle 命令,可以给android平台打出这样的unbundle包。</p>    <p>顺便提一下,这个unbundle方案,只在android上有效,打ios平台的unbundle包,是打不出来的,在RN的打包脚本上有一行注释,大致意思是在iOS上众多小文件读取,文件IO效率不够高,android上没这样的问题,然后判断如果是打iOS的unbundle包的时候,直接return了。</p>    <p>相对应的,iOS开发了一个prepack的打包模式,简单点说,就是把所有的JS模块打包到一个文件里面,打包成一个二进制文件,并固定0xFB0BD1E5为文件开始,这个二进制文件里面有个meta-table,记录各个模块在文件中的相对位置,在加载模块(require)的时候,通过fseek,找到相应的文件开始,读取,执行。</p>    <p>在Unbundle的启发下,我们修改打包工具,开发了CRNUnbunle,做了简单的优化,把众多零散的JS文件做了简单的合并。</p>    <p style="text-align: center;"><img src="https://simg.open-open.com/show/4c740c10d7dce1f73f77f9c77a6d6571.jpg"></p>    <p>将common部分的JS文件,合并成一个common_ios(android).js.</p>    <p>_crn_config记录了这个RNApp的入口模块ID以及其他配置信息,详见下图:</p>    <p style="text-align: center;"><img src="https://simg.open-open.com/show/592a8ec51d9caa401c01645252a290f2.jpg"></p>    <p>1、main_module为当前业务模块入口模块ID;</p>    <p>2、module_path为业务模块JS文件所在当前包的相对路径;</p>    <p>3、666666=0.js,说明666666这个模块在0.js文件里面;</p>    <p>做完这个拆包和加载优化之后,我们用自己的几个业务做了下测试,下图是当时的测试验证数据。</p>    <p style="text-align: center;"><img src="https://simg.open-open.com/show/6a4f32757229dc4532193ce8c0c03479.jpg"></p>    <p>可以看出,iOS和android基本都比官方打包方式的加载时间,减少了50%。</p>    <p>这是自己单机测试的数据,那上线之后,数据如何呢?</p>    <p>下图,是我们分析一天的数据,得出的平均值<排除掉了5s以上的异常数据,后面实测下来5s以上数据极少>;</p>    <p style="text-align: center;"><img src="https://simg.open-open.com/show/e6976d7f6afe621b16ba417cefeae688.png"></p>    <p>看到这个数据,发现和我们自己测试的基本一致,但是还有一个疑问,加载的时间分布,是否服从正态分布,会不会很离散,快的设备很快,慢的设备很慢呢?</p>    <p>然后我又进一步分析这一天的数据,按照页面加载时间区间分布统计。</p>    <p style="text-align: center;"><img src="https://simg.open-open.com/show/987b920dc21afc8dcda2bec26264ef79.jpg"></p>    <p>看图上数据,很明显,iOS&Android基本一致,将近98%的用户都能在1s内加载完成页面,符合我们期望的正态分布,所以bundle拆分到此基本完成。</p>    <p>关于这个数据,补充一下,先前看业到一篇58同城同学分享的 的文章,里面也曾提到他们业务页面加载时间的数据,有兴趣的同学可以去比较下。</p>    <h3><strong>页面加载优化</strong></h3>    <p>按照上述的拆包方案实现后,我们的RN页面加载流程大致是这样的。</p>    <p style="text-align: center;"><img src="https://simg.open-open.com/show/6bb763e2ab9ed45d6e8ad4828a5fc42e.jpg"></p>    <p>从上文的优化可以看出,缓存了common.js部分的JS执行引擎(iOS RCTBridge, Android ReactInstanceManager),页面加载可以大大提速,那对于已经被业务使用过的JS执行引擎,该如何处理呢?</p>    <p>缓存,还是缓存,不要立即释放,等符合一定条件之后,再释放。</p>    <p>对JS执行引擎,我们定义了以下的一些生命周期状态。</p>    <p style="text-align: center;"><img src="https://simg.open-open.com/show/ba70366d6824f63ec51869f356a35a9d.jpg"></p>    <p>1、JS执行引擎加载common.js的时候,处于loading状态,如果加载出错,处于Error状态;</p>    <p>2、框架common.js加载结束,JS执行引擎状态设置为Ready;</p>    <p>3、Ready状态的JS执行引擎被使用,则修改状态为Dirty;</p>    <p>4、Dirty状态的JS执行引擎达到一定条件<比如Dirty的JS执行引擎总数达到2个时候>,开始回收;</p>    <p>5、回收过程很简单,就是将加载(require)的业务代码,从__d<前文提到的define>的缓存模块数组里面删除掉就可以了,回收完成之后,又变成还原状态;</p>    <h3><strong>错误处理</strong></h3>    <p>RN刚上线的前2个版本,我们发现有大量因为RN导致的crash,常见的错误有以下几种。</p>    <p><strong>iOS的crash问题处理</strong></p>    <p style="text-align: center;"><img src="https://simg.open-open.com/show/eb46d391b33e6e3e21ce9933009b8b2c.jpg"></p>    <p>iOS的crash,基本都来自RCTFatalException,都是RCTFatal抛出错误信息所知, 处理也相对简单, 设置自己的Error Handler即可。</p>    <p>void RCTSetFatalHandler(RCTFatalHandler fatalHandler);</p>    <p>一般初次开发RN应用的开发人员,都没有留意这一点,其实查阅下RN的源代码,RCTFatal的注释写的还比较清楚,分析源码也可以发现在生产环境的时候,RCTFatal会直接Raise Exception,然后crash。</p>    <p style="text-align: center;"><img src="https://simg.open-open.com/show/f8fe1d49044852af8ae34b467a24174b.png"></p>    <p><strong>Android的crash问题处理</strong></p>    <p>Android的crash点相对较多,大致会出现在以下几个场景。</p>    <p>1、bundle加载过程中的RuntimeException;</p>    <p>2、JS执行过程中的,处理NativeExceptionsManagerModule;</p>    <p>3、native模块执行出错, 处理NativeModuleCallExceptionHandler</p>    <p>4、so lib加载失败,经典的java.lang.UnsatisfiedLinkError, 这种问题,解决方案很简单,给System.load添加try catch,并且在catch里面做补偿,可以大大降低由此导致的crash;</p>    <p>对于第一点提到的RuntimeException,我们收集到的日志如下:</p>    <p>不能连接到dev server,看到之后很不明白,明明是生产环境,怎么会报这样的错误呢? </p>    <p style="text-align: center;"><img src="https://simg.open-open.com/show/8c411c8755cfcf6b459f8e0366db2b80.jpg"></p>    <p>偶现的Javascript执行出错,怎么会走到RuntimeException呢? </p>    <p>问题的解决很简单,这些RuntimeException,都是从ReactInstanceManagerImp.java的createReactContext抛出来的, 处理掉就可以了。</p>    <p>再补充一点,这些错误处理之后,都需要一层一层的传递到最上层的UI界面,这样才能友好的给用户提示。</p>    <p><strong>ListView性能问题</strong></p>    <p>先来看一张截图,是从RN提供的UIExplore demo跑出来的。</p>    <p style="text-align: center;"><img src="https://simg.open-open.com/show/c30102aab512fdde4cfe6496def480ba.jpg"></p>    <p>可以清楚的看到,超出屏幕的条目,依然被渲染了。没有实现cell重用,导致数据量大时候,卡顿。</p>    <p>为适应大数据量listview的场景,我们专门安排资源,开发了可重用cell的CRNListView,iOS借鉴了第三方的 的实现,开发了可重用cell的listview,接口和官方原生的基本一致,Android借鉴iOS的方案,采用RecyclerView实现了类似的可重用cell的listview,同时我们还做了一些扩展,把常用的下拉刷新,载入更多,右侧字母索引栏等功能,都增加了进去。</p>    <p>实际测试下来,数据量少时候,和RN提供的listview,性能基本一致,但当数据量大时候,CRNListView优势明显,下面这张图,是我们在android上的测试数据。</p>    <p style="text-align: center;"><img src="https://simg.open-open.com/show/5ca82d1d1a6fa9768f25b3f0db01952e.png"></p>    <h2><strong>三、下一阶段的规划</strong></h2>    <p><strong>1. CRN-Web的开发</strong></p>    <p>同样的功能,CRN一套代码可以在iOS和android 2个平台运行。但对于业务开发团队,他们还需要维护H5平台同样的功能,如果我们能够将CRN代码,通过类似webpack这样的工具,直接转换过去就能在H5平台上运行起来,就可以做到一套代码,三端运行,可以大大降低业务团队的开发维护成本。</p>    <p>目前,我们已经再拿一些业务的CRN代码做转换验证,初步验证可行,还在持续优化完善中。</p>    <p><strong>2. 单JS执行引擎的实现</strong></p>    <p>RN还有一个比较大的性能瓶颈在于内存耗用大.做过这样的测试,在一个HelloWorld的RN工程里面,打开一个Native/RN/H5 Hybrid的HelloWorld页面,native显示页面内存占用0.2MB,RN占用10MB,H5 Hybrid占用20MB。如果大量业务都使用RN开发,JS执行引擎大量创建,会耗费大量内存,但是从JS执行引擎的执行过程,运行逻辑来说,只要做好业务隔离,完全是可以在一个执行引擎里面运行多个业务功能的JS代码的。我们正在做相关尝试,相信在未来1-2个版本时间,可以完成线上验证。</p>    <p><strong>3. AMD模式的加载尝试</strong></p>    <p>RN打包默认是 ,整个JSBundle一次读入内存,一次全部执行完成,所以耗费大量时间。如果能够用AMD模式改造,JSBundle读取到内存,但是只执行用到的模块,真正做到按需加载,相信对页面加载效率,会有更近一步的提升。</p>    <p> </p>    <p> </p>    <p>来自:https://mp.weixin.qq.com/s?__biz=MjM5MDI3MjA5MQ==&mid=2697265573&idx=2&sn=3a560b14da608cea5bdff193e9d7cbdf&chksm=8376fe91b4017787c46fc854dd9a96e6ece6b2e14c00d1aded4b741cbeac0ca29884fe1cabc7&mpshare=1&scene=1&srcid=1111QGSJAxeh3Mu0vmRF78wn&pass_ticket=GrN3ApiPKLBLUCaaxhFTb57AI0k64rlufcHG3leafl4=</p>    <p> </p>