cover_image

雪球 RN 的拆包之路

雪球大前端团队 雪球工程师团队
2023年10月20日 12:18
图片

导读:对于熟悉 React Native(RN)的开发者来说,热更新和拆包是经常被讨论的话题。通常,针对一个被广泛讨论的技术话题,都会有一套成熟的技术解决方案。然而,就拆包而言,目前尚未有被广泛认可的成熟方案。雪球作为一家采用 RN 的公司,也经历了 RN 革命带来的不适和挑战,从最初的单 Bundle 单引擎,逐渐发展到现在的拆包、按需加载和实时更新等阶段。目前,我们已经取得了重要的里程碑式成果,本文将分享雪球在拆包方面的演进路线和实现方案,希望能为大家提供适合自己的解决方案,将 RN 技术应用到实际开发中。

通过这些经验和分享,我们希望能够推动 RN 技术的发展,促进行业内技术的成熟和进步。我们相信,通过共享知识和经验,可以为开发者们提供更好的工具和指导,使他们能够更高效、更稳定地开发 RN 应用。同时,我们也非常欢迎各位开发者在使用 React Native 的过程中积极分享自己的经验和技巧,共同推动 React Native 生态的繁荣发展。

一、背景

自 React Native 发布以来,它已经吸引了大量的开发者投入其中,创造了许多出色的应用。全球范围内,React Native 社区繁荣发展,规模日益壮大。现在,越来越多的应用选择使用 React Native 进行开发。据 npmtrends 网站统计,react-native 框架的每周下载量从 5 年前的 20 万次激增至现在的 180 万次,5 年间增长了 9 倍。这一数据明确反映出 React Native 的生态系统呈现出勃勃生机,蓬勃发展的态势。

图片

雪球的愿景是成为中国人首选的财富管理平台,这要求我们的产品既要具备专业稳定的特性,同时又要能够保持动态性,持续高效迭代。因此,从传统的本地开发升级到跨平台框架是我们必须经历的重要步骤。2019 年,雪球跟随 React Native(RN)的流行趋势,越来越多的开发人员从事 RN 开发工作,RN 代码数量日益增加。然而,单 Bundle 单引擎的问题逐渐凸显出来,成为了 RN 在雪球发展道路上的障碍,亟待我们解决。

二、为什么要拆包?

在采用单 Bundle 单引擎模式时,我们面临着两个主要问题,即 Bundle 包体积的迅速增加和无法灵活实现版本发布。

首先,随着业务的不断发展,采用单 Bundle 单引擎模式导致 Bundle 包的体积快速增加。随着功能的增加和代码的累积,Bundle 包变得越来越庞大,这对应用程序的性能和用户体验造成了负面影响。大量的冗余代码和未使用的资源占据了宝贵的存储空间,并且每次版本发布都需要传输和加载整个 Bundle,导致不必要的时间和网络成本。

其次,采用单 Bundle 单引擎模式限制了我们在版本发布方面的灵活性。由于所有代码和资源都打包在一个 Bundle 中,每次进行版本发布时都需要更新整个 Bundle,即使只有少量的修改。这导致了发布过程的不便和冗余,同时也增加了发布过程中出错的风险。

问题一:体积增大

图片

明显的变化是基金包的体积。在 v13.19 版本中,基金包大小为 2.2M,在 v14.23 版本中增加到了 5.1M,包体积在半年内增加了 150%。同时,RN 页面的数量从 44 个增加到了 94 个,数量增加了一倍多。考虑到我们的目标是将业务全面转向 RN,随着 RN 应用范围的扩大,RN 页面数量将会进一步增加,而且包体积的增长速度也会加快。

包体积增大对我们使用 RN 有什么影响呢?其实我们平时使用过程中,有几个冷启动的场景就可以明显感知 Bundle 体积大小对于性能的影响。

例如第三方唤起基金 APP 的场景,拆包前后的对比效果:

视频加载失败,请刷新页面再试

刷新

问题二:发版不够灵活

使用 RN 的一个重要意义在于不需要依赖客户端发版。在许多紧急需求和修复情况下,我们需要快速上线并将用户手中的应用程序实时更新至最新版本。在进行拆包之前,我们在这方面的能力并未完全发挥,因为每次发布捆绑包都会对整个项目产生影响,这就需要与相关团队进行沟通,并需要进行漫长的回归测试。

图片

在 RN 开发中,我们有几个主要目标:三端一致性、与原生应用体验基本相同、灵活的版本发布。

目前,我们已经通过三端同构实现了第一个目标。为了实现另外两个目标(即与原生应用相似的加载性能和灵活的版本发布),我们就需要解决三个主要问题:

  1. 性能问题:随着 Bundle 包体积的增大,下载、解压和加载所需的时间也会增加,这会增加更新失败的概率。通过拆包,我们可以减小包体积,避免下载底层依赖库,并预加载高优先级的关键页面,从而降低失败概率,提升用户的使用体验。
  2. 人力成本问题:与客户端开发类似,当不同业务模块修改时,我们需要对整个 Bundle 进行合并并进行全量回归测试。然而,许多模块并没有受到影响,这样就浪费了大量的人力资源。通过拆包后,我们可以只对特定模块进行回归测试,而无需回归测试其他模块,从而节省了回归测试的时间和资源。
  3. 发版限制问题:目前,由于所有内容都在同一个 Bundle 中,当进行发版时,必须全量发版,并需要与各开发团队进行沟通和检查,发版时间也受到限制,与客户端没有本质区别。通过拆包后,我们可以独立进行发版,只需发出特定的 Bundle,客户端只需更新该 Bundle 即可,从而解除了发版的限制和时间约束。

综上所述,RN 拆包可以带来多方面的好处,包括减小初始加载时间、优化应用程序性能、灵活的版本发布和更新、模块化开发和维护,以及节约设备存储空间。这些优势有助于提升应用程序的用户体验,简化开发和维护工作,并提高应用程序的可扩展性和可维护性。

三、拆包的演进路线

图片

一期-多引擎(解决工程依赖)

在最初的接入阶段,雪球 APP 只允许运行单个 Bundle 文件,而该 Bundle 文件需要包含所有基金的 RN 页面。然而,基金也有独立的 RN 工程,因此如果想在雪球 APP 中运行基金的 RN 页面,就必须将基金的 RN 代码复制到雪球的 RN 工程中进行打包。为了解决单 Bundle 的限制,必须在客户端实现支持多 Bundle 加载和切换的功能。由于雪球客户端和基金客户端不支持切换加载不同的 Bundle,基金的 RN 页面只能被复制到雪球的 RN 项目中以便在雪球上使用,同样地,如果基金想要使用雪球的 RN 页面,也需要将页面复制到基金的 RN 项目中。这种相互复制的做法引发了一系列问题。

将雪球中基金内容拆包之后:

图片

优化:现在,我们的客户端已经支持切换加载不同的 Bundle,这意味着雪球的 RN 和基金的 RN 可以真正分为两个独立的工程,无需相互复制。此外,客户端还支持多个容器之间的相互调用,这为我们提供了更大的灵活性和协作性。

二期-底层库抽离(减小包体积)

随着业务的不断发展,我们注意到 React Native(RN)的 Bundle 包体积逐渐增大。令人惊讶的是,约一半的包体积被 RN 底层库所占据。然而,这些底层库在日常迭代中很少需要进行变更。鉴于此,我们做出了一个决策,即将这些底层库从 Bundle 中抽离出来,并在本地进行加载。这一举措的目的是减小 Bundle 的体积,提高应用程序的加载速度和性能。通过将这些底层库从 Bundle 中分离出来,我们可以将它们存放在本地环境中,并在需要时进行加载。这样一来,我们可以减少每次版本发布时的数据传输量,同时提升应用程序的运行效率。

图片

底层库拆包后包体积:

图片

总结:这个优化方案不仅使得 Bundle 包体积得到了缩减,还能够减轻网络传输的负担,提高用户的使用体验。同时,由于这些底层库很少需要变更,将它们从 Bundle 中分离出来也有助于降低版本发布的复杂性和风险。

三期-业务拆包(独立发版)

在当前情况下,业务依赖性十分重要,而发版过程却显得不够灵活。每次修改一行代码都需要更新整个 Bundle,这影响了开发的效率和用户体验。为了改善这一状况,我们可以采取一些优化措施。首先,通过对业务包进行拆分和按需加载,我们可以实现独立的版本发布,而不会对其他业务功能产生影响。这种方式使得我们可以针对特定的业务包进行修改和更新,而无需重新构建整个 Bundle。这样一来,开发人员可以更加灵活地进行版本发布,减少了不必要的操作和时间消耗。同时,对于这些拆分出来的业务包,我们可以根据它们的重要性配置三种不同等级的加载策略。这意味着我们可以根据业务包的重要性和优先级,对其加载策略进行精确控制。例如,对于核心功能和关键模块,我们可以采用快速加载策略,以确保用户能够快速访问到这些重要的功能;而对于一些次要的功能模块,我们可以采用延迟加载策略,以优化整体性能和资源利用。

  1. 预下载&预加载(Level:high) :首先在 app 内置一份 Bundle 文件,启动时更新下载和预加载,在打开相应页面的时候页面可以秒开,不需要 loading。也就是目前 Native 的 high-danjuan.jsbundle 和 high-xueqiu.jsbundle 两个主业务包的下载和加载逻辑。
  2. 预下载&按需加载(Level:mid) :检查更新的时候下载到本地,进入具体页面时再进行加载,打开页面的时候会有等待加载 Bundle 的时间,loading 时间较短。这种方式比较适合的模块有活动页,认证页面等等,这类页面大多是一次性使用,后续不再会访问
  3. 按需下载&按需加载(Level:low) :进入具体页面时进行下载,下载完毕后进行加载,打开页面的时候会有等待下载和加载两个步骤的时间,loading 时间较长。这种方式适合使用频率低、主页面下的二级、三级页面,也不需要更新或者修改

业务拆包后包体积:

图片

总结:通过以上优化措施,我们能够更好地提升业务的灵活性和效率。拆包和按需加载使得版本发布更加独立和精确,同时加载策略的灵活配置为我们提供了更好的控制能力。这些措施将有助于提高开发团队的工作效率,同时改善用户的使用体验。

四期-公共库抽离(进一步减小包体积)

在目前情况下,我们存在一些冗余的底层公共库,例如 snowbox 和自定义控件等。为了进一步提升各项性能指标,我们计划对这些底层公共库进行抽离操作。通过将底层公共库进行抽离,我们可以实现以下几个方面的优化效果。首先,减少底层公共库的冗余,可以降低整体代码的复杂度,提高可维护性。其次,抽离后的底层公共库可以被多个模块或项目共享,避免重复开发和资源浪费,提高开发效率。最重要的是,这种优化可以进一步提升性能指标,例如加载速度、内存占用和响应时间等,从而提升用户体验和整体应用性能。

Bundle 内容的变化过程:

图片

总结:通过对冗余底层公共库的抽离操作,我们将进一步优化应用的性能指标,提升整体效率和用户体验。

四、拆包的成果

目前,雪球已经完成了三期迭代开发,成功实施了拆包策略。此外,我们对 RN 容器底层进行了初步封装。作为成果的一部分,雪球的主业务 Bundle 已经缩小到了 1.3M,并且加载时间约为 300 毫秒。这意味着我们的 RN 项目现在具备了模块化独立发版的能力。通过减小包体积,我们缩短了下载、解压和加载等三个阶段的耗时,从而明显提升了性能。这一优化措施为用户带来更快的加载速度和更流畅的用户体验:

图片

五、拆包方案实现

5.1 RN 的实现方案

在选择 React Native(RN)的拆包方案时,通常需要考虑以下几个因素:项目规模、技术基础、项目需求和社区支持。在 RN 中,Metro 被广泛采用作为拆包工具,这是因为它具备以下优势:支持 HMR(模块热替换)、高效的打包和代码拆分、与 React Native 的良好集成以及活跃的社区和生态系统。这些因素使得 Metro 成为 React Native 拆包的理想选择。针对雪球的拆包方案,我们选择了由 58 同城技术团队开源的拆包工具 metro-code-split。该工具是基于 Metro 开发的插件,通过它我们能够轻松地实现 React Native 的拆包操作。首先,我们进行了公共包的拆分。在使用 metro-code-split 时,我们需要配置公共包和业务包的拆包信息。接着,针对每个业务包,我们需要配置相应的入口文件。一旦配置完成,Metro 将会根据入口文件自动进行不同业务包的打包过程。通过采用 metro-code-split 作为拆包工具,我们能够高效地进行 React Native 的拆包工作,在项目中实现代码的优化和分割。这为雪球的开发团队提供了便利,同时也确保了应用的性能和可维护性。

如何拆公共包?

接入 metro-code-split 之后,在 metro 配置文件中配置公共包需要打入的底层库,公共包会内置在 APP 内,这些依赖库只在升级的时候才需要客户端发版

const mcs = new Mcs({
  output: {},
  dll: {
    entry: [
      'react-native',//依赖的rn 库
      'react',//react 库
      ......
    ],
    referenceDir'./bundle/dll',//打包输出路径
  },
  dynamicImportsfalse,
});

如何拆业务包?

根据实际业务需求,Metro 的拆包需要按页面路由拆包。首先在你的项目中,创建一个路由文件(例如routes.js),并在该文件中定义你的应用程序的所有路由。你可以使用 React Router、React Navigation 或其他路由库来管理你的路由。其次根据你在路由文件中定义的路由,将你的代码拆分为不同的模块或包。每个路由对应的模块应该包含与该路由相关的所有代码,包括组件、容器、服务等。

import routes from '@/routes';
routes.forEach((route) => {
  AppRegistry.registerComponent(route.appKey, () => Wrapper(route.module, route.theme));
});

工程结构:

snb-rn
├─ app.json
├─ babel.config.js
├─ bundle
├─ common//公共库
├─ components//公共组件
├─ index.js//配置入口文件
├─ jsconfig.json
├─ metro.config.js//msc 配置文件
├─ package.json//依赖管理
├─ pages//页面文件
├─ rn_routes.json//路由表
├─ routes//路由拆包配置入口文件
│  ├─ high_xueqiu_routes.js
│  ├─ index.js
│  ├─ low_activity_routes.js
│  ├─ low_media_routes.js
│  ├─ low_other_routes.js
│  ├─ low_private_routes.js
│  ├─ low_stock_routes.js
│  ├─ low_wallet_routes.js
│  ├─ mid_live_routes.js
│  ├─ mid_private_routes.js
│  └─ mid_wealth_routes.js

其中 index.js 文件上各个业务包的总入口配置文件,也就是需要注册的组件

import high_xueqiu_routes from './high_xueqiu_routes';
import mid_private_routes from './mid_private_routes';
import mid_wealth_routes from './mid_wealth_routes';
import mid_live_routes from './mid_live_routes';
import low_private_routes from './low_private_routes';
import low_other_routes from './low_other_routes';
import low_wallet_routes from './low_wallet_routes';
import low_activity_routes from './low_activity_routes';
import low_media_routes from './low_media_routes';
import low_stock_routes from './low_stock_routes';
const routes = [
  ...high_xueqiu_routes,
  ...mid_private_routes,
  ...mid_wealth_routes,
  ...mid_live_routes,
  ...low_private_routes,
  ...low_wallet_routes,
  ...low_media_routes,
  ...low_stock_routes,
  ...low_activity_routes,
  ...low_other_routes,
];
export default routes;

业务包配置的 js 文件中配置了业务包需要打入的页面信息,例如 low_media_routes.js 文件

import { AppRegistry } from 'react-native';
import Wrapper from '@/components/BaseWrapper';
import MyAudios from '@/pages/MyAudios';

const Routes = [
  {
    title'我的音频',//RN 页面展示的标题
    path'/audio_album/subscribe',//页面路径
    module: MyAudios,// module名称
    appKey'MyAudios',//注册的组件名
  },
];

Routes.forEach((route) => {
  AppRegistry.registerComponent(route.appKey, () => Wrapper(route.module));
});

export default Routes;

其中 Routes 是 low_media.jsbundle 包含页面的页面路由,这部分 json 代码将会由打包脚本统一整理到路由列表中,当客户端请求时下发。

如何路由到指定页面?

