100

基于拆分包的React Native在iOS端加载性能优化

 6 years ago
source link: https://mp.weixin.qq.com/s/GX3NcTaeUWtPf5JEL7Xlmw
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

基于拆分包的React Native在iOS端加载性能优化

刘亚东 58无线技术 2017-11-24 09:52 Posted on

自从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所示:

Image

图1 RCTRootView API

    从API文档可以看出,创建RCTRootView必须创建对应的RCTBridge,RCTBridge是JS与Native通信的桥梁,因此问题的关键转化为了如何创建RCBridge。查看React Native源码发现,如下图2所示:

Image

图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所示:

Image
Image

图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所示:

Image

图5  FE端JSBunlde拆分整体流程

1、如何获取common.jsbundle文件。

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

  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所示:

Image

图6 JSBundle下发Native整体流程

Native端每次进入React Native页面时向WBRN热更新平台请求当前bussiness.jsbundle的最新信息,若需要更新,则下载最新的diff并将其保存在本地,以确保本地存储的是最新的jsbundle文件。具体的流程如下图7所示:

Image

图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所示:

Image

图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所示:

Image

图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)KB*100=60M空间。使用拆分包方案一后,100*70KB+531KB=7.5M,节省空间为87.5%,如下图10所示:

图10 数据对比

方案一仍需要解决的问题

    从上图可以得看出,使用方案一优化后,同样数量的React Native页面减少的87.5%的存储空间。但相对于未拆分方案其增加了两次I/O操作以及一次文件的合并操作,增加了时间消耗。高端机上增加的这部分时间消耗不太影响用户体验,低端机设备则会出现短暂的空白页面,影响了用户体验。那么是否存在一种方案可以在拆包的前提下减少JSBundle的I/O次数呢,从而减少JSBundle文件的读取时间,答案是肯定的,也即是接下来将要介绍的方案二。

拆分包实现方案二

       在引入方案方案二之前首先有必要了解下React Native的整个加载过程,根据FaceBook提供的一篇文章,可以看出ReactNavtive从加载到渲染完成主要包括以下六个阶段,如下图11所示:

Image

图11 ReactNavtive加载整体过程

1、Native Initialization阶段:主要初始化JavaScript虚拟机和所有后备模块(磁盘缓存,网络,UI管理器等)。

2、JS Init + Require 阶段:从磁盘读取最小化的JavaScript软件包文件,并将其加载到JavaScript虚拟机中,该虚拟机将解析它并生成字节码,因为它需要初始模块(大多数为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所示:

Image

图12 JSBundle 加载代码片段

以上是React Native框架load JSBundle的相关代码片段,从上图代码中可以看出,片段1主要是执行的是JSBundle的加载过程。片段2主要是初始化组件,片段3主要是初始化组件配置表config,并将配置表注入到JSContext中。片段4主要是执行js操作。那么如何实现加载过程的优化呢?

实现方案二的理论猜想:

       如果能有一种方式可以使React Native分步加载JSBundle并且不需要合并,那么就能减少1次合并操作与1次读取complete.jsbundleI/O操作,理论上就可以有效的缩短页面加载时间,事实证明这种方案也是可行的。因为JSContext是由GlobalObject管理JavaScript执行的上下文,在同一个GlobalObject对应的同一个JSContext中执行JavaScript代码,执行多个JavaScript是没有区别的,所以在同一个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所示:

Image

图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所示:

Image

图14 Bridge冲突解决方案

Image

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

部门招聘

高级Java开发工程师

岗位职责:

1.负责58同城APP,58同镇等相关后端研发工作

2.负责基础平台的架构设计,核心代码开发

3.调研并掌握业内通用技术方案,引入项目迭代,提升研发效率

任职要求:

1.3年以上Java互联网项目开发经验;

2.Java基础扎实,编码规范,程序具备较高的健壮性,熟悉常用设计模式;

3.对MVC框架、RPC框架、基础服务组件等有深入的研究;

4.掌握Linux环境下的网络编程、多线程编程,数据结构和算法能力良好;

5.对高并发高可用系统设计有深入的实践经验;

6.具有高度的责任心、勇于承担责任,能承受较强的工作压力;

7.积极主动,敢于接受挑战,有较强的团队合作精神;

高级前端研发工程师

工作职责:

1.负责58同城App前端产品研发

2.负责58同城前端无线产品某一技术方向,人才培养

3.前端研发所需类库、框架、脚手架搭建

4.交互模式调研及创新(React,ReactNative)

职位要求:

-计算机及相关专业本科以上学历

-3年以上前端开发经验,负责过复杂应用的前端设计和开发 

-精通web前端技术(js/css/html),熟悉主流框架类库的设计实现、w3c标准,熟悉ES6/7优先

-熟悉前端模块化开发方式(commonjs/webpack …)

-熟悉移动端开发、自适应布局和开发调试工具,熟悉hybrid app开发

-掌握一门后端语言(node/java/php...),对前后端合作模式有深入理解

-有良好的产品意识和团队合作意识,能够和产品、UI交互部门协作完成产品面向用户端的呈现

-有技术理想,致力于用技术去推动和改变前端研发。

-熟悉Vue/React/ReactNative优先,有BAT等公司经验优先

高级Android开发工程师

岗位描述:

1. 负责58同城App的研发工作;

2. 肩负平台化任务(插件框架,Walle,Hybrid,WubaRN) ;

3. 维护和开发服务库,公共库的工作;

4. 调研Android前端技术;

5. 提升开发效率和应用性能;

岗位要求:

1. 2年以上的Android开发工作经验;

2. 精通Java语言,精通Android Studio开发,了解Gradle编译;

3. 精通常用算法、数据结构和架构设计;

4. 了解Android性能限制及优化方案;

5. 了解常用的开源工具:Volley,RxJava,Fresco等等;

6. 了解git, maven等等工具;

7. 有插件开发经验,Hybrid开发经验,ReactNative开发经验优先;

8. 积极主动、喜欢挑战,有强烈的创业精神,能承受高强度的工作压力;

以上如有小伙伴感兴趣,请发送简历到:

[email protected]

Image

About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK