ljunb / rn-relates

📝一些关于 React Native 项目实践的记录。

Geek Repo:Geek Repo

Github PK Tool:Github PK Tool

XPCodePush 热更新体系之增量更新

ljunb opened this issue · comments

commented

背景

小鹏汽车 App ReactNative (以下简称RN) 热更新的实践方式,可能与其他 App 不大一样。当前虽然已经做了 RN 业务的拆包处理,但实际 RN 在 Jenkins 的构建产物是一个压缩文件,该文件在构建 App 的时候通过脚本下载和解压,最终打进 App 里面,作为当前版本的原始基准包。当时我们在设计热更新体系的时候,没有对 RN 构建产物做过多修改,最终每次热更活动下发的,都是这个完整的压缩文件。在热更完毕之后,RN Bundle 管理类将不再从 Asset 中加载拆分后的 Bundle 文件,而是加载沙盒中热更目录的文件,来达到最终的热更效果。

现状

与进入 RN 页面时动态下发 Bundle 的方式不同,当前 App 只会在启动的时候触发检查更新,更新完毕后在下次启动时生效。当用户进入某个 RN 页面时,将直接执行沙盒中对应的业务 Bundle 文件。这种下发和加载方式,资源版本管理会比较简单,但也会有以下问题:

  • 热更活动下发的资源包越来越大,但实际该活动只有个别业务发生了变更
  • 每个业务方没有独立的版本管理规范,活动发布时存在强耦合关系

RN 业务独立打包以及相应版本管理,后续将由精卫平台来承载。慢慢增长的资源包大小,会是目前重点关注的问题。减小资源包的大小,对降低用户流量使用、公司带宽费用都会有明显的提升效果;另外如果大幅降低下载耗时,新活动也将更快地触达用户,产生相应的业务价值。

预研

CodePush 本身支持对图片资源的增量更新,但不适用于 Bundle 文件。如果想要实现增量更新,且是单 Bundle 的 RN 工程,可以直接使用 RN 中文网的 react-native-pushy,后端服务也是在国内,可以不用担心网络问题。

由于我们的 App 是多 Bundle 的应用场景,以上两个库不大适用,因此需要定制一套符合自身情况的增量更新方案。

其实在社区可以搜到不少增量更新相关的方案,Bsdp 就是其中之一,在参考 Shopee携程 分享的技术方案,并进行一番预研和本地验证后,我们选择了 Bsdp 算法,并按实际情况做了一些调整(方案来自 Shopee 团队,下文也会引述一些他们博文的内容,在此表示感谢)。

Bsdp(BSDiff & BSPatch)算法

引自 Shopee 技术团队博文:

BSDiff 算法是一个非常流行的差分算法,它专注于得到尽可能小的 Patch 体积(适用于 Patch 要通过网络传输的更新方式),被 Google Chrome 等软件广泛使用,非常适合代码升级这种具有局域性的稀疏的改动。

BSDiff 算法开源且免费,用 C 语言写成,源代码可以在服务端、Android/iOS 端执行。BSDiff 算法所搭配的打补丁算法 BSPatch 可以将旧文件和 Patch 结合,恢复出新文件。BSDiff 和 BSPatch 算法并称 Bsdp 算法。

流程示意图:
BSDiff

关于算法更多内容,可以参考原文。

尝试

ZIP 的差分

由于当前 RN 构建产物是一个压缩文件,因此针对新旧压缩包进行差分,是最容易想到的方法。基于这个想法,我们在本地终端工具进行相应验证。以线上某次热更活动为例,下载了 4.8.04.8.0-patch1 两个包(当前 RN 资源包的大小已经达到了 30+m,实际里面大部分是图片资源,这也是我们后续优化的重点方向,此次主要讨论增量更新方案),最终 Diff 出来的文件是 1.5m 左右,这已经远小于原包大小。对我们来说,这算是已经迈进了一大步,有信心认为自己正在往正确的方向前进。

基准包获取

要进行差分,必将需要拿到新旧包,由于 App 构建时,已经解压了 RN 资源包,图片资源和各个业务 Bundle 都已经独立打包进 App。因此,如果要获取旧包(以下称基准包),按现在情况有两种方式:

  • 直接内置一份 RN 压缩包
  • 维持原样,在下载完差分包后,拷贝 Asset 中的文件到临时目录,并压缩成新的 RN 压缩包

内置新的压缩包会增加 App Size,评估之后不在考虑范围之内;而拷贝 Asset 目标文件,再压缩成基准包,貌似是可行的方案。

但是很遗憾,我们也遇到了与 Shopee 团队一样的问题,就是:ZIP 文件在不同端不兼容

