体积优化90%的增量热更新?Xtransfer的RN优化实践(二)热更新

75 阅读9分钟

前言

9月初,Xtransfer正式开源了基于React Native0.72版本的XRN框架(www.infoq.cn/article/cXC…),开源地址为(github.com/xtransferor…)。这一在Xtransfer内部两年来经过多个项目沉淀、兼容三端(安卓、iOS和纯血鸿蒙)的框架,帮助前端团队节省了70%的人力,也开创性的实现了类似qiankun等Web微前端框架的多Bundle架构,使得多个团队可以用单独仓库单独Bundle的模式并行开发、独立发布,极大提升了业务需求的研发效率。本篇文章会重点阐述XRN是如何实现极致的热更新体验、过程中遇到过哪些技术难点、未来会继续如何优化。

背景

React Native(RN)之所以能够在跨平台的技术栈中脱颖而出,其中一个重要的原因就是 RN 的热更新能力。区别于纯 Native 应用每次代码更新都需要经过应用市场审核,而 RN 的 Code Push 能力能够允许开发者在不发版应用商店的情况下,动态推送代码/资源更新应用功能、修复 Bug 或优化体验。然而在目前市面上的热更新开源方案比较成熟且免费的只有微软的Code-Push,并且不支持增量热更新。当然也有一些做的比较好的收费框架比如 pushy、expo updates。

对比维度Code-push (Microsoft/Vercel)Expo Updates (Expo.io)Pushy (第三方方案)
维护者微软(已停止新功能开发)→ Vercel接管Expo官方团队国内团队(Charmlot)
增量更新不支持 diff 更新支持原子化更新宣称"极速热更新"(未公开具体算法)
集成复杂度需要配置Code-push服务器集成Expo CLI即可需要替换React Native原生模块
文档完善度完善(有官方指南)完善(集成在Expo文档中)基础(需参考GitHub README)
成本免费版有限制,企业版收费免费基础版+付费企业功能需联系厂商获取报价
适用场景稳定成熟项目(需接受停止维护风险)Expo生态项目追求极致性能优化的项目
是否支持多 bundle不支持不支持可定制

通过以上对比,综合考虑决定自研比较适合我们的业务场景。

探索

俗话说他山之石,可以攻玉。因为热更新整体流程还是比较复杂的,而且部分功能社区也有现成的解决方案,综合考虑我们打算基于微软的客户端 SDK 以及社区提供的 Code-Push-Sever进行二开。

::: 由于 Visual Studio App Center 平台的下线微软也于 2024 年 9.25 号开源其 CodePush-Sever 代码,也可以参考这部分能力进行二开github.com/microsoft/c… :::

首先我们看一下 CodePush 核心流程: image.png

这里有几个点我觉得还是值得借鉴的。

  • 客户端自动回滚机制:这一机制有效地避免了因服务端错误推送导致的大规模客户端崩溃,并且当热更新修复措施失效时,该机制亦能发挥作用。具体而言,CodePush 在应用下载热更新包的过程中,会先为该包设置一个 isLoading=true 的标志位。如果在加载 bundle 期间出现崩溃,此标记将保持为 true。一旦应用程序重新启动并检测到此标志依然为 true,则会自动回退至上一版本的热更新资源,并将当前尝试更新的包标记为失败。反之,若 bundle 成功加载并解析,那么必然会触发 notifyApplicationReady 方法,此时上述标志位也会被清除,确保系统能够顺利运行最新版本。

  • 节流机制: 为了防止用户重复下载无效或已知存在问题的热更新内容,同时节约宝贵的移动数据流量,CodePush 加入一种防重复下载策略。每当检测到有新的热更新可用时,并不会立即开始下载过程,而是首先检查目标 bundle 是否已经被下载过。对于那些虽然已经完成下载但未能成功生效、或是下载过程中遭遇失败、或在重试时间窗口内超过重试次数时,将不再进行重复下载操作。这种做法不仅提升了用户体验,还显著减少了不必要的网络消耗。

说完优点我们再来看看哪些是 CodePush 里还可以优化的:

  • 热更新流程长: 从 CodePush 方案流程图里我们可以看到CodePush 的检查热更新时机是在 bundle 加载后,当有强制热更新时又重新加载了一次 bundle。相当于在一次热更新流程里执行了两次 bundle 加载,这对于那些加载速度本就较为缓慢的设备而言,特别是某些Android手机来说,这种重复加载无疑会带来难以接受的时间成本与用户体验下降。

  • Bundle全量更新: 通过研究上述两个开源方案发现,这两个项目里并未对更新的 Bundle 体积做 diff 更新,对于单 bundle 项目而言每次更新 bundle 体积基本至少在1M 以上,用户每次更新下载流程中会比较慢,特别是网络状况不佳的情况,体验更加糟糕。

  • 不支持多 bundle 场景: 当一个 bundle 代码量达到一定程度,就会对开发效率和用户性能有较大影响,这个时候就需要将单 bundle 架构转换为多 bundle 架构。但是 CodePush 目前并不支持多 bundle 能力。

    • 存储命名空间冲突: Codepush 存储下载的资源命名空间是统一的, 对于不同的 bundle 的资源的 app.json 文件会相互覆盖。
    • 常量定义冲突:CodePush 对象内有很多静态变量,不同的 bundle 的 CodePush 实例是不同的,但是静态变量却是同一个,这里也会相互覆盖,互相影响。

