基于拆分包的React Native在iOS端加载性能优化
自从Facebook于2015年在React Conf大会上推出React Native,移动开发领域就掀起了一股学习与项目实践的热潮。ReactNative不仅具有良好的Native性能,更具备web快速迭代的能力。这两大特性使得React Native在推广的过程中顺风顺水,而且在国内互联网公司的应用比国外还火热。58 APP从2016年就开始基于ReactNative进行项目实践,并已经对外进行了一些分享。目前项目已进入React Native深度研究与实践阶段。
在ReactNative深度实践的过程中,一个关键的问题是React Native页面的加载性能。如果不对这部分进行处理,在低端机上很容易出现短暂的空白,影响用户体验。在React Native加载性能优化方面,业界已经有了一些讨论和解决方案,但在针对问题解决的系统性和可操作性方面还有所欠缺。
本文将基于主流的拆分包思想,系统性地介绍我们在iOS端处理React Native加载性能问题的经验,以期给同行提供一些借鉴,避免重复趟坑。
拆分包实现方案一:为什么要拆分包:基于完整JSBundle加载存在的问题
58同城具体将React Native应用在项目中大概是2016年初,当时主要参考的资料是FaceBook提供的React Native文档以及官方Demo。按照文档的理解,创建RN页面的只需要创建对应的RCTRootView并将其添加到对应的Native视图中即可,因为RCTRootView是一个UIView的容器,它承载着React Native应用,因此如何创建RCTRootView成为了解决问题的关键。根据根据官方API,如下图1所示:
图1RCTRootView API从API文档可以看出,创建RCTRootView必须创建对应的RCTBridge,RCTBridge是JS与Native通信的桥梁,因此问题的关键转化为了如何创建RCBridge。查看React Native源码发现,如下图2所示:
图2 RCTBridge API从API的接口可以看出,参数中的bundleURL既可以是远程服务器具体的、完整的、可执行的jsbundle的地址,也可以是本地完整的jsbundle对应的绝对路径,那么该如何选择使用哪种bundleURL?
首先对比下两种bundleURL优缺点:
1、就读取jsbundle文件耗时而言。读取远程服务器的jsbundle首先要建立网络连接,然后再读取jsbundle文件,而且依赖用户当时的网络环境状况,增加了不稳定性,显然使用本地bundleURL在时间方面更具优势。
2、就实现jsbundle文件热更新成本而言。远程服务器中的bundleURL可以实时更新不依赖native的发版。而使用本地的bundleURL,若要实现实时更新则需要一套完整的热更新平台支持。显然远程服务器的bundleURL更具优势。
3、就用户的使用APP成本而言。远程服务器的bundleURL在每次进入RN页面时都会消耗流量,而本地bundleURL则不需要消耗用户流量或者仅仅在用户第一次加载RN页面的时候消耗流量,减少用户的使用成本。显然就用户使用成本而言本地bundleURL更具优势。
综上所述,使用本地的bundleURL能更好的减少读取本地JSBundle时间以及用户使用APP的成本,提高用户体验,增强用户黏性。
但是随着使用React Native业务场景的增多,RN页面数量也随之增加,与之对应的是JSBundle文件增多。复杂的业务逻辑也会导致JSBundle体积越来越大,最直接影响就是App size增大。以实际数据为例:
一个ReactNative页面对应的完整JSBundle文件一般为700KB,如果项目中存在300个React Native页面则需要内置的资源就会增加210MB(700KB*300),显然这是无法接受的!因此如何减少内置资源体积大小是当时制约React Native能否应用到项目中的一个关键因素。在此背景下引出了引出了方案一的设计,首先了解下方案一的拆包思想。
拆分包基本思想:
通过分析各ReactNative页面的JSBundle文件发现一个完成的ReactNative页面代码结构可以分为模块引用、模块定义、模块注册三部分。其中模块引用主要是全局模块的定义,模块定义主要是组件的定义(原生组件、自定义组件),模块注册主要是初始化以及入口函数的执行。
通过对比发现,不同的JSBundle文件包含着大量重复的代码,那么试想下能否通过优化打包脚本来对JSBundle进行优化,将框架本身的内容从完整的JSBundle中抽离出来只剩下纯业务的JSBundle文件,等到真正需要加载React Native页面的时候再将业务的JSBundle文件与重复的JSBundle文件进行合并,生成一个完成的、可执行的完整的文件,然后进行加载。事实证明这种方案是可行的,也即是项目中使用的拆分方案JSBundle的拆分与合并,简单来讲如下图3、图4所示:
图3 图4
图3 FE拆分 图4 Native合并
简单解析一下这两个图:
JS端拆分:在打包阶段,通过特定的策略将一个完整的JSBundle拆分成两个JSBundle。
Native端合并:Native端通过文本处理,将Common部分的JSBundle与业务部分的JSBundle合并成一个文件。
拆分包实现方案一
基于以上拆分包的思想,我们可以得出所谓拆分方案就是JS端将完整的JSBundle文件通过脚本拆分为Common.jsbundle文件和Bussiness.jsbundle文件。Common.jsbundle文件是指包含React Native基础组件以及相关解析代码的JS文件,Bussiness.jsbundle文件是指包含业务代码的JS文件。Native端通过内置或者热更新平台下发的方式获取Common与Bussiness文件,待真正需要展示React Native页面时通过合并的方式生成一个完整的JSBundle文件并加载
JS 端如何实现的拆包
首先我们了解下JS端如何进行jsbundle文件的拆分?整体流程如下图5所示:
图5 FE端JSBunlde拆分整体流程1、如何获取common.jsbundle文件。
-
a)首先通过React Native提供的指令react-nativeinitAwesomeProject 来创建一个空的工程
-
b)然后根据WBRN打包平台生成jsbundle文件,由于该文件不包含任何业务代码,所以该文件就是所需要的common.jsbundle文件。具体使用的指令如下:react-native bundle--entry-file ./index.ios.js --dev false --bundle-output common.bundle--bundle-encoding utf-8 --platform "ios" 。
2、如何获取bussiness.jsbundle文件。
-
a)首先,不同的React Native页面通过WBRN打包平台生成不同的、完整的complete.jsbundle文件。
-
b)其次,通过Google提供的google-diff-match-patch算法,将complete.jsbundle与common.jsbundle文件进行对比,最终由WBRN打包平台输出两者的差异的描述文件,也即是bussiness.jsbundle文件。
JS 端的JSBundle 是如何存储Native 端的?
根据不同业务场景需要,通过WBRN热更新平台为不同的bussiness.jsbundle其配置相关参数信息,例如:版本号、是否需要强制更新APP、是否执行下次生效策略、jsbundle下载地址等参数。
然后通过热更新平台下发至Native端,整体流程如下图6所示:
图6 JSBundle下发Native整体流程Native端每次进入React Native页面时向WBRN热更新平台请求当前bussiness.jsbundle的最新信息,若需要更新,则下载最新的diff并将其保存在本地,以确保本地存储的是最新的jsbundle文件。具体的流程如下图7所示:
图7 JSBundle下发Nativex详细流程1、根据当前bussiness.jsbundle的版本号、BundleId等参数请求WBRN热更新平台,获取当前bussiness.jsbundle文件的最新信息。
2、根据返回的信息判断是否包含commonUrl来判断是否需要更新common.jsbundle文件,若需要,则下载最新common.jsbundle并保存在沙盒中同时更新common文件对应的配置文件。若不需要,则common.jsbundle不做任何操作。
3、根据返回的信息中jsbundle的版本号与本地jsbundle的版本进行比较,判断是否需要更新bussiness.jsbundle,若需要,则下载最新的bussiness.jsbundle并保存在沙盒中同时更新该bundle对应的配置文件,否则不执行任何操作。
4、根据返回的信息中isForceUpdate来判断bussiness.jsbundle是否需强制更新,若需要,则立即生效并展示新的页面。否则,展示旧页面,实行下次生效策略。
注意:
1、在common.jsbundle需要更新的情况下,无论business.jsbundle是否需要强制更新都直接展示最新的页面。
2、如果本地不存在对应的buniness.jsbundle文件,则下载对应的business.jsbundle后,无论最新信息是否为强制更新则都展示最新的页面。否则在非强制更新情况下展示旧的页面。
3、如果bussiness.jsbundle下载失败,出于用户体验的角度,如果本地存在旧的bussiness.jsbundle文件,则先展示旧的页面。
最终,Native端通过热更新平台或者内置的方式将Common.jsbundle文件以及bussiness.jsbundle文件存储在Native本地,存储的目录结构如下图8所示:
图8 JSbundle本地存储目录从上图可以看出,存储在沙盒中的文件不仅包含common.jsbundle和bussiness.jsbundle而且包含两个plist文件,其中JSBundleIndex.plist文件就是上文提到的bundle配置文件,用于记录每个本地jsbundle对应的版本号,每次与WBRN热更新平台的最新jsbundle文件版本号进行比对,从而判断是否需要进行更新当前bussiness.jsbundle。BundleExcepion.plist文件用于记录每个本地jsbundle文件对应的异常次数,一旦某个bussiness.jsbundle文件异常次数超过一定的阈值,则会启动看门狗策略,删除本地相应的bussiness.jsbundle文件,再次进入React Native页面时从服务器下载最新的bunssiness.jsbundle文件,以确保不会因为jsbundle文件的损害导致页面一直加载异常。
Native 端如何实现的合包
通过以上步骤就完成了React Native页面FE端JSBundle文件的拆分和分发,那么Native端如何使用拆分后的文件呢?关于Native加载React Native页面整体的详细流程如下图9所示:
图9 JBundle加载流程1、根据跳转协议中的bundleId进入到对应的载体页。
2、通过缓存管理模块检测本地沙盒中是否包含bundleId对应的bussiness.jsbundle文件。若存在,则从本地读取对应的bussiness.jsbundle文件,并通过Google-diff-match-patch算法将common.jsbundle文件与bussiness.jsbundle文件进行合并,生成对应的complete.bundle文件,若不存在,则检测内置中是否含有该bunssiness.jsbundle文件,如果存在,则先执行步骤三,否则执行步骤四。
3、然后通过JSBundle加载管理模块读取complete.bundle文件,加载并展示。
4、若沙盒中和内置中均不存在bussiness.bundle文件,则通过jsbundle网络管理模块从服务器下载bussiness.bundle保存到本地沙盒同时记录其版本号,重复进行第二步骤。
5、同时向服务器请求当前bussiness.bundle的最新信息,根据返回内容来判断是否需要强制更新页面,如果不需要强制更新则后台下载并执行下次生效的策略,否则立即刷新当前页面。
6、如果当前页面已经是最新页面,则不做任何操作。
方案一数据对比:
假设完整的页面共600KB,其common.jsbundle大小为531KB,bussiness.bundle大小为70KB。以100个ReactNative页面而言,如果不使用拆分包逻辑,需要(531KB+70KB)KB100=60M空间。使用拆分包方案一后,10070KB+531KB=7.5M,节省空间为87.5%
方案一仍需要解决的问题
从上图可以得看出,使用方案一优化后,同样数量的React Native页面减少的87.5%的存储空间。但相对于未拆分方案其增加了两次I/O操作以及一次文件的合并操作,增加了时间消耗。高端机上增加的这部分时间消耗不太影响用户体验,低端机设备则会出现短暂的空白页面,影响了用户体验。那么是否存在一种方案可以在拆包的前提下减少JSBundle的I/O次数呢,从而减少JSBundle文件的读取时间,答案是肯定的,也即是接下来将要介绍的方案二。
拆分包实现方案二
在引入方案方案二之前首先有必要了解下React Native的整个加载过程,根据FaceBook提供的一篇文章,可以看出ReactNavtive从加载到渲染完成主要包括以下六个阶段,如下图11所示:
图11 ReactNavtive加载整体过程1、Native Initialization阶段:主要初始化Java虚拟机和所有后备模块(磁盘缓存,网络,UI管理器等)。
2、JS Init + Require 阶段:从磁盘读取最小化的Java软件包文件,并将其加载到Java虚拟机中,该虚拟机将解析它并生成字节码,因为它需要初始模块(大多数为React,Relay及其依赖项)。
3、Before Fetch 阶段:加载并执行事件应用程序代码,构建查询并启动从磁盘缓存读取数据。
4、Fetch阶段:从磁盘缓存读取数据
5、JS Render阶段:实例化所有React组件,并将它们发送到本地UI管理器模块进行显示。
6、Native Render:通过计算阴影线程上的FlexBox布局来计算视图大小; 在主线程上创建和定位视图。
上图清晰的记录了每个阶段占用时间的百分比,所以可以直观的看出耗时最多的是js init+ require阶段,也即是jsbundle的加载和执行阶段。
因此如何缩短js init+ Require时间是提高RN页面展示速度的关键也即是方案二所要解决的问题。接下来详细分析下React Native load JSBundle和执行JSBundle文件的过程:如下图12所示:
图12 JSBundle 加载代码片段以上是React Native框架load JSBundle的相关代码片段,从上图代码中可以看出,片段1主要是执行的是JSBundle的加载过程。片段2主要是初始化组件,片段3主要是初始化组件配置表config,并将配置表注入到JSContext中。片段4主要是执行js操作。那么如何实现加载过程的优化呢?
实现方案二的理论猜想:
如果能有一种方式可以使React Native分步加载JSBundle并且不需要合并,那么就能减少1次合并操作与1次读取complete.jsbundleI/O操作,理论上就可以有效的缩短页面加载时间,事实证明这种方案也是可行的。因为JSContext是由GlobalObject管理Java执行的上下文,在同一个GlobalObject对应的同一个JSContext中执行Java代码,执行多个Java是没有区别的,所以在同一个JSContext中分步加载common.jsbundle与bussiness.jsbundle效果应该是一样的。
JS 端如何实现的拆包
方案二JS端拆包的原理与步骤与方案一基本相同,相同的部分不再赘述。唯一不同的是需要对打包脚本需要进行优化,差异性具体如下:
1.通过react-native init指令创建新的空工程,使用wbrn-package工具生成common文件,具体使用指令如下:./pacakger bundle--entry-file ./core.js --bundle-output common.ios.bundle --bundle-encoding"utf-8" --platform “ios” --core-output common.json,使用该指令生成common.jsbundle文件以及对应的common.json文件,common.json主要是记录了RN原生组件以及唯一标识符的映射关系。
2.如何获取bussiness.jsbundle文件。通过wbrn-package工具根据不同的React Native页面创建不同的、完整的complete.jsbundle文件,然后使用rn-package工具生成对应bussiness文件,具体使用指令如下:./pacakger bundle --entry-file./index.js --bundle-output business.bundle --bundle-encoding "utf-8"--platform “ios" --core-file common.json。
3.通过热更新平台下发每个React Native页面对应的bussiness.jsbundle文件。
JS 端的JSBundle 是如何存储Native 端的?
此步骤与方案一相同,不再赘述。
Native 端如何实现的合包
此方案中Native端采用的热更新流程与逻辑与方案一基本相同,不同的是文件的合并方式以及jsbundle加载时机,方案一采取的是文本文件的合并,而方案二是基于同一个JSContext分步加载common.jsbundle文件和bussiness.jsbundle文件的方式。具体流程如下图13所示:
图13 JBundle加载流程与方案一差异的步骤如下:(已用红框标记)
1、将React Native本身框架提供的common.jsbundle文件提前在APP启动的时候加载JSGlobalContextRef中,目的是为了减少common加载的这部分时间。
2、根据bundleId从本地找到对应的business.jsbundle文件,并将其加载到同一个JSContext环境中。
3、执行JS代码。
通过方案二能有效的减少jsbundle文件的读取次数以及合并的时间,大大提高了页面的加载速度。
实验过程中遇到了问题以及相应的解决方案:
实验中我们发现,如果按照上面思路依次进入多个RN页面,如果多个bussiness.jsbundle代码完全不相同则可以正常展示,如果有相同的方法则会发生异常的错误,那么如何处理多个RN页面Bridge冲突?
我们使用的方案是维护一个基于common.jsbundle的Bridge池,每次创建新的页面时就从Pool取出一个新的Bridge使用,取出之后在适当的时间再生成一个新的Bridge放入池中,使得Pool中始终有一个“干净”的Bridge等待被使用,具体流程如下图14、15所示:
图14 Bridge冲突解决方案 图15 方案二整体加载示意图那么改造的RN加载步骤:
1、APP启动之后从WBBridgePoolManager中读取一个Common.jsbundle生成commonBridge,如果WBBridgePoolManager中不存在可用的commonBridge则直接生成。
2、在进入对应的具体的RN页面后,根据跳转协议中的bundleId则加载本地的对应的bussniness.jsbundle文件,并将其放在commonBridge的同一个JSGlobalContextRef环境中去执行。
3、根据此时bridge去创建RCTRootView,于此同时再次由common.jsbundle生成commonBridge放在WBBridgePoolManager队列中进行管理,以备下次使用。如果当前的React Native需要进行强制更新,则同样从WBBridgePoolManager管理的pool中取出“干净”Bridge去加载并创建新的RCTRootView,同时删除旧的RCTRootView。
与方案一的数据对比:
相比方案一的3次本地读取操作1次合并操作,方案二中仅仅进行了2次本地读取操作,大大降低了RN页面的加载时间。
以iPhone7为例,方案二无缓存的情况下,加载时间为398ms,而方案一无缓存情况下加载时间为860ms,优化比例为:53.72%. 方案二有缓存情况下,加载时间为140ms,而方案一有缓存情况下,加载时间为460ms,优化比例为:69.6%
以iPhone5s为例,方案二无缓存的情况下,加载时间为830ms,而方案一无缓存情况下加载时间为1221ms,优化比例为:32.02%. 方案二有缓存情况下,加载时间为400ms,而方案一有缓存情况下,加载时间为510ms,优化比例为:21.56%.
以魅族X5为例,方案二无缓存的情况下,加载时间为410ms,而方案一无缓存情况下加载时间为957ms,优化比例为:57.15%. 方案二有缓存情况下,加载时间为274ms,而方案一有缓存情况下,加载时间为578ms,优化比例为:52.59%.
从上面数据可以看出在优化效果十分明显,iPhone高端机比低端机效果更显著。
方案二仍存在的优化空间:
截止到此58同城React Native的优化暂且告一段落,但并不是说已经不存在优化的空间,试想下如果我们能否找到一种方案在App的生命周期中只创建一次JSContext运行环境,每次进入RN页面只需要加载相应的bussiness.jsbundle而不需要维护一个BridgePool,这样能有效的减少App使用时占用的内存大小。
总结
以上便是58同城React Native的优化过程以及演进的思路,项目的进展始终按照“提出问题、分析问题、解决问题”的思路向前推进。在研发过程中,结合公司自身的业务场景研发出相应的打包平台、热更新平台、调试工具以及详细的接入文档,形成了一套完善的React Native开发流程,为React Native在其他业务线能顺利展开扫清障碍,减少各个业务线接入的沟通成本,提高工作效率。希望58 React Native的优化过程,能给一些已经应用或者即将应用React Native的开发者一些参考,也希望大家一起相互探讨、学习。