热更新-完整热更新流程实战
篇章 :08-核心技术-热更新系统 状态 :已完成 阅读时间:约 30 分钟
一、引言
1.1 本章在系列中的定位
本章是 08 章的实战收官。我们已经从原理上理解了 YooAsset 的版本管理、差量更新、边玩边下载、首包资源策略,但理解原理不等于能落地。本章将所有知识点串联起来,给出一条端到端的"构建→上传→下载→安装→切换"完整流程,并提供可直接落地的代码。
这是本系列文章中最贴近实战的一章,建议配合第十、十一章的实战项目一起阅读。
1.2 本章要解决的核心问题
- 一条完整的热更新流水线包含哪些步骤?
- 每个步骤的关键配置和最佳实践是什么?
- 如何串联这些步骤形成可用的代码框架?
- 实战中常见的坑和应对方案是什么?
1.3 阅读前置要求
- 了解前四章的所有内容(08-01 到 08-04)
- 了解 YooAsset 的 Editor 构建脚本
- 了解 CDN / OSS / FTP 等服务器部署方式
二、构建(Build)
2.1 构建流程总览
css
[配置 Collector 规则]
│
▼
[配置 Build Pipeline]
│
▼
[调用 IBuildPipeline.Build]
│
├── [收集资源] AssetBundleCollector
│ └── 输出:BuildMap
│
├── [分析依赖] BuildMap
│ └── 输出:包含所有 Bundle 信息
│
├── [构建 Bundle] BuildTaskBuilder
│ └── 输出:bundle 文件
│
├── [生成 Manifest] BuildTaskManifest
│ └── 输出:PackageManifest_X.Y.Z.bytes
│
└── [生成报告] BuildReport
└── 输出:BuildReport.html
2.2 构建脚本示例
YooAsset 的构建脚本通常放在 Assets/Editor/Build/BuildPipeline.cs:
csharp
using UnityEditor;
using YooAsset.Editor;
public static class YooAssetBuilder
{
public static void BuildAndroid()
{
var buildParams = new BuildParameters
{
// 基础配置
BuildOutputRoot = "D:/Builds/MyGame",
BuildVersion = "1.0.5",
BuildPipeline = EBuildPipeline.ScriptableBuildPipeline,
// 压缩配置
CompressOption = ECompressOption.LZ4,
// 加密配置(可选)
EncryptionOption = EEncryptionOption.AES,
EncryptionSecret = "MyGame@2024",
// 平台
BuildTarget = BuildTarget.Android,
// 输出格式
OutputNameStyle = EOutputNameStyle.HashName,
// 共享资源
EnableSharedPackRule = true,
// 拷贝到 StreamingAssets
CopyToStreamingAssets = true
};
var pipeline = new ScriptableBuildPipeline();
var manifest = pipeline.Build(buildParams);
if (manifest.Success)
{
Debug.Log($"构建成功: {manifest.OutputRoot}");
}
else
{
Debug.LogError($"构建失败: {manifest.ErrorInfo}");
}
}
}
2.3 构建配置详解
2.3.1 压缩配置
| 算法 | 压缩率 | 速度 | 选择建议 |
|---|---|---|---|
| Uncompressed | 1.0x | 最快 | 不推荐(除非有特殊需求) |
| LZ4 | ~1.5x | 极快 | 推荐:商业项目首选 |
| LZMA | ~2.5x | 慢 | 不推荐(构建慢、解压慢) |
2.3.2 加密配置
csharp
public enum EEncryptionOption
{
None, // 不加密
AES, // AES 加密(推荐)
ChaCha20, // ChaCha20 加密
Offset, // 字节偏移加密(不安全)
Xor, // 异或加密(不安全)
}
加密的最佳实践:
- 加密粒度:建议按 Bundle 加密,而不是全包加密
- 密钥管理:密钥从远程获取,本地不留明文
- 解密性能:移动端避免 AES 全文件解密,影响加载速度
2.3.3 平台配置
csharp
public static void BuildForAllPlatforms()
{
var platforms = new[]
{
(BuildTarget.Android, "android"),
(BuildTarget.iOS, "ios"),
(BuildTarget.StandaloneWindows64, "windows"),
(BuildTarget.WebGL, "webgl"),
};
foreach (var (target, name) in platforms)
{
Debug.Log($"开始构建 {name}");
var buildParams = GetBuildParams(target);
var pipeline = new ScriptableBuildPipeline();
pipeline.Build(buildParams);
}
}
2.4 构建验证
2.4.1 构建报告
YooAsset 会在构建目录生成 BuildReport.html:
javascript
D:/Builds/MyGame/
├── BuildReport.html ← 构建报告
├── BuildLog.txt ← 构建日志
├── Android/
│ ├── PackageManifest_1.0.5.bytes
│ ├── PackageManifest_1.0.5.hash
│ └── bundles/
└── iOS/
└── ...
报告内容:
- 收集的资源数量
- 构建的 Bundle 数量
- 总文件大小(含压缩前后)
- 警告和错误信息
- 资源依赖图
2.4.2 冗余检测
csharp
public class RedundancyDetector
{
public static void CheckRedundancy()
{
var buildMap = AssetBundleCollector.GetBuildMap();
var allAssets = buildMap.GetAllAssets();
// 查找同名资源
var duplicates = allAssets
.GroupBy(a => a.AssetPath)
.Where(g => g.Count() > 1);
foreach (var dup in duplicates)
{
Debug.LogWarning($"冗余资源: {dup.Key}");
}
}
}
2.4.3 依赖验证
csharp
public class DependencyValidator
{
public static void ValidateDependencies()
{
var buildMap = AssetBundleCollector.GetBuildMap();
var allBundles = buildMap.GetAllBundles();
foreach (var bundle in allBundles)
{
// 检查循环依赖
if (HasCyclicDependency(bundle, new HashSet<string>()))
{
Debug.LogError($"循环依赖: {bundle.BundleName}");
}
// 检查缺失依赖
foreach (var dep in bundle.Dependencies)
{
if (!allBundles.Any(b => b.BundleName == dep))
{
Debug.LogError($"缺失依赖: {dep}(被 {bundle.BundleName} 引用)");
}
}
}
}
}
三、上传(Upload)
3.1 上传流程
css
[构建完成]
│
▼
[遍历构建输出目录]
│
▼
[上传到 CDN/OSS]
│ ├── 全量文件(所有 Bundle)
│ ├── Manifest 文件(PackageManifest_X.Y.Z.bytes/.hash)
│ └── 报告文件
│
▼
[更新版本索引]
│ └── /cdn/version_index.json
│ { "latest": "1.0.5", "stable": "1.0.4" }
│
▼
[通知业务服务器]
└── HTTP POST /api/release
{ "version": "1.0.5", "cdn_url": "..." }
3.2 上传脚本示例
csharp
public class YooAssetUploader
{
public static async Task UploadToCDN(string buildOutputDir, string cdnBaseUrl)
{
// 1. 读取版本号
var versionFile = Path.Combine(buildOutputDir, "version.txt");
string version = File.ReadAllText(versionFile).Trim();
// 2. 遍历所有平台目录
var platformDirs = Directory.GetDirectories(buildOutputDir);
foreach (var platformDir in platformDirs)
{
string platform = Path.GetFileName(platformDir);
string targetUrl = $"{cdnBaseUrl}/{platform}/{version}/";
// 3. 上传所有文件
await UploadDirectory(platformDir, targetUrl);
}
// 4. 更新版本索引
await UpdateVersionIndex(cdnBaseUrl, version);
// 5. 通知业务服务器
await NotifyBusinessServer(version, cdnBaseUrl);
}
private static async Task UploadDirectory(string localDir, string remoteUrl)
{
var files = Directory.GetFiles(localDir, "*", SearchOption.AllDirectories);
var semaphore = new SemaphoreSlim(10); // 10 个并发上传
var tasks = files.Select(async file =>
{
await semaphore.WaitAsync();
try
{
string relativePath = Path.GetRelativePath(localDir, file).Replace('\\', '/');
string uploadUrl = $"{remoteUrl}/{relativePath}";
await UploadFile(file, uploadUrl);
}
finally
{
semaphore.Release();
}
});
await Task.WhenAll(tasks);
}
}
3.3 上传策略
3.3.1 全量上传 vs 增量上传
csharp
public class IncrementalUploader
{
public async Task<bool> ShouldUpload(string filePath, string remoteUrl)
{
// 1. 查询远端文件 ETag
string remoteEtag = await GetRemoteETag(remoteUrl);
// 2. 计算本地文件 MD5
string localMd5 = ComputeFileMD5(filePath);
// 3. 对比决定是否需要上传
return remoteEtag != localMd5;
}
}
3.3.2 多 CDN 备份
csharp
public class MultiCDNUploader
{
private readonly List<string> _cdnUrls = new()
{
"https://cdn1.example.com",
"https://cdn2.example.com",
"https://cdn3.example.com"
};
public async Task UploadToAllCDNs(string filePath, string relativePath)
{
var tasks = _cdnUrls.Select(cdn =>
UploadFile(filePath, $"{cdn}/{relativePath}")
);
await Task.WhenAll(tasks);
}
}
3.4 上传验证
csharp
public class UploadVerifier
{
public async Task<bool> VerifyUpload(string url, string localPath)
{
// 1. 下载远端文件
string tempPath = Path.GetTempFileName();
await DownloadFile(url, tempPath);
// 2. 对比 Hash
string localHash = ComputeFileMD5(localPath);
string remoteHash = ComputeFileMD5(tempPath);
File.Delete(tempPath);
return localHash == remoteHash;
}
}
四、下载(Download)
4.1 下载流程
css
[游戏启动]
│
▼
[读取 YooAssetSetting]
│
▼
[初始化 YooAsset]
│ ├── 创建文件系统
│ ├── 加载本地缓存清单
│ └── 设置下载管理器
│
▼
[检查版本]
│ ├── 下载 .hash 文件
│ ├── 与本地对比
│ └── 是否需要更新?
│
├── 是 ─→ [下载更新]
│ ├── 下载完整 Manifest
│ ├── 计算差异
│ ├── 加入下载队列
│ └── 等待完成
│
└── 否 ─→ [直接进入游戏]
4.2 下载实现
csharp
public class HotUpdateManager : MonoBehaviour
{
public async Task<bool> StartHotUpdate()
{
try
{
// 1. 初始化 YooAsset
var initOp = YooAssets.InitializeAsync();
await initOp.Task;
if (initOp.Status != EOperationStatus.Succeed)
{
Debug.LogError($"YooAsset 初始化失败: {initOp.Error}");
return false;
}
// 2. 获取版本信息
var versionOp = YooAssets.RequestVersionAsync();
await versionOp.Task;
if (versionOp.Status != EOperationStatus.Succeed)
{
Debug.LogError($"获取版本失败: {versionOp.Error}");
return false;
}
// 3. 检查更新
var checkOp = YooAssets.RequestCheckVersionAsync();
await checkOp.Task;
if (checkOp.Status != EOperationStatus.Succeed)
{
Debug.LogError($"检查更新失败: {checkOp.Error}");
return false;
}
// 4. 是否有更新
if (checkOp.Result.IsNewVersion)
{
Debug.Log($"发现新版本,需要下载 {checkOp.Result.DownloadSize} 字节");
// 5. 获取资源清单
var manifestOp = YooAssets.RequestPackageManifestAsync();
await manifestOp.Task;
if (manifestOp.Status != EOperationStatus.Succeed)
{
Debug.LogError($"获取清单失败: {manifestOp.Error}");
return false;
}
// 6. 下载差异
var downloadOp = YooAssets.DownloadUpdateAsync(
downloadingMaxNum: 10,
failedTryAgain: 3,
timeout: 60
);
// 显示进度
ShowDownloadProgress(downloadOp);
await downloadOp.Task;
if (downloadOp.Status != EOperationStatus.Succeed)
{
Debug.LogError($"下载失败: {downloadOp.Error}");
return false;
}
}
return true;
}
catch (Exception ex)
{
Debug.LogError($"热更新异常: {ex.Message}");
return false;
}
}
}
4.3 进度显示
csharp
public class DownloadProgressUI : MonoBehaviour
{
public Slider progressBar;
public TMP_Text progressText;
public TMP_Text speedText;
public TMP_Text fileText;
public TMP_Text sizeText;
public void ShowDownloadProgress(DownloadUpdateOperation op)
{
gameObject.SetActive(true);
StartCoroutine(ProgressCoroutine(op));
}
private IEnumerator ProgressCoroutine(DownloadUpdateOperation op)
{
var wait = new WaitForSeconds(0.1f);
while (!op.IsDone)
{
progressBar.value = op.Progress;
progressText.text = $"{op.Progress * 100:F1}%";
speedText.text = $"{op.CurrentSpeed / 1024:F1} KB/s";
fileText.text = op.CurrentDownloadFileName;
sizeText.text = $"{op.CurrentDownloadBytes / 1024 / 1024}MB / {op.TotalDownloadBytes / 1024 / 1024}MB";
yield return wait;
}
gameObject.SetActive(false);
}
}
4.4 下载错误处理
csharp
public class DownloadErrorHandler
{
public async Task<bool> HandleDownloadError(DownloadUpdateOperation op)
{
switch (op.ErrorCode)
{
case EDownloadErrorCode.NetworkError:
// 等待网络恢复
await WaitForNetwork();
return true; // 重试
case EDownloadErrorCode.DiskFull:
// 提示用户清理空间
UIManager.Show<DiskFullDialog>();
return false; // 不重试
case EDownloadErrorCode.HashMismatch:
// 删除损坏文件
File.Delete(op.CurrentDownloadFileName);
return true; // 重试
case EDownloadErrorCode.VersionMismatch:
// 版本不一致,强制重启
YooAssets.ForceChangeVersion();
return false;
default:
return false;
}
}
private async Task WaitForNetwork()
{
while (!NetworkManager.IsAvailable())
{
await Task.Delay(1000);
}
}
}
五、安装(Install)
5.1 安装流程
YooAsset 中的"安装"并不是传统意义上的安装(拷贝文件到系统目录),而是指资源下载完成后写入本地缓存。
css
[下载完成]
│
▼
[校验所有文件 Hash]
│
▼
[写入本地缓存]
│ ├── Bundle 文件 → yooasset/bundles/
│ ├── Manifest 文件 → yooasset/manifest/
│ └── 断点信息文件清理
│
▼
[更新本地版本索引]
│
▼
[完成安装]
5.2 安装实现
YooAsset 的安装是自动的,开发者无需手动调用。内部流程:
csharp
public class InstallOperation
{
public async Task InstallAsync(List<BundleInfo> downloadedBundles)
{
// 1. 验证所有 Bundle
var invalidBundles = new List<BundleInfo>();
foreach (var bundle in downloadedBundles)
{
if (!VerifyBundle(bundle))
{
invalidBundles.Add(bundle);
}
}
// 2. 删除无效文件
foreach (var bundle in invalidBundles)
{
File.Delete(GetBundlePath(bundle));
}
// 3. 更新清单索引
var manifest = YooAssets.GetPackageManifest();
manifest.UpdateBundleList(downloadedBundles);
manifest.SaveToCache();
// 4. 清理下载断点
ClearDownloadResume();
// 5. 触发回调
OnInstallCompleted?.Invoke();
}
}
六、切换(Switch)
6.1 切换流程
scss
[下载完成]
│
▼
[选择切换时机]
│
├── 启动时切换(玩家首次进入游戏时)
│
└── 切场景时切换(业务层主动触发)
│
▼
[强制切换版本]
│ YooAssets.ForceChangeVersion(newVersion)
│
▼
[重新初始化资源系统]
│
▼
[验证切换成功]
│ ├── 加载核心资源测试
│ └── 错误则回滚
│
▼
[继续游戏]
6.2 切换实现
csharp
public class VersionSwitcher
{
public async Task<bool> SwitchToVersion(string targetVersion)
{
// 1. 校验目标版本
var manifest = LoadManifestFromCache(targetVersion);
if (manifest == null)
{
Debug.LogError($"版本 {targetVersion} 不存在");
return false;
}
// 2. 强制切换
YooAssets.ForceChangeVersion(targetVersion);
// 3. 重新初始化
var reinitOp = YooAssets.InitializeAsync();
await reinitOp.Task;
if (reinitOp.Status != EOperationStatus.Succeed)
{
Debug.LogError("切换后初始化失败");
return false;
}
// 4. 验证:加载一个关键资源
var testOp = YooAssets.LoadAssetAsync<Sprite>("Assets/Startup/Logo.png");
await testOp.Task;
if (testOp.Status != EOperationStatus.Succeed)
{
Debug.LogError("切换后资源加载失败");
return false;
}
// 5. 清理旧版本资源(可选)
// await CleanupOldVersions();
return true;
}
}
6.3 切换失败回滚
csharp
public class SafeVersionSwitch
{
private string _lastGoodVersion;
public async Task<bool> SafeSwitchTo(string targetVersion)
{
_lastGoodVersion = YooAssets.GetCurrentVersion();
try
{
bool success = await SwitchToVersion(targetVersion);
if (!success)
{
await RollbackToLastGood();
return false;
}
return true;
}
catch (Exception ex)
{
Debug.LogError($"切换异常: {ex.Message}");
await RollbackToLastGood();
return false;
}
}
private async Task RollbackToLastGood()
{
Debug.LogWarning($"回滚到上一稳定版本: {_lastGoodVersion}");
await SwitchToVersion(_lastGoodVersion);
}
}
七、完整代码示例
7.1 完整的热更新管理器
csharp
using System;
using System.Threading.Tasks;
using UnityEngine;
using YooAsset;
public class HotUpdateManager : MonoBehaviour
{
[SerializeField] private GameObject updateUI;
[SerializeField] private UpdateProgressUI progressUI;
[SerializeField] private UpdateConfirmDialog confirmDialog;
[SerializeField] private ForceUpdateDialog forceUpdateDialog;
public async Task<bool> InitializeAndUpdate()
{
// ========== 阶段 1:检查强制更新 ==========
var forceUpdate = await GameApi.GetForceUpdateConfig();
if (forceUpdate.needForceUpdate)
{
forceUpdateDialog.Show(forceUpdate.updateUrl);
return false;
}
// ========== 阶段 2:初始化 YooAsset ==========
updateUI.SetActive(true);
progressUI.SetStatus("正在初始化...");
var initOp = YooAssets.InitializeAsync();
await initOp.Task;
if (initOp.Status != EOperationStatus.Succeed)
{
progressUI.ShowError($"初始化失败: {initOp.Error}");
return false;
}
// ========== 阶段 3:检查版本 ==========
progressUI.SetStatus("正在检查版本...");
var checkOp = YooAssets.RequestCheckVersionAsync();
await checkOp.Task;
if (checkOp.Status != EOperationStatus.Succeed)
{
progressUI.ShowError($"检查版本失败: {checkOp.Error}");
return false;
}
// ========== 阶段 4:判断是否需要更新 ==========
if (!checkOp.Result.IsNewVersion)
{
// 已经是最新版本,直接进入游戏
updateUI.SetActive(false);
return true;
}
// ========== 阶段 5:询问用户 ==========
bool userAccepted = false;
if (checkOp.Result.DownloadSize > 50 * 1024 * 1024) // 超过 50MB 询问
{
userAccepted = await confirmDialog.ShowAndWait(
$"发现新版本,需要下载 {checkOp.Result.DownloadSize / 1024 / 1024} MB"
);
if (!userAccepted)
{
// 用户选择稍后更新
return TryEnterGameWithOldVersion();
}
}
else
{
userAccepted = true; // 小更新直接下载
}
// ========== 阶段 6:获取资源清单 ==========
progressUI.SetStatus("正在获取资源清单...");
var manifestOp = YooAssets.RequestPackageManifestAsync();
await manifestOp.Task;
if (manifestOp.Status != EOperationStatus.Succeed)
{
progressUI.ShowError($"获取清单失败: {manifestOp.Error}");
return false;
}
// ========== 阶段 7:下载资源 ==========
var downloadOp = YooAssets.DownloadUpdateAsync(
downloadingMaxNum: 10,
failedTryAgain: 3,
timeout: 60
);
// 显示进度
progressUI.BindDownloadOperation(downloadOp);
await downloadOp.Task;
if (downloadOp.Status != EOperationStatus.Succeed)
{
progressUI.ShowError($"下载失败: {downloadOp.Error}");
return await HandleDownloadFailure(downloadOp);
}
// ========== 阶段 8:完成 ==========
progressUI.SetStatus("更新完成");
await Task.Delay(500); // 让玩家看到"完成"
updateUI.SetActive(false);
return true;
}
private bool TryEnterGameWithOldVersion()
{
// 尝试用旧版本进入游戏(功能可能不完整)
return true;
}
private async Task<bool> HandleDownloadFailure(DownloadUpdateOperation op)
{
// 询问用户:重试 / 取消
bool retry = await confirmDialog.ShowRetryOrCancel();
if (retry)
{
// 删除损坏文件后重试
YooAssets.ClearCache();
return await InitializeAndUpdate();
}
return false;
}
}
7.2 自动启动
csharp
public class GameEntry : MonoBehaviour
{
[SerializeField] private HotUpdateManager hotUpdateManager;
[SerializeField] private GameObject loginPanel;
async void Start()
{
// 1. 启动热更新
bool success = await hotUpdateManager.InitializeAndUpdate();
if (success)
{
// 2. 进入登录
loginPanel.SetActive(true);
}
else
{
// 3. 启动失败,提示玩家
ShowFatalErrorDialog();
}
}
}
八、常见问题与解决方案
8.1 启动黑屏
现象:游戏启动后一直黑屏,Loading 界面不出现。
原因:
- 启动 UI 资源没放在首包中
- 启动场景缺失关键资源
- YooAsset 初始化失败
解决:
csharp
// 1. 确保启动 UI 资源在首包
// 2. 加日志
Debug.Log("[Startup] YooAsset 初始化开始");
var initOp = YooAssets.InitializeAsync();
await initOp.Task;
Debug.Log($"[Startup] YooAsset 初始化结果: {initOp.Status}");
// 3. 异步启动分阶段日志
public async Task StartupWithLogs()
{
Debug.Log("[1/5] 初始化 YooAsset");
await Step1_InitYooAsset();
Debug.Log("[2/5] 检查版本");
await Step2_CheckVersion();
Debug.Log("[3/5] 下载资源");
await Step3_Download();
Debug.Log("[4/5] 加载主场景");
await Step4_LoadMainScene();
Debug.Log("[5/5] 进入游戏");
await Step5_EnterGame();
}
8.2 下载卡 99%
现象:下载进度到 99% 卡住不动。
原因:
- 某个大文件下载失败
- Hash 校验失败但未自动重试
- 线程死锁
解决:
csharp
// 1. 增加超时
var downloadOp = YooAssets.DownloadUpdateAsync(timeout: 60);
// 2. 进度心跳监控
if (DateTime.Now - _lastProgressUpdate > TimeSpan.FromSeconds(30))
{
Debug.LogWarning("下载超过 30 秒无进度,重试");
YooAssets.ClearCache();
await InitializeAndUpdate();
}
8.3 切场景后资源丢失
现象:从一个场景切到另一个场景后,原场景的资源变成"加载失败"。
原因:
- 引用计数管理不当
- 切场景时强制清空了缓存
解决:
csharp
// 切场景时正确管理引用
public async Task LoadNewScene(string sceneName)
{
// 1. 异步加载新场景
var sceneOp = YooAssets.LoadSceneAsync(sceneName, LoadSceneMode.Single);
await sceneOp.Task;
// 2. 新场景加载完成后,原场景资源自动释放
// YooAsset 内部会处理引用计数
}
九、总结
6.1 本章要点回顾
- 构建 :通过
IBuildPipeline一键完成 Bundle 构建、Manifest 生成、报告输出 - 上传:支持全量和增量上传,建议多 CDN 备份
- 下载 :使用
RequestCheckVersionAsync+DownloadUpdateAsync完成 - 安装:YooAsset 自动处理,开发者无需关心
- 切换 :使用
ForceChangeVersion强制切换版本,支持回滚
6.2 与前后章节的关联
- 前章:08-01 ~ 08-04 解决了热更新的各个细节
- 本章:将这些细节串联成一条端到端流水线
- 后章:09-01 将介绍 YooAsset + HybridCLR 的代码热更方案
6.3 实践建议
- 构建脚本化:把构建脚本写进 CI,避免人工操作
- 上传分平台:每个平台独立目录,避免混淆
- 下载可视化:进度条、速度、剩余时间一应俱全
- 切换要回滚:切版本失败时一定要回滚
- 监控全覆盖:下载成功率、平均时长、失败原因都要监控