在构建 RN 产物的时候,cli 工具在压缩时使用了 jszip,而在 iOS 端验证的时候,使用的是 SSZipArchive,验证下来发现两者压缩后的基准包是不一样的,MD5值已经发生了变更,这样就无法用来进行差分。在遇到这个问题的时候,一开始考虑在 App 引入 NodeJS 的运行环境(nodejs-mobile),再用相同的压缩库进行压缩。看似可行,但考虑到引入的包大小、功能冗余、适配实现等问题,后来也放弃了这个想法。

FolderPatch

在遇到压缩文件不兼容问题后,又重新梳理了 Shopee 和携程两个团队分享的方案,并在两篇博文中,提取到了两个比较关键的信息:

  • 针对压缩文件的差分产物,比对原文件进行差分后再压缩的产物,要大很多
  • 引入对目录的差分操作

以上文对 4.8.04.8.0-patch1 的测试为例,原来针对压缩文件的差分,产物大小 1.5m 左右;最新测试是先生成新包每个文件的差分文件,再一并压缩,最终不到 100k!这个提升效果是再次令人惊喜的,在一番新的梳理之后,我们决定采用与 Shopee 团队一致的技术方案,并稍作调整,定制出适合自己的 FolderPatch。

方案

引述自 Shopee 团队博文:

先比较新旧文件夹的目录结构和内含文件,新文件夹相对于旧文件夹所产生的变动包括五种情况:新增目录、删除目录、新增文件、删除文件、修改文件。

对于新增目录、删除目录、删除文件的情况,记录下对应的操作;对于修改文件,调用 BSDiff 函数。我们基于直接资源里的每一个文件的内容修改求 BSDiff,留下其 Patch 文件体积足够小,避开了压缩不利差分性质的困境;对于新增文件,则记录下所增文件的相对路径,并拷贝此文件。

设计

在原下发逻辑中,App 侧下载拿到的签名包如下所示:

|--.codepushrelease                 // JWT验签文件
|--react-native                     // RN相关资源
    |--main.unbundle        
    |--business-a.unbundle
    |--business-b.unbundle
    |--ReactNativeResource.bundle   // 图片目录
        |--common/common_bg.png
        ...

为了能记录目录和文件的新增、删除等操作,拟定引入 FolderDiff.json;为了能校验新旧文件的 Patch 结果是否正确,引入 ManifestHash.json 文件,记录新文件的 MD5 值。

其中 FolderDiff.json 的示例内容如下:

{
    "addFolders": [
        "react-native/ReactNativeResource.bundle/new_folder",
        "react-native/ReactNativeResource.bundle/new_folder/nested_folder"
    ],
    "addFiles": [
        "react-native/ReactNativeResource.bundle/new_folder/car_new.png",
        "react-native/ReactNativeResource.bundle/new_folder/nested_folder/car_new_nested.png",
        ".codepushrelease"
    ],
    "deleteFolders": [
        "react-native/ReactNativeResource.bundle/need_del_folder",
    ],
    "deleteFiles": [
        "react-native/ReactNativeResource.bundle/need_del_file.png"
    ]
}

由于 .codepushrelease 文件每次都会变更,并且比较小,因此我们选择直接忽略,当做新增文件直接拷贝。

再看看 ManifestHash.json 的示例内容(只记录变更文件的 MD5):

{
    "react-native/business-a.unbundle": "MD5 for business-a.unbundle",
    "react-native/business-b.unbundle": "MD5 for business-b.unbundle",
    "react-native/ReactNativeResource.bundle/common/modify.png": "MD5 for modify.png"
}

而原来的 react-native 目录,将只会保留新增文件和变更文件的差分产物,如下所示:

|--react-native
    |--business-a.unbundle.patched
    |--business-b.unbundle.patched
    |--ReactNativeResource.bundle/
        |--common
            |--modify.png.patched
        |--new_folder
            |--car_new.png
            |--nested_folder
                |--car_new_nested.png

基准包获取

在设计以上规则之后,依然需要面对基准包获取问题,主要是涉及下文所述两个方面。

Asset 基准包

在首次热更活动下发时,目标基准包将会是 Asset 版本。在 Patch 结束后,沙盒中的热更目录应该包含完整的 RN 资源包内容,但下发的差分包中只会包含新增文件和变更文件,其他文件该如何获取呢?

最终还是从 ManifestHash.json 中入手。在后端进行 Diff 的时候,可以知道当前基准包中的所有文件,因此可以在这个过程中,把必要的文件记录到 ManifestHash.json 中。考虑到图片目录 ReactNativeResource.bundle 必定存在,App 侧可以做兜底拷贝处理,因此重点是记录其他业务 Bundle 文件集合。在完成所有文件的记录后,如果当前文件没有变更,则对应的 MD5 置为 0;否则记录变更后的 MD5,App 侧按需进行 Patch。