由于在前两个步骤中进行了页面打包,导致页面被分散到各个业务 Bundle 包中。为了在客户端上显示相应的页面,需要一个统一的路由表,以指导客户端下载并加载特定的 Bundle 包。为此,我们需要开发一个脚本来扫描并整合各个 Bundle 中的页面路由信息,并将其下发给客户端。如果您有兴趣了解打包的具体细节,请随时给我们留言。以下是路由结构的示例:

{
        "data": [{
                "version""18.0.0",//工程版本
                "project""snb-rn",//工程名称
                "bundles": [{//Bundle信息
                        "bundle_name""xueqiu",//Bundle 包名称
                        "bundle_version""163000004215",//Bundle 包版本号
                        "level""high",//Bundle 包对应的加载登记
                        "checksum""hashcode",//Bundle 文件的 hashcode,用于校验 Bundle 完整性
                        "download_url""$download_url",//下载地址
                        "routes": [{//Bundle 包里包含页面的路由信息
                            "title""钱包",//RN 页面展示的标题
                            "path""/wallet/assets",//页面路径
                            "module""WalletAssets"
                        }......]
                }......]
        }......]
}

5.2 客户端的实现方案

5.2.1 更新逻辑

为了实现实时更新,我们需要在客户端进行三种场景的 Bundle 包更新。这三种场景相互配合,缺一不可,以确保用户每次访问的页面都是最新版本的 React Native(RN)页面。

  1. 启动时更新:在应用程序启动时,我们需要进行 Bundle 包的更新。这确保了用户在打开应用程序时能够获取到最新的 RN 页面,以提供最新的功能和体验。
图片
  1. 后台切换时更新:当应用程序在后台运行并切换回前台时,我们也需要进行 Bundle 包的更新。这样做可以保证用户在切换回应用程序时能够获得最新版本的 RN 页面,以便享受到最新的功能和内容。
图片
  1. 运行时异步更新:在应用程序运行时,当用户退出某个 Bundle 下的所有页面时,我们会在后台对该 Bundle 进行异步更新。一旦更新完成,当用户再次进入该 Bundle 下的页面时,他们将使用最新版本的内容。
图片

总结:通过这三种场景的相互配合,我们可以保证用户在任何时候访问应用程序时都能够获得最新的 RN 页面。这样做不仅可以提供最佳的用户体验,还可以确保用户能够享受到最新的功能和优化,从而提高应用程序的质量和竞争力。

5.2.2 加载策略

客户端根据路由表返回的 Bundle 等级(level:high/mid/low)使用不同的下载加载方式,以下是页面打开效果:

视频加载失败,请刷新页面再试

刷新

以上三种级别的 Bundle 在第一次加载后,后续再访问相同 Bundle 内的页面,可实现秒开

  1. Level:high :预下载&预加载,首先在 app 内置一份 Bundle 文件,启动时更新下载和预加载,在打开相应页面的时候页面可以秒开,不需要 loading。
  2. Level:mid :预下载&按需加载,检查更新的时候下载到本地,进入具体页面时再进行加载,打开页面的时候会有等待加载 Bundle 的时间,loading 时间较短。
  3. Level:low :按需下载&按需加载,进入具体页面时进行下载,下载完毕后进行加载,打开页面的时候会有等待下载和加载两个步骤的时间,loading 时间较长。

5.2.3 路由逻辑

客户端下载加载的主要逻辑,下面我们来看看客户端跳转的 RN 页面的整个过程

第一步:确定需要跳转的页面对应 Bundle

图片

简单来说,客户端是通过跳转的 url 和路由表的路由信息进行匹配来确定需要跳转的 Bundle

"bundles": [{
        "bundle_name""xueqiu",//Bundle 包名称
        "bundle_version""163000004215",//Bundle 包版本号
        "level""high",//Bundle 包对应的加载登记
        "checksum""hashcode",//Bundle 文件的 hashcode,用于校验 Bundle 完整性
        "download_url""$download_url",//下载地址
        "routes": [{//Bundle 包里包含页面的路由信息
                "title""钱包",//RN 页面展示的标题
                "path""/wallet/assets",//页面路径
                "module""WalletAssets"
        }, ......]
}......]

例如:url:https://xueqiu.com/wallet/assets 将会加载"bundle_name": "xueqiu"对应的 Bundle 文件

