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