热更新-版本管理机制
篇章:08-核心技术-热更新系统
状态:已完成
阅读时间:约 25 分钟
一、引言
1.1 本章在系列中的定位
在 YooAsset 整个资源管理体系中,版本管理是连接"构建端"与"运行端"的桥梁。如果把 YooAsset 想象成一套完整的物流系统,那么版本管理就是"快递单号系统":构建端在打包时生成清单(Manifest),运行端在启动时校验单号,只有单号对得上,整个系统才能顺畅运转。
对于一个商业级 Unity 项目来说,版本管理是热更新(Hot Update)能否落地的第一道关卡。它看似简单,背后却涉及:本地清单的持久化、远端清单的获取、本地与远端清单的对比、灰度发布的分流、回滚时的版本切换等多个工程问题。
1.2 本章要解决的核心问题
- YooAsset 的版本号体系如何设计?数字版本与 Hash 版本如何选择?
- 游戏启动时,YooAsset 如何判断"是否需要更新"?
- 强制更新与非强制更新在 YooAsset 中如何落地?
- 灰度发布如何在 YooAsset 上实现?
- 当新版本出现问题时,如何快速回滚到旧版本?
1.3 阅读前置要求
- 了解 YooAsset 的 Manifest 概念(参考 02-原理篇-清单与寻址)
- 了解 YooAsset 的运行模式(EditorPlayMode、OfflinePlayMode、HostPlayMode、WebPlayMode)
- 了解 YooAsset 的 WebServer 部署(参考 06-源码深度-文件系统与下载模块)
二、版本校验
2.1 版本校验的流程
版本校验是 YooAsset 热更新的入口。当游戏在 HostPlayMode(联机模式)下启动时,YooAsset 默认会执行以下流程:
[游戏启动]
│
▼
[读取本地缓存清单](若存在)
│
▼
[请求远端最新清单] ── HTTP GET PackageManifest_{Version}.hash / .bytes
│
▼
[对比本地与远端清单]
│
├── 一致 → 直接进入游戏(无需下载)
│
└── 不一致 → 触发下载流程
│
▼
[下载差异资源]
│
▼
[更新本地清单]
│
▼
[进入游戏]
YooAsset 中的版本校验主要发生在以下三个时机:
| 时机 | 触发方式 | 用途 |
|---|---|---|
| 启动时 | 自动触发 | 检查是否需要更新 |
| 运行时 | 手动调用 RequestCheckVersionAsync | 主动检查更新 |
| 切场景 | 业务层调用 | 在场景切换间隙检查 |
2.2 版本校验的实现
2.2.1 Manifest 文件结构
YooAsset 在构建时,会在 PackageManifest_{Version}.bytes 中写入版本信息:
// 简化的 Manifest 头部(实际为二进制)
public class PackageManifest
{
public string PackageName; // 包名,如 "DefaultPackage"
public string PackageVersion; // 版本号,如 "1.0.5"
public string PackageNote; // 备注(可选)
public bool IsIncludeMainAssetBundle; // 是否包含主 Bundle
public string MainAssetBundleFileName; // 主 Bundle 名
public string[] AssetBundleList; // 所有 Bundle 文件名
public AssetInfo[] AssetList; // 所有资源条目
public BundleInfo[] BundleList; // 所有 Bundle 详情
public string Hash; // 整个 Manifest 的 Hash
public string[] Dependencies; // 依赖的其他包
}
2.2.2 Hash 对比
YooAsset 在 RequestCheckVersionAsync 中执行的核心逻辑(伪代码):
public class CheckVersionOperation : AsyncOperationBase
{
protected override void OnStart()
{
// 1. 下载远端清单索引(仅一个小的 .hash 文件)
var indexOp = GetRemoteTextAsync("PackageManifest_{Version}.hash");
// 2. 与本地记录的 Hash 对比
if (indexOp.Result == _cachedHash)
{
// 远端无更新,使用本地清单直接进入游戏
_status = EOperationStatus.Succeed;
}
else
{
// 3. 远端有更新,下载完整清单
var manifestOp = GetRemoteTextAsync("PackageManifest_{Version}.bytes");
// 4. 解析后保存到本地
SaveCacheFile(manifestOp.Result);
}
}
}
关键设计:
- 第一步只下载一个极小的
.hash文件(几十字节),避免每次启动都拉取完整 Manifest - 只有 Hash 不一致时,才下载完整的
.bytes清单 - 这种"两步校验"是大流量游戏的标准做法,能极大降低 CDN 的 QPS 压力
2.2.3 版本对比
YooAsset 默认采用 Hash 对比方式,但也支持数字版本号对比。开发者可以在 IBuildPipeline 中自定义版本号生成策略:
// 自定义版本号:日期+构建号+GitHash
public class CustomBuildPipeline : IBuildPipeline
{
public string GetPackageVersion()
{
string date = DateTime.Now.ToString("yyyyMMdd");
string build = PlayerSettings.bundleVersion;
string gitHash = GetGitShortHash(); // 通过命令行获取
return $"{date}.{build}.{gitHash}";
}
}
两种版本对比方式的差异:
| 维度 | Hash 对比(推荐) | 数字版本对比 |
|---|---|---|
| 精确度 | 字节级一致,零误差 | 字符串级,可能漏判 |
| 服务器压力 | 极低(只下 .hash) | 中等(需要下完整清单) |
| 实现复杂度 | 需要构建时计算 | 简单 |
| 回滚支持 | 不支持自动回滚 | 支持降级到旧版本 |
| 适用场景 | 标准商业项目 | 强版本号语义的 SDK |
2.3 版本校验的时机
2.3.1 启动时校验
启动时校验是 YooAsset 的默认行为。开发者无需手动调用,只要 PlayMode 设置为 HostPlayMode 并配置了 HostServerURL,YooAsset 就会在初始化时自动检查版本:
// YooAssetRuntimeSetting
public class YooAssetSetting : ScriptableObject
{
public EPlayMode PlayMode; // HostPlayMode
public string HostServerURL; // https://cdn.example.com/v1/
public string FallbackHostServerURL; // 备用地址
public bool AutoCheckVersion; // 是否自动检查版本
}
2.3.2 运行时校验
对于需要"长时间挂机"的游戏(例如放置类、SLG),玩家可能几天都不重启游戏。此时应在合适的时机主动调用版本检查:
public class GameEntry : MonoBehaviour
{
/// <summary>
/// 在切场景时检查更新,避免影响玩家体验
/// </summary>
public async void CheckUpdateOnSceneChanged()
{
var op = YooAssets.RequestCheckVersionAsync(30); // 30秒超时
await op.Task;
if (op.Status == EOperationStatus.Succeed && op.Result.IsNewVersion)
{
// 提示玩家有更新
UIManager.Show<UpdateHintDialog>();
}
}
}
2.3.3 手动校验
对于运营活动触发的强制更新(节假日活动、紧急 Bug 修复),可以通过运营后台下发"强制更新"指令:
public class ForcedUpdateChecker
{
/// <summary>
/// 在合适的时机(如打开商店、领取奖励)检查运营指令
/// </summary>
public async Task<bool> CheckForcedUpdate()
{
string url = $"{ServerConfig.ApiHost}/api/forced-update";
var resp = await HttpGetAsync(url);
if (resp.needForcedUpdate)
{
// 弹出强制更新界面
UIManager.Show<ForcedUpdateDialog>(resp.updateUrl);
return true;
}
return false;
}
}
三、强制/非强制更新
3.1 强制更新
强制更新是指版本不一致时阻止游戏继续运行,要求玩家必须更新才能进入。强制更新通常用于:
- 关键 Bug 修复:例如支付、登录模块的严重问题
- 安全更新:例如反作弊模块更新
- 协议变更:例如登录协议或支付协议变更
- 法律合规:例如 GDPR、未成年人保护相关更新
强制更新的实现需要将"是否强制更新"的判断从 YooAsset 内部剥离出来,交给运营后台控制:
[游戏启动]
│
▼
[读取 YooAsset 本地清单] ──→ 清单存在?
│ │
│ ├── 不存在 → 首次启动,进入下载流程
│
▼
[请求运营后台的版本配置] ──→ HTTP GET /api/game-config
│
▼
[运营后台判断]
│
├── 强制更新 → 弹出强制更新界面,玩家必须更新
│
└── 非强制更新 → 进入 YooAsset 下载流程
│
▼
[下载完成后,弹出可选更新提示]
实现代码示例:
public class UpdateGate : MonoBehaviour
{
async void Start()
{
// 1. 查询运营后台的版本策略
var config = await GameApi.GetVersionConfig();
if (config.mustUpdate)
{
// 强制更新:阻止游戏继续
UIManager.Show<ForceUpdateDialog>(config.updateUrl);
return;
}
// 2. 初始化 YooAsset
await YooAssets.InitializeAsync();
// 3. 检查 YooAsset 资源版本
var checkOp = YooAssets.RequestCheckVersionAsync();
await checkOp.Task;
if (checkOp.Result.IsNewVersion)
{
// 4. 提示玩家更新
UIManager.Show<UpdateConfirmDialog>(async (accepted) => {
if (accepted)
{
await DownloadAndUpdate();
}
else
{
// 玩家选择跳过,但功能可能受限
EnterGame();
}
});
}
else
{
// 无需更新
EnterGame();
}
}
}
3.2 非强制更新
非强制更新是指版本不一致时提示玩家更新,但玩家可以选择跳过。非强制更新适用于:
- 新玩法上线:例如新角色、新地图
- 新活动开启:例如节日活动、签到活动
- 小优化:例如美术资源替换、UI 调整
非强制更新需要在 UI 上做出明确的"立即更新"和"稍后再说"两个选项:
public class UpdateConfirmDialog : MonoBehaviour
{
public TMP_Text sizeText;
public Button updateButton;
public Button skipButton;
private long _downloadSize;
public void Setup(long size, Action<bool> callback)
{
_downloadSize = size;
sizeText.text = $"需要下载 {size / 1024 / 1024} MB 新资源";
updateButton.onClick.AddListener(() => {
callback(true);
Close();
});
skipButton.onClick.AddListener(() => {
callback(false);
Close();
});
}
}
3.3 更新策略的配置
3.3.1 策略选择
| 策略 | 适用场景 | 用户体验 | 运营风险 |
|---|---|---|---|
| 强制更新 | 关键 Bug、安全问题 | 较差 | 低 |
| 强提示更新 | 重要玩法、核心资源 | 中等 | 中 |
| 弱提示更新 | 新活动、新角色 | 较好 | 中 |
| 静默更新 | 美术资源、UI 调整 | 好 | 高 |
| 不更新 | 1% 不到的边缘资源 | 最好 | 最高 |
3.3.2 更新界面定制
YooAsset 本身不提供 UI,开发者需要自行实现更新界面。推荐使用分层 UI 设计:
更新界面层次:
├── 强制更新遮罩(不可关闭)
│ ├── Logo
│ ├── 当前版本号
│ ├── 进度条
│ └── 重启按钮(更新完成后)
│
├── 强提示弹窗(可关闭)
│ ├── 标题
│ ├── 更新内容
│ ├── 大小
│ ├── 立即更新按钮
│ └── 稍后再说按钮
│
└── 静默提示条(可关闭)
└── "有新内容可下载"
3.3.3 更新进度的显示
YooAsset 的下载进度通过 DownloadUpdateOperation 暴露:
var op = YooAssets.DownloadUpdateAsync(
downloadingMaxNum: 10, // 最大并发下载数
failedTryAgain: 3, // 失败重试次数
timeout: 60 // 单文件超时(秒)
);
while (!op.IsDone)
{
// 显示进度
progressBar.value = op.Progress;
progressText.text = $"{op.CurrentDownloadBytes / 1024 / 1024}MB / {op.TotalDownloadBytes / 1024 / 1024}MB";
speedText.text = $"{op.CurrentSpeed / 1024:F1} KB/s";
await Task.Yield();
}
四、灰度发布
4.1 灰度发布的概念
灰度发布(Gray Release)是指先向小部分用户发布新版本,观察无问题后再扩大发布范围的发布方式。其核心思想是"用最小成本验证新版本"。
YooAsset 本身不直接实现灰度发布,但提供了完整的扩展点。灰度发布通常在 CDN 层面、运营后台层面、客户端层面三个位置实现:
| 层次 | 实现方式 | 优缺点 |
|---|---|---|
| CDN 层面 | DNS 解析、CDN 边缘节点分流 | 精准,但需要 CDN 厂商支持 |
| 运营后台层面 | 按用户 ID 决定返回哪个版本清单 | 灵活,可精细化控制 |
| 客户端层面 | 客户端根据自身 ID 选择请求哪个版本 | 简单,但流量浪费 |
4.2 灰度发布的实现
4.2.1 运营后台的灰度配置
最常见的灰度实现是"运营后台返回不同的清单 URL":
// 运营后台的灰度配置(示意)
{
"grayEnabled": true,
"grayPercent": 10, // 灰度 10% 用户
"grayUserList": [], // 白名单用户
"grayVersion": "1.0.6", // 灰度版本
"stableVersion": "1.0.5" // 稳定版本
}
public class GrayReleaseManager
{
public async Task<string> GetManifestUrlAsync(string packageName, string userId)
{
var config = await GameApi.GetReleaseConfig();
if (!config.grayEnabled)
return $"{config.cdnHost}/{packageName}/{config.stableVersion}/";
// 检查白名单
if (config.grayUserList.Contains(userId))
return $"{config.cdnHost}/{packageName}/{config.grayVersion}/";
// 按 Hash 散列
int hash = Math.Abs(userId.GetHashCode()) % 100;
if (hash < config.grayPercent)
return $"{config.cdnHost}/{packageName}/{config.grayVersion}/";
return $"{config.cdnHost}/{packageName}/{config.stableVersion}/";
}
}
4.2.2 灰度的比例控制
灰度比例的提升需要循序渐进:
5% → 20% → 50% → 100%
每阶段持续:24-48 小时
监控指标:崩溃率、ANR 率、卡顿率、关键路径成功率
4.2.3 灰度的监控
灰度期间需要重点监控以下指标:
| 指标 | 阈值 | 异常处理 |
|---|---|---|
| 崩溃率 | 上升超过 0.1% | 立即停止灰度 |
| 启动失败率 | 上升超过 0.5% | 立即停止灰度 |
| 资源加载失败率 | 上升超过 0.1% | 检查 CDN 与清单 |
| 网络超时率 | 上升超过 1% | 检查 CDN 节点 |
| 玩家投诉量 | 上升超过 10% | 评估是否回滚 |
4.3 灰度发布的策略
4.3.1 按比例灰度
按用户比例灰度是最简单的方式,缺点是"同服玩家可能版本不同",对联机游戏不友好。
4.3.2 按设备灰度
按设备类型(高端机、中端机、低端机)灰度:
public string GetGrayVersion()
{
int systemMemory = SystemInfo.systemMemorySize;
if (systemMemory >= 6144) // 6GB+ 高端机
return "1.0.6-high";
else if (systemMemory >= 3072) // 3-6GB 中端机
return "1.0.6-mid";
else // 3GB 以下低端机
return "1.0.5"; // 不参与灰度
}
4.3.3 按地区灰度
按地区灰度可以避免"全国性故障",特别适合新内容需要运营配合的场景:
public string GetGrayVersion(string region)
{
// 新玩法先在广东、上海试点
var pilotRegions = new[] { "guangdong", "shanghai", "beijing" };
if (pilotRegions.Contains(region))
return "1.0.6";
else
return "1.0.5";
}
五、回滚
5.1 回滚的概念
回滚(Rollback)是指将版本回退到之前的稳定版本。回滚与灰度是"一体两面":灰度是新版本的小流量验证,回滚是验证失败后的紧急恢复。
YooAsset 的回滚机制基于多版本缓存:本地缓存中保留最近的 N 个版本清单,必要时切换到旧版本清单。
5.2 回滚的实现
5.2.1 多版本共存
YooAsset 的本地缓存目录设计:
{yooasset_cache_root}/
├── DefaultPackage/
│ ├── PackageManifest_1.0.3.bytes
│ ├── PackageManifest_1.0.3.bytes.meta
│ ├── PackageManifest_1.0.4.bytes
│ ├── PackageManifest_1.0.4.bytes.meta
│ ├── PackageManifest_1.0.5.bytes
│ ├── PackageManifest_1.0.5.bytes.meta
│ └── ...
├── bundle_v1.0.3/
│ ├── bundle_01
│ ├── bundle_02
│ └── ...
├── bundle_v1.0.4/
└── bundle_v1.0.5/
为什么需要多版本共存:
- 如果新版本
1.0.6出现问题,运营可以下发指令让客户端切换到1.0.5 - 玩家本地已有
1.0.5的资源,无需重新下载 - 这就是"秒级回滚"
5.2.2 版本切换
YooAsset 的版本切换通过修改"激活清单"实现:
public class VersionRollback
{
/// <summary>
/// 切换到指定版本
/// </summary>
public async Task<bool> RollbackToVersion(string targetVersion)
{
// 1. 检查本地是否存在该版本
var manifestPath = $"PackageManifest_{targetVersion}.bytes";
if (!File.Exists(manifestPath))
{
Debug.LogError($"本地无版本 {targetVersion} 的清单");
return false;
}
// 2. 强制切换清单
YooAssets.ForceChangeVersion(targetVersion);
// 3. 重新初始化资源系统
await YooAssets.InitializeAsync();
return true;
}
}
5.2.3 回滚验证
回滚后需要进行一系列验证:
| 验证项 | 验证方式 | 失败处理 |
|---|---|---|
| 启动验证 | 重启游戏,进入登录界面 | 切回上一版本 |
| 资源加载 | 加载核心场景、UI、角色 | 切回上一版本 |
| 网络请求 | 走完整网络流程 | 切回上一版本 |
| 数据兼容 | 检查玩家存档是否兼容 | 提供回档或转档工具 |
5.3 回滚的策略
5.3.1 自动回滚
通过监控异常率自动触发回滚:
public class AutoRollbackMonitor
{
private float _sampleWindow = 300; // 5 分钟采样窗口
private float _crashThreshold = 0.005f; // 崩溃率阈值 0.5%
private Queue<float> _crashSamples = new Queue<float>();
public void RecordCrash(bool crashed)
{
_crashSamples.Enqueue(crashed ? 1f : 0f);
while (_crashSamples.Count > 100) _crashSamples.Dequeue();
if (_crashSamples.Count >= 100)
{
float crashRate = _crashSamples.Average();
if (crashRate > _crashThreshold)
{
// 触发自动回滚
RollbackManager.TriggerRollback("1.0.5", "auto: high crash rate");
}
}
}
}
5.3.2 手动回滚
运营人员通过后台工具触发:
public class ManualRollback
{
/// <summary>
/// 运营后台调用,紧急回滚到上一版本
/// </summary>
public async Task<RollbackResult> Rollback(string targetVersion, string reason)
{
// 1. 记录回滚原因(用于事后分析)
await GameApi.LogRollback(targetVersion, reason);
// 2. 全服广播
await GameApi.BroadcastRollbackNotice(targetVersion);
// 3. 强制玩家下次启动时回滚
PlayerPrefs.SetString("ForcedRollbackVersion", targetVersion);
return RollbackResult.Success;
}
}
5.3.3 渐进式回滚
对于需要"撤回到旧版本但保留新版本部分特性"的场景:
public class GradualRollback
{
/// <summary>
/// 渐进式回滚:先回滚核心资源,再回滚次要资源
/// </summary>
public async Task GradualRollbackAsync()
{
// 第 1 步:回滚战斗相关资源
var battleRes = new[] { "battle_bundles", "skill_bundles" };
await RollbackBundles(battleRes, "1.0.5");
// 第 2 步:观察 24 小时
await Task.Delay(TimeSpan.FromHours(24));
// 第 3 步:回滚 UI 相关资源
var uiRes = new[] { "ui_bundles", "lobby_bundles" };
await RollbackBundles(uiRes, "1.0.5");
}
}
六、总结
6.1 本章要点回顾
- 版本校验:YooAsset 默认采用 Hash 对比,启动时自动检查,启动性能损耗极低
- 强制/非强制更新:强制更新用于关键 Bug、非强制更新用于新内容,UI 需分层设计
- 灰度发布:推荐在运营后台实现,按用户 ID 散列分流,可按设备/地区细分
- 回滚:基于多版本缓存实现"秒级回滚",可结合自动监控实现智能回滚
6.2 与前后章节的关联
- 前章:08-00 / 07 章主要讨论构建与打包,本章是其下游:构建出来的清单如何被客户端消费
- 后章:08-02 将深入"差量更新与断点续传",解决"清单不一致时如何高效下载"
6.3 实践建议
- 构建时就考虑灰度:把版本号设计为"主版本+灰度标签",如
1.0.5-rc.1 - 监控先行:灰度发布必须配套监控,没有监控的灰度是"裸奔"
- 回滚预案:每次发版前,运营、技术、QA 必须对齐回滚流程
- 多版本缓存:至少保留 3 个历史版本,平衡"磁盘占用"与"回滚能力"
- 强制更新慎用:频繁的强制更新会严重伤害玩家体验
下一篇:差量更新与断点续传

748

被折叠的 条评论
为什么被折叠?