第二步:找到对应的 Bundle 进行加载

如上面所讲的,有的 Bundle 在没有加载之前,并不在客户端本地,而是按需下载的,有的 Bundle 已经在本地却并没有被加载过,而有的 Bundle 已经被加载到内存里了

图片

当然,加载业务 Bundle 的前提是 common 包已经加载完成,以 android 的代码为例:

var builder: ReactInstanceManagerBuilder? = null
builder = ReactInstanceManager.builder()    
            .setApplication(App.INSTANCE)    
            .setJSMainModulePath(options.jsMainModuleName)    
            .setBundleAssetName("_dll.android.bundle"//预先设置common 包    
            .setJavaScriptExecutorFactory(HermesExecutorFactory())
var reactInstance: ReactInstanceManager? = null
reactInstance!!.addReactInstanceEventListener(ReactInstanceManager.ReactInstanceEventListener { reactContext ->       
    //这里加载业务 Bundle 
    ScriptLoadUtil.loadScriptFromFile(oadPath, mReactContext.catalystInstance, "file://$loadPath"false)
}
reactInstance?.createReactContextInBackground()

第三步:缓存加载 Bundle 的引擎

在加载完 Bundle 后,我们不能直接在退出页面时立即回收引擎。如果我们在退出一个 Bundle 下的所有页面时回收该 Bundle 对应的引擎,那么下次进入该 Bundle 下的其他页面时就需要重新加载一次。因此,我们不能直接回收引擎。然而,随着 Bundle 数量的增加,如果不回收引擎,会导致内存占用增加。例如,如果用户在使用过程中打开了我们 10 个 Bundle 下的页面,也就是说内存中存在 10 个引擎。随着 Bundle 数量的增加,内存占用也会相应增加。为了解决这个问题,我们采用了最近最少使用(LRU)的策略来管理加载 Bundle 的引擎。这种策略会根据引擎的使用情况,优先回收最近最少使用的引擎,从而保持内存占用的合理控制。这样一来,既避免了重复加载的问题,也有效控制了内存占用,提升了系统的性能和资源管理效率。

val mMangerMap = LruCache<String, WeakReference<ReactInstanceManager>?>(CACHE_SIZE)
mMangerMap.put(managerKey, WeakReference(reactInstance))//当加载新 bundle 时用弱引用缓存引擎
mMangerMap.get(bundleName)//当需要加载 bundle 时先获取一下缓存的引擎
mMangerMap.remove(bundleName)//当bundle内的页面出现异常情况,我们需要移除引擎,方便下载fix 后的 bundle重新加载

六、小结

内容回顾:

  • 为什么要拆包-包体积的增长速度越来越快、项目依赖和发版限制等问题
  • 雪球拆包的演进路线-从单引擎单 Bundle 到多引擎多 Bundle
  • 拆包的成果-包体积减小了 70%,整体耗时缩短了 65%
  • 拆包的实现方案-RN 拆公共包、业务包,客户端分步加载、按需加载

雪球的 React Native 拆包方案是结合实际业务需求进行综合考虑后的结果。然而,需要注意的是,该方案可能并不适用于所有场景。因此,如果您计划在 React Native 应用中进行持续集成/持续交付(CI/CD),您需要根据自身业务的开发成本、效率、迭代流程以及投产比等因素来定制适合您的拆包方案。同时,还需要根据实际需求来灵活调整和优化拆包方案,确保应用的高效稳定运行。雪球的拆包方案并不复杂,更适用于需要快速迭代的应用。针对当前的项目架构和迭代流程,该方案能够以较低的开发成本和较低的风险解决开发效率和客户端发版的问题,从而实现成本的降低和效率的提升。

通过拆包方案,可以使得应用的开发流程更加灵活、高效,并且可以更好地适应市场需求和业务需求的变化。另外,采用这个方案,无需升级客户端代码,就可以实现热更新,使客户端像 H5 一样具备灵活的发版能力。这样可以更好地为用户提供更优质的体验。

参考资料:

https://github.com/wuba/metro-code-split

https://facebook.github.io/metro/

继续滑动看下一个
雪球工程师团队
向上滑动看下一个