XPCodePush 热更新体系之增量更新
ljunb opened this issue · comments
背景
小鹏汽车 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 算法。
关于算法更多内容,可以参考原文。
尝试
ZIP 的差分
由于当前 RN 构建产物是一个压缩文件,因此针对新旧压缩包进行差分,是最容易想到的方法。基于这个想法,我们在本地终端工具进行相应验证。以线上某次热更活动为例,下载了 4.8.0
和 4.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.0
和 4.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#deleteFolders
、FolderDiff#deleteFiles
进行目录和文件的删除操作 - 完成所有 Patch 操作,拷贝
bspatch_workspace
到目标热更目录,后续流程与全量更新并无二异
以上任一流程,只要抛出 Error,都将认为 Patch 失败,直接中断流程并退出。
其他
除了关键的核心功能,还有其他一些小细节:
- 补充降级策略,如果当前增量更新出错,并且尝试超过一定次数后,则自动转为下载全量包,进行全量更新
- 目录的差分,只会存在于图片目录,由于大部分图片为 Icon 性质,Size 比较小,因此我们设定了一定阈值,超过该阈值才对图片文件进行 Diff,否则当做新增文件,下载后直接替换
- 补充关键节点的埋点,作为后续报表统计的基础数据,届时将更加直接的看到优化效果
最后
本次增量更新技术方案的设计和落地,是站在社区同行肩膀上,并结合自身情况,加以试错和不断改进的结果。本次分享,除了梳理方案从预研、设计到落地的过程,也是想传达一个观点:适合自己的,才是最好的。
希望其他团队设计自己的增量更新方案时,本篇文章能有所帮助。