调整后的 ManifestHash.json 内容示例:

{
    "react-native/main.unbundle": "0",
    "react-native/business-a.unbundle": "MD5 for business-a.unbundle",
    "react-native/business-b.unbundle": "MD5 for business-b.unbundle",
    "react-native/business-c.unbundle": "0",
    "react-native/business-d.unbundle": "0",
    "react-native/ReactNativeResource.bundle/common/modify.png": "MD5 for modify.png"
}

这样 App 就可以根据文件内容,从 Asset 中拷贝目标文件到沙盒目录中,并按需进行 Patch。

Jenkins 基准包

RN 的构建产物,最终都会上传到 OSS 服务,在获取非首次热更活动的基准包问题上,首先考虑的是后端按规则拼接包下载链接,然后下载作为基准包进行 Diff。方案最开始阶段是这么设计的,后来与测试同学沟通后,发现不一定适用。主要是当前的集成阶段,在 Jenkins 上构建时,一般会勾选一个选项,用时间戳为当前包打上 Tag。这样的话,后端是无法简单的通过字符串拼接,来获得基准包下载链接的,因为时间戳不固定。

带 Tag 的集成包,方便我们在集成阶段进行问题的定位,因此有必要进行保留。为了兼容这种情况,构造基准包下载链接的任务,就应该由 App 提交给后端。但 App 侧应该如何获取这个链接呢?

解铃还须系铃人,生成 OSS 保存链接和打 Tag 的操作都来自 Jenkins,可以在这个过程中,把按需带时间戳的 OSS 下载链接,按一定路径,保存到 RN 构建产物中,然后 App 在发起检查更新请求时,携带该链接作为参数,给到后端作为基准包的下载链接,达到最终目的。

调整后的构建产物将包含 DownloadUrlFromJenkins 文件,其记录当前 RN 资源包的下载链接:

|--DownloadUrlFromJenkins
|--react-native
    |--main.unbundle
    ...

为了在每次检查更新时,都能拿到当前基准包的下载链接,后端在下发差分包时,都应把最新包的 DownloadUrlFromJenkins 一起下发。考虑到该文件大小问题,将与上文提到 .codepushrelease 文件一样处理逻辑,作为新增文件,最终 FolderDiff.json 内容示例:

{
    "addFiles": [
        // 必将有以下两个文件
        ".codepushrelease", 
        "DownloadUrlFromJenkins",
    ],
    ...
}

下发的差分包中,也将包含该文件:

|--DownloadUrlFromJenkins
|--.codepushrelease
|--FolderDiff.json
|--ManifestHash.json
|--react-native
    |--business-a.unbundle.patched
    |--ReactNativeResource.bundle
        |--new_folder
            |--car_new.png
    ...

至此,完成整个 FolderPatch 的规范设计。

Patch

App 侧的 Patch 操作,下载完差分包之后,主要按以下步骤进行:

  • 如果当前为 Asset 版本,则根据 ManifestHash.json 文件,拷贝目标文件到沙盒操作目录 bspatch_workspace;如果为沙盒目录版本,则直接拷贝上次热更的目录到 bspatch_workspace
  • 根据 FolderDiff#addFolders,创建新目录
  • 根据 FolderDiff#addFiles,进行新增文件的拷贝,如有同名文件则直接覆盖
  • 根据 ManifestHash.json 文件,如果文件 MD5 不为 0,则进行 Patch 操作,并对产物进行相应的 MD5 校验
  • 根据 FolderDiff#deleteFoldersFolderDiff#deleteFiles 进行目录和文件的删除操作
  • 完成所有 Patch 操作,拷贝 bspatch_workspace 到目标热更目录,后续流程与全量更新并无二异

以上任一流程,只要抛出 Error,都将认为 Patch 失败,直接中断流程并退出。

其他

除了关键的核心功能,还有其他一些小细节:

  • 补充降级策略,如果当前增量更新出错,并且尝试超过一定次数后,则自动转为下载全量包,进行全量更新
  • 目录的差分,只会存在于图片目录,由于大部分图片为 Icon 性质,Size 比较小,因此我们设定了一定阈值,超过该阈值才对图片文件进行 Diff,否则当做新增文件,下载后直接替换
  • 补充关键节点的埋点,作为后续报表统计的基础数据,届时将更加直接的看到优化效果

最后

本次增量更新技术方案的设计和落地,是站在社区同行肩膀上,并结合自身情况,加以试错和不断改进的结果。本次分享,除了梳理方案从预研、设计到落地的过程,也是想传达一个观点:适合自己的,才是最好的。

希望其他团队设计自己的增量更新方案时,本篇文章能有所帮助。

参考资料