热更新-差量更新与断点续传
篇章 :08-核心技术-热更新系统 状态 :已完成 阅读时间:约 25 分钟
一、引言
1.1 本章在系列中的定位
在上一章我们解决了"是否需要更新"的判断问题,本章继续深入"如何更新"这个核心问题。差量更新 与断点续传是 YooAsset 下载系统中的两个核心能力:
- 差量更新:服务器只下发"变化的部分",不下载整个包。在百万级 DAU 的游戏中,这一项优化每年可以节省数千万 CDN 费用。
- 断点续传:下载过程中断后,玩家下次启动游戏能继续从断点下载,而不是从头开始。在弱网环境下,这一项能力直接决定了玩家的留存。
1.2 本章要解决的核心问题
- YooAsset 如何计算"差异"?是文件级还是 Bundle 级?
- 差异包如何生成?如何让差异包尽量小?
- 断点信息如何持久化?游戏崩溃后如何恢复?
- 下载完成后如何校验完整性?
1.3 阅读前置要求
- 了解 YooAsset 的 Manifest 结构(参考 02-原理篇)
- 了解 HTTP Range 请求的基本原理
- 了解 MD5 / SHA256 等 Hash 算法
二、差异计算
2.1 差异计算的概念
差异计算是指对比本地版本与服务器版本的 Manifest,找出需要下载的文件。其本质是一个集合差问题:
css
服务端 Manifest V2: { A, B, C, D, E } (新版本)
本地 Manifest V1: { A, B, C } (旧版本)
差异 = 服务端 - 本地 = { D, E }
差异的类型有三种:
| 差异类型 | 含义 | 是否需要下载 |
|---|---|---|
| 新增 | V2 中有,V1 中没有 | 是 |
| 修改 | V2 和 V1 中都有,但 Hash 不同 | 是 |
| 删除 | V1 中有,V2 中没有 | 否(仅删除本地文件) |
2.2 差异计算的算法
2.2.1 Hash 对比
YooAsset 采用 Bundle 级 Hash 对比:
csharp
public class DiffCalculator
{
public List<BundleInfo> CalculateDiff(
PackageManifest localManifest,
PackageManifest remoteManifest)
{
var diffBundles = new List<BundleInfo>();
var localMap = localManifest.BundleList.ToDictionary(b => b.BundleName);
foreach (var remoteBundle in remoteManifest.BundleList)
{
// 本地不存在
if (!localMap.ContainsKey(remoteBundle.BundleName))
{
diffBundles.Add(remoteBundle);
continue;
}
// Hash 不同
var localBundle = localMap[remoteBundle.BundleName];
if (localBundle.FileHash != remoteBundle.FileHash)
{
diffBundles.Add(remoteBundle);
}
}
return diffBundles;
}
}
2.2.2 文件对比
Hash 对比已经足够精确(YooAsset 默认使用 MD5 或 CRC32),所以 YooAsset 不再进行"文件级字节对比"。但 Hash 算法的选择会显著影响计算性能:
| 算法 | 速度 | 安全性 | 碰撞概率 | 推荐场景 |
|---|---|---|---|---|
| CRC32 | 极快 | 低 | 较高 | 内部版本校验 |
| MD5 | 快 | 中 | 已破解 | 资源完整性校验 |
| SHA1 | 较快 | 中 | 已破解 | 不推荐 |
| SHA256 | 较慢 | 高 | 极低 | 金融级别 |
YooAsset 默认使用 CRC32 (速度优先),如有安全需求可在 IBuildPipeline 中替换为 SHA256。
2.2.3 增量对比
对于频繁更新的项目(如放置类游戏每周一次大更),差异计算还可以进一步优化:
csharp
// 增量对比:只对比"上次构建"到"本次构建"的变化
public class IncrementalDiff
{
public List<BundleInfo> CalculateIncrementalDiff(
PackageManifest lastManifest, // 上次清单
PackageManifest currentManifest, // 本次清单
List<string> baselineBundles) // 基线 Bundle(不变)
{
var diff = new List<BundleInfo>();
foreach (var bundle in currentManifest.BundleList)
{
// 如果在基线中且 Hash 不变,跳过
if (baselineBundles.Contains(bundle.BundleName) &&
lastManifest.GetBundle(bundle.BundleName)?.FileHash == bundle.FileHash)
{
continue;
}
diff.Add(bundle);
}
return diff;
}
}
2.3 差异计算的优化
2.3.1 并行对比
差异计算本身是 CPU 密集型操作,但 Bundle 之间相互独立,可以并行处理:
csharp
public async Task<List<BundleInfo>> CalculateDiffParallel(
PackageManifest local,
PackageManifest remote)
{
var diffList = new ConcurrentBag<BundleInfo>();
var localMap = local.BundleList.ToDictionary(b => b.BundleName);
await Parallel.ForEachAsync(remote.BundleList, async (remoteBundle, ct) =>
{
if (!localMap.TryGetValue(remoteBundle.BundleName, out var localBundle) ||
localBundle.FileHash != remoteBundle.FileHash)
{
diffList.Add(remoteBundle);
}
await Task.Yield();
});
return diffList.ToList();
}
2.3.2 缓存对比结果
对于"经常启动游戏但资源未变"的玩家,差异计算结果可以缓存到本地:
python
{yooasset_cache_root}/
├── DefaultPackage/
│ ├── PackageManifest_1.0.5.bytes
│ ├── diff_cache.bin # 差异结果缓存
│ └── ...
csharp
public class DiffCache
{
public List<BundleInfo> GetDiff(string localVersion, string remoteVersion)
{
string cacheKey = $"{localVersion}_{remoteVersion}";
string cachePath = $"diff_cache_{cacheKey}.bin";
if (File.Exists(cachePath))
{
return DeserializeFromFile<List<BundleInfo>>(cachePath);
}
return null;
}
}
2.3.3 增量对比
增量对比是指"只对比上次以来的变化"。在 YooAsset 中,可以通过 LastCheckVersion 实现:
csharp
// 上次检查的版本
PlayerPrefs.SetString("LastCheckVersion", "1.0.4");
// 本次检查:只对比 1.0.4 → 1.0.5
var op = YooAssets.RequestCheckVersionAsync(lastCheckVersion: "1.0.4");
三、增量包生成
3.1 增量包的概念
增量包是只包含差异资源的服务器端文件。它由以下三部分组成:
- 新的
PackageManifest_X.Y.Z.bytes(含全量清单信息) - 变化的
*.bundle文件 - 差异描述文件(可选,用于客户端预览)
3.2 增量包的生成流程
scss
[构建机执行 IBuildPipeline.Build]
│
▼
[收集资源] → AssetBundleCollector
│
▼
[构建全量包] → 输出 bundle_v1.0.6/
│
▼
[对比历史版本] → diff_v1.0.5_to_v1.0.6/
│ ├── changed_bundles/ (变化的 Bundle)
│ ├── added_bundles/ (新增的 Bundle)
│ ├── removed_bundles.txt (删除的 Bundle 列表)
│ └── diff_manifest.json (差异描述)
│
▼
[上传到 CDN] → /cdn/DefaultPackage/v1.0.6/
│
▼
[更新版本索引] → /cdn/DefaultPackage/version_index.json
3.3 增量包的大小优化
3.3.1 压缩优化
YooAsset 默认使用 LZ4 压缩(速度优先),可在构建脚本中选择其他算法:
csharp
public class CustomBuildPipeline : IBuildPipeline
{
public ECompressOption CompressOption = ECompressOption.LZ4;
// 也可以按文件类型选择不同的压缩算法
public ECompressOption GetCompressOption(string filePath)
{
if (filePath.EndsWith(".png") || filePath.EndsWith(".jpg"))
return ECompressOption.Uncompressed; // 图片已经压缩过
if (filePath.EndsWith(".json") || filePath.EndsWith(".txt"))
return ECompressOption.LZMA; // 文本用 LZMA 压缩率更高
return ECompressOption.LZ4;
}
}
| 算法 | 压缩率 | 速度 | 适用场景 |
|---|---|---|---|
| Uncompressed | 1.0x | 最快 | 已是压缩格式的文件 |
| LZ4 | ~1.5x | 极快 | 运行时实时解压 |
| LZMA | ~2.5x | 慢 | 离线下载包 |
3.3.2 去重优化
YooAsset 在构建时会自动去重共享资源,避免多个 Bundle 中包含相同文件:
csharp
// AssetBundleCollector 配置
public class CollectorConfig
{
public bool EnableSharedPackRule = true; // 启用共享包规则
public string PackRuleName = "PackShared"; // 使用共享包规则
}
3.3.3 共享依赖优化
对于多个 Bundle 都依赖的公共资源,应打成共享 Bundle:
makefile
bundle_battle:
├── Hero.prefab (引用)
├── Enemy.prefab (引用)
└── SkillConfig.bin (引用) ← 公共资源,应独立
正确做法:
bundle_shared_skill:
└── SkillConfig.bin
bundle_battle_hero:
└── Hero.prefab
bundle_battle_enemy:
└── Enemy.prefab
四、断点恢复
4.1 断点续传的概念
断点续传(HTTP Range)是 HTTP 协议的标准能力,允许客户端只请求文件的某一段:
ini
[Client Request]
GET /bundle/asset_01 HTTP/1.1
Range: bytes=1024000-2047999 ← 请求从 1MB 开始的 1MB 数据
[Server Response]
HTTP/1.1 206 Partial Content
Content-Range: bytes 1024000-2047999/5120000
Content-Length: 1024000
YooAsset 的断点续传能力基于 UnityWebRequest + Range 头实现。
4.2 断点信息的管理
4.2.1 断点信息的存储
YooAsset 的下载断点信息存储在 DownloadResumeFile 中:
csharp
public class DownloadResumeData
{
public string BundleName; // Bundle 名称
public long DownloadedBytes; // 已下载字节数
public string FileHash; // 文件 Hash
public long TotalBytes; // 文件总大小
public long LastUpdateTime; // 最后更新时间
}
存储位置:
python
{yooasset_cache_root}/
├── DefaultPackage/
│ ├── download_resume.bin # 断点信息文件
│ └── ...
4.2.2 断点信息的更新
下载过程中,YooAsset 会周期性(默认 1MB 数据)更新断点信息:
csharp
public class DownloadOperation
{
private long _lastResumeSaveSize = 0;
void OnDownloadProgress(long currentBytes, long totalBytes)
{
// 每下载 1MB 保存一次断点
if (currentBytes - _lastResumeSaveSize > 1024 * 1024)
{
SaveResumeData(currentBytes);
_lastResumeSaveSize = currentBytes;
}
}
}
4.2.3 断点信息的清理
下载完成后,断点信息会立即清理:
csharp
public void OnDownloadComplete()
{
// 1. 删除断点文件
File.Delete(_resumeFilePath);
// 2. 校验文件完整性
VerifyFileHash();
}
4.3 断点续传的实现
4.3.1 HTTP Range 请求
csharp
public class RangeDownloader
{
public async Task DownloadWithRange(string url, string savePath, long totalBytes)
{
long downloadedBytes = 0;
if (File.Exists(savePath))
{
downloadedBytes = new FileInfo(savePath).Length;
}
if (downloadedBytes == totalBytes)
{
// 已下载完成
return;
}
// 使用 Range 头请求剩余部分
using var request = UnityWebRequest.Get(url);
request.SetRequestHeader("Range", $"bytes={downloadedBytes}-");
// 追加到本地文件
var handler = new DownloadHandlerFile(savePath, true); // append=true
request.downloadHandler = handler;
await request.SendWebRequest();
}
}
4.3.2 本地文件追加
YooAsset 使用 FileStream 的 Append 模式:
csharp
public class DownloadHandlerFile : DownloadHandlerScript
{
private FileStream _fileStream;
public DownloadHandlerFile(string path, bool append) : base()
{
_fileStream = new FileStream(path, FileMode.OpenOrCreate, FileAccess.Write);
if (append) _fileStream.Seek(0, SeekOrigin.End);
}
protected override bool ReceiveData(byte[] data, int dataLength)
{
_fileStream.Write(data, 0, dataLength);
return true;
}
}
4.3.3 完整性校验
下载完成后立即校验 Hash:
csharp
public bool VerifyFileHash(string filePath, string expectedHash)
{
using var stream = File.OpenRead(filePath);
using var md5 = MD5.Create();
byte[] hashBytes = md5.ComputeHash(stream);
string actualHash = BitConverter.ToString(hashBytes).Replace("-", "").ToLower();
return actualHash == expectedHash.ToLower();
}
五、完整性校验
5.1 完整性校验的概念
完整性校验是下载的最后一道关卡,目的是确保下载的文件与服务端完全一致。校验失败时必须重新下载。
5.2 完整性校验的实现
5.2.1 Hash 计算
YooAsset 在下载完成后立即计算本地文件 Hash:
csharp
public class IntegrityChecker
{
public bool CheckFile(string filePath, string expectedHash)
{
if (!File.Exists(filePath))
return false;
// 使用 CRC32(速度优先)
using var stream = File.OpenRead(filePath);
var actualHash = Crc32Helper.ComputeHash(stream);
return actualHash.Equals(expectedHash, StringComparison.OrdinalIgnoreCase);
}
}
5.2.2 Hash 对比
对比时需要规范化大小写(Hash 通常为十六进制字符串):
csharp
public bool CompareHash(string actual, string expected)
{
// 统一为小写
actual = actual?.ToLowerInvariant();
expected = expected?.ToLowerInvariant();
return string.Equals(actual, expected);
}
5.2.3 校验失败处理
csharp
public class DownloadErrorHandler
{
private int _retryCount = 0;
private const int MAX_RETRY = 3;
public async Task<bool> DownloadWithRetry(string url, string savePath, string expectedHash)
{
while (_retryCount < MAX_RETRY)
{
try
{
await DownloadFileAsync(url, savePath);
if (IntegrityChecker.CheckFile(savePath, expectedHash))
return true;
Debug.LogWarning($"Hash mismatch, retry {_retryCount + 1}/{MAX_RETRY}");
File.Delete(savePath);
}
catch (Exception ex)
{
Debug.LogError($"Download failed: {ex.Message}");
}
_retryCount++;
await Task.Delay(TimeSpan.FromSeconds(Math.Pow(2, _retryCount))); // 指数退避
}
return false;
}
}
5.3 完整性校验的性能
5.3.1 Hash 算法选择
| 算法 | 速度 | 适用场景 |
|---|---|---|
| CRC32 | 10 GB/s | 大文件完整性校验(推荐) |
| MD5 | 700 MB/s | 中等文件 |
| SHA256 | 400 MB/s | 金融级别(不需要) |
5.3.2 校验性能
对一个 100MB 的 Bundle:
- CRC32:~10ms
- MD5:~150ms
- SHA256:~250ms
YooAsset 默认使用 CRC32,因为 Bundle 通常很大(几十 MB 到几百 MB),速度差异显著。
5.3.3 优化策略
对于批量下载场景,可以并行校验多个文件:
csharp
public async Task VerifyAllFilesAsync(List<BundleInfo> bundles)
{
var semaphore = new SemaphoreSlim(Environment.ProcessorCount);
var tasks = bundles.Select(async bundle =>
{
await semaphore.WaitAsync();
try
{
var isValid = IntegrityChecker.CheckFile(
GetBundlePath(bundle),
bundle.FileHash
);
if (!isValid)
{
Debug.LogError($"Bundle {bundle.BundleName} failed integrity check");
}
}
finally
{
semaphore.Release();
}
});
await Task.WhenAll(tasks);
}
六、总结
6.1 本章要点回顾
- 差异计算:YooAsset 采用 Bundle 级 Hash 对比,支持并行计算和缓存
- 增量包生成:基于全量构建后与历史版本对比,自动生成差异文件
- 断点续传:使用 HTTP Range 头实现,断点信息持久化到本地
- 完整性校验:下载完成后立即校验 Hash,失败自动重试
6.2 与前后章节的关联
- 前章:08-01 版本管理机制解决了"是否需要更新"
- 本章:解决了"如何更新"------只下载差异、断点续传、完整性校验
- 后章:08-03 边玩边下载解决"运行时按需下载"
6.3 实践建议
- 差异粒度:尽量采用 Bundle 级而不是文件级差异,减少对比开销
- 断点保存频率:不要每下载一个字节就保存断点,建议每 1MB 一次
- Hash 算法:Bundle 完整性校验使用 CRC32 即可,速度优势明显
- 重试策略:采用指数退避算法,避免短时间内多次重试
- 完整性校验:失败后一定要重试,不要相信"99% 成功的下载"
下一篇 :边玩边下载