Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

XPCodePush 热更新体系之增量更新 #11

Open
ljunb opened this issue Jul 19, 2022 · 0 comments
Open

XPCodePush 热更新体系之增量更新 #11

ljunb opened this issue Jul 19, 2022 · 0 comments

Comments

@ljunb
Copy link
Owner

ljunb commented Jul 19, 2022

背景

小鹏汽车 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,否则当做新增文件,下载后直接替换
  • 补充关键节点的埋点,作为后续报表统计的基础数据,届时将更加直接的看到优化效果

最后

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

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

参考资料

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

1 participant