08-02-热更新-差量更新与断点续传

0 阅读7分钟

热更新-差量更新与断点续传

篇章:08-核心技术-热更新系统 状态:已完成 阅读时间:约 25 分钟


一、引言

1.1 本章在系列中的定位

在上一章我们解决了"是否需要更新"的判断问题,本章继续深入"如何更新"这个核心问题。差量更新与断点续传是 YooAsset 下载系统中的两个核心能力:

  • 差量更新:服务器只下发"变化的部分",不下载整个包。在百万级 DAU 的游戏中,这一项优化每年可以节省数千万 CDN 费用。
  • 断点续传:下载过程中断后,玩家下次启动游戏能继续从断点下载,而不是从头开始。在弱网环境下,这一项能力直接决定了玩家的留存。

1.2 本章要解决的核心问题

  • YooAsset 如何计算"差异"?是文件级还是 Bundle 级?
  • 差异包如何生成?如何让差异包尽量小?
  • 断点信息如何持久化?游戏崩溃后如何恢复?
  • 下载完成后如何校验完整性?

1.3 阅读前置要求

  • 了解 YooAsset 的 Manifest 结构(参考 02-原理篇)
  • 了解 HTTP Range 请求的基本原理
  • 了解 MD5 / SHA256 等 Hash 算法

二、差异计算

2.1 差异计算的概念

差异计算是指对比本地版本与服务器版本的 Manifest,找出需要下载的文件。其本质是一个集合差问题:

服务端 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 对比:

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 增量对比

对于频繁更新的项目(如放置类游戏每周一次大更),差异计算还可以进一步优化:

// 增量对比:只对比"上次构建"到"本次构建"的变化
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 之间相互独立,可以并行处理:

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 缓存对比结果

对于"经常启动游戏但资源未变"的玩家,差异计算结果可以缓存到本地:

{yooasset_cache_root}/
├── DefaultPackage/
│   ├── PackageManifest_1.0.5.bytes
│   ├── diff_cache.bin     # 差异结果缓存
│   └── ...
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 实现:

// 上次检查的版本
PlayerPrefs.SetString("LastCheckVersion", "1.0.4");

// 本次检查:只对比 1.0.4 → 1.0.5
var op = YooAssets.RequestCheckVersionAsync(lastCheckVersion: "1.0.4");

三、增量包生成

3.1 增量包的概念

增量包是只包含差异资源的服务器端文件。它由以下三部分组成:

  1. 新的 PackageManifest_X.Y.Z.bytes(含全量清单信息)
  2. 变化的 *.bundle 文件
  3. 差异描述文件(可选,用于客户端预览)

3.2 增量包的生成流程

[构建机执行 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 压缩(速度优先),可在构建脚本中选择其他算法:

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;
    }
}
算法压缩率速度适用场景
Uncompressed1.0x最快已是压缩格式的文件
LZ4~1.5x极快运行时实时解压
LZMA~2.5x慢离线下载包
3.3.2 去重优化

YooAsset 在构建时会自动去重共享资源,避免多个 Bundle 中包含相同文件:

// AssetBundleCollector 配置
public class CollectorConfig
{
    public bool EnableSharedPackRule = true;   // 启用共享包规则
    public string PackRuleName = "PackShared";  // 使用共享包规则
}
3.3.3 共享依赖优化

对于多个 Bundle 都依赖的公共资源,应打成共享 Bundle:

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 协议的标准能力,允许客户端只请求文件的某一段:

[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 中:

public class DownloadResumeData
{
    public string BundleName;       // Bundle 名称
    public long   DownloadedBytes;  // 已下载字节数
    public string FileHash;         // 文件 Hash
    public long   TotalBytes;       // 文件总大小
    public long   LastUpdateTime;   // 最后更新时间
}

存储位置:

{yooasset_cache_root}/
├── DefaultPackage/
│   ├── download_resume.bin    # 断点信息文件
│   └── ...
4.2.2 断点信息的更新

下载过程中,YooAsset 会周期性(默认 1MB 数据)更新断点信息:

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 断点信息的清理

下载完成后,断点信息会立即清理:

public void OnDownloadComplete()
{
    // 1. 删除断点文件
    File.Delete(_resumeFilePath);
    
    // 2. 校验文件完整性
    VerifyFileHash();
}

4.3 断点续传的实现

4.3.1 HTTP Range 请求
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 模式:

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:

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:

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 通常为十六进制字符串):

public bool CompareHash(string actual, string expected)
{
    // 统一为小写
    actual = actual?.ToLowerInvariant();
    expected = expected?.ToLowerInvariant();
    return string.Equals(actual, expected);
}
5.2.3 校验失败处理
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 算法选择
算法速度适用场景
CRC3210 GB/s大文件完整性校验(推荐)
MD5700 MB/s中等文件
SHA256400 MB/s金融级别(不需要)
5.3.2 校验性能

对一个 100MB 的 Bundle:

  • CRC32:~10ms
  • MD5:~150ms
  • SHA256:~250ms

YooAsset 默认使用 CRC32,因为 Bundle 通常很大(几十 MB 到几百 MB),速度差异显著。

5.3.3 优化策略

对于批量下载场景,可以并行校验多个文件:

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 实践建议

  1. 差异粒度:尽量采用 Bundle 级而不是文件级差异,减少对比开销
  2. 断点保存频率:不要每下载一个字节就保存断点,建议每 1MB 一次
  3. Hash 算法:Bundle 完整性校验使用 CRC32 即可,速度优势明显
  4. 重试策略:采用指数退避算法,避免短时间内多次重试
  5. 完整性校验:失败后一定要重试,不要相信"99% 成功的下载"

下一篇:边玩边下载