看完热更新的客户端核心流程,我们再来看看接口请求的设计吧

热更新的一些核心参数:

  • deploymentKey: 标识项目的唯一key。
  • packageHash:当前在使用的包的 hash 值
  • appVersion:标识当前 app 版本
  • label: bundle 版本
  • clientUniqueId:客户端唯一设备 id

热更新的接口设计还是比较简单的。不过这里有个需要思考的地方,接口入参里为什么还要增加 packageHash 呢,这个问题的答案我们在最后再揭晓;

落地

在热更新领域,两个至关重要的指标是稳定性时长。对于前者,我们已经通过客户端CodePush实现的自动回滚机制为可能出现的重大异常提供了坚实的保障。接下来,为了进一步提升服务端的灵活性与可靠性,我们需要对CodePushServer进行必要的调整,以支持服务器端回滚和灰度发布功能。

稳定性

  • 灰度逻辑:对客户端设备 ID 与发布版本 ID 进行计算生成值与灰度放量值进行比较
private hexToDecimal(hexString: string) {
    const decimalValue = parseInt(hexString, 16)
    return Math.min(100, Math.max(0, Math.floor(decimalValue % 101)))
  }

  private isInCanaryRelease(
    clientUniqueId,
    channelReleaseId: string,
    rollout: number,
  ) {
    if (isEmpty(clientUniqueId) || isEmpty(channelReleaseId)) {
      return Promise.resolve(false)
    }
    const idSuffix = channelReleaseId + clientUniqueId.slice(-2)
    const numericValue = this.hexToDecimal(idSuffix)
    return Promise.resolve(numericValue < rollout)
  }
  • 回滚逻辑:比较快速的方式是,直接将上一个有效的 bundle 产物版本号提升一位置为最新的更新包即可。

时长优化:

一般来说减少一个下载流程的时间主要从两个方面入手,一个是下载内容的体积、另一个就是下载流程的缩短。同样的回到 CodePush 本身来说有三个主要的切入点:更新时机前置、预加载、包体积优化。

  • 更新时机前置: 由于 CodePush 检查更新时机是在 js 侧所以一旦需要强更就会经过两次 bundle,所以我们对 CodePush 的一个大的改造就是将更新时机前置到加载 bundle 之前,这就要求用 native 代码重写大部分 js 侧的代码。

image.png

从上图很明显看出热更新内置到 native 侧以后减少了一次 bundle 加载和热更新请求时长。因此改造后热更新流程整体时长减少至少 40%。

  • 更新包体积优化: 除了更新流程以外,更新包体积过大也是影响热更新时长的重要因素之一。

    • 原始包体积缩小: 优化包体积的方式主要还是对 bundle 进行拆分。具体拆分方案可以参考上一期的文章juejin.cn/post/755047…

    • 增量更新: 增量更新的核心思路就是用户每次更新只更新和当前 bundle 内容比的增量内容,这样每次下载的包体积就会大大减少。核心流程如下:

image.png

在上面的流程里我们实际上是做了简化,所有的 diff 这是用本次发布的热更新内容和基线内容比而得到的,而不是和最近几个版本去比较。如果每次热更新都和前几个版本比,那产生的 diff 资源会是巨大的,并且有些处在中间版本的用户并没有 diff 资源。所以综合考虑,每次发布热更新时只和基线内容比较,这样不仅所有的用户都能收到增量更新并且实际更新大小差距也不会很大。

通过上述措施,可以显著减少用户下载的数据量,从而改善整体热更新体验。 最终效果:热更新体积减少了98%,下载时长减少了92%

image.png image.png
  • 预加载: 尽管我们已经把更新时间压缩到 300ms 左右,但是如果用户在一次 app 的操作生命周期内打开了不同 bundle 并且都触发了热更新,那么就会经历多个热更新 loading。这种体验也是非常不好的。因为更新时间比较短,所以可以在 bundle 加载阶段同时触发下载动作。这样当用户打开新页面时就不会触发 loading 了。
优化前优化后
优化前.gif优化后.gif

最后我们来揭晓一下前面遗留的问题:为什么检查热更新的接口需要传递 packageHash 呢。这个主要目的还是为了省流,因为在服务端不仅会比较两次的热更新版本是否一致还会用 packageHash 比较 热更新内容是否一致,即使版本号不一致但是 packageHash 不一致服务端也不会下发新的更新内容。

总结与展望

通过对 CodePush 客户端 SDK 以及 CodePush-Sever 的改造优化,已经基本解决了因为频繁的热更新对客户造成的体验困扰。部分功能已经开源 github.com/xtransferor…,喜欢的欢迎 star 。

下一期我们将更新 Xtransfer的RN优化实践(三)鸿蒙适配之路

如果你感兴趣可以关注XRN,欢迎一起共建。

image.png

参考资料

github.com/lisong/code…

github.com/microsoft/c…