基于拆分包的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文件。

  1. a)首先通过React Native提供的指令react-nativeinitAwesomeProject 来创建一个空的工程

  2. 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文件。

  1. a)首先,不同的React Native页面通过WBRN打包平台生成不同的、完整的complete.jsbundle文件。

  2. 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的开发者一些参考,也希望大家一起相互探讨、学习。

58无线技术

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 158,847评论 4 362
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 67,208评论 1 292
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 108,587评论 0 243
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 43,942评论 0 205
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,332评论 3 287
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,587评论 1 218
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,853评论 2 312
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,568评论 0 198
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,273评论 1 242
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,542评论 2 246
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 32,033评论 1 260
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,373评论 2 253
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 33,031评论 3 236
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,073评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,830评论 0 195
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,628评论 2 274
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,537评论 2 269

推荐阅读更多精彩内容