一. 官方提供的 AssetsManager

Cocos 官方提供了一套基于 AssetsManager 的热更新方案, 这套方案大致是这样的:

  1. 每次构建时配合 version_generator.js 生成清单文件: project.manifest 和 version.manifest
  2. 将最新构建好的 代码/资源/清单文件 放到服务器上
  3. 启动游戏时使用 AssetsManager 检查更新, 对比差异, 下载更新, 重启游戏

这样做的优点有:

  1. 服务器几乎不需要做任何事情, 只需要提供一个静态文件存储就行.
  2. 无版本号概念, 可以从任何版本更新到最新版.

缺点也不少:

  1. 没法做灰度更新, 即不能一部分用户先测试更新, 没有问题后再全网开放.
  2. 需要下载多个文件, 存在下载失败的可能, 需要特别小心.
  3. 每次把最新的版本放到 oss 上是一件非常痛苦的事情, 因为 oss 不支持压缩, 文件多了后每次要耗费数十分钟之久.

其中灰度更新是我们比较在意的, 在面试时我也问过这个问题, 好点的同学说他们是通过两个安装包来实现的, 一些同学干脆回答他们没有做灰度更新, 都无法令人满意. 而真正令我们下定决心实现一个新的方案的原因是很多用户反馈会卡在 100% 进度, 卸载重新安装包也无法解决.

二. 新的方案

1. 方案

因此我们为此设计了一套更简易的热更新方案:

  1. 每次构建后的内容, 使用 version_generator 生成清单, 保留所有内容到以 version 命名的文件夹
  2. 遮掩有多个版本后, 我们使用一个脚本对比不同版本的 project.manifest 便能生成这两个版本的差异文件
  3. 将这些差异文件传到服务器上, 使用当前版本去匹配一个最新版本的差异包下载下来解压就行.

更新的流程如下图所示:

我们如何解决 AssetsManager 的缺点的呢?

1). 灰度更新

在向服务器请求最新版本时, 除了必须的本地版本外, 还可以带上一个设备 id, 在后台维护一个 测试设备id 列表, 这样就知道这次请求是不是测试设备发出的了, 这样我们就完成了第一步.

每次在后台新增热更新时默认状态是 测试, 在服务器端获取最新热更新的逻辑中加入: 只有测试设备才能更新状态为测试的热更新 限制, 测试完成后将热更新修改为上线状态.

2). 文件数量

使用这个方案上传到服务器的文件数量极少, 第 N 次更新只会增加 N 个文件, 如果觉得更新数量太多的话, 我们可以以接力更新 (A->B->C) 的方式减少生成的数量. 这样我们就避免了 AssetsManager 的第二个和第三个缺点.

2. 方案细节:

1). 生成差异包

我们是以构建时 git HEAD 的 short hash 作为版本号的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
├── 44fd23968b
│   ├── main.js
│   ├── project.manifest
│   ├── res
│   ├── script
│   ├── src
│   └── version.manifest
└── f144d9017a
├── main.js
├── project.manifest
├── res
├── script
├── src
└── version.manifest

对比脚本也极其简单, 我们是用 python 实现的, 核心逻辑如下:

1
2
3
4
5
6
7
def diff(_version1, _version2, _output):
assets1 = shutils.read_json(os.path.join(_version1, "project.manifest")).get("assets", {})
assets2 = shutils.read_json(os.path.join(_version2, "project.manifest")).get("assets", {})
for k,v in assets2.iteritems():
v2 = assets1.get(k)
if not v2 or v.get("md5") != v2.get("md5"):
shutil.copy(os.path.join(_version2, k), _output)

再把对比出差异的目录压缩成 zip: 44fd23968b-f144d9017a.zip 并传到服务器上.

2). 下载差异包

下载我们用的是 jsb.Downloader 模块, 下载代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
public update() {
this.mDownloader = new jsb.Downloader({
countOfMaxProcessingTasks: 32,
timeoutInSeconds: 5,
tempFileNameSuffix: ".temp",
});
jsb.fileUtils.createDirectory(this.getStoragePath());
this.mDownloader.setOnFileTaskSuccess(this.onTaskSuccess.bind(this));
this.mDownloader.setOnTaskError(this.onTaskError.bind(this));
this.mDownloader.setOnTaskProgress(this.onTaskProgress.bind(this));
this.mDownloader.createDownloadFileTask(this.mUrl, storagePath, "hotupdate");
}

值得一提的是 countOfMaxProcessingTasks 不能填 1, 不然会导致 Android 上无法开始下载.

3). 解压

解压我们没有单独写实现, 复用了 jsb.AssetsManagerdecompress 函数, 这个函数之前是私有的, 需要修改下 c++ 代码改成 public 的然后 tojs 下:

1
2
3
4
5
6
7
8
9
10
private decompress(zipPath: string) {
if (new jsb.AssetsManager("", this.getStoragePath(), null).decompress(zipPath)) {
cc.log("SimpleHotUpdate => decompress:", "successed");
this.saveSearchPath([this.getStoragePath()]);
this.onUpdateSuccess();
return;
}
cc.log("SimpleHotUpdate => decompress:", "解压失败");
this.onUpdateFail("解压失败");
}

好久没写文章了, 最近换了新的电脑, 又有了一些写东西的欲望, 希望能多坚持一会吧.