08-01-热更新-版本管理机制

热更新-版本管理机制

篇章 :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 默认会执行以下流程:

css 复制代码
[游戏启动]
    │
    ▼
[读取本地缓存清单](若存在)
    │
    ▼
[请求远端最新清单] ── HTTP GET PackageManifest_{Version}.hash / .bytes
    │
    ▼
[对比本地与远端清单]
    │
    ├── 一致 → 直接进入游戏(无需下载)
    │
    └── 不一致 → 触发下载流程
                    │
                    ▼
                [下载差异资源]
                    │
                    ▼
                [更新本地清单]
                    │
                    ▼
                [进入游戏]

YooAsset 中的版本校验主要发生在以下三个时机:

时机 触发方式 用途
启动时 自动触发 检查是否需要更新
运行时 手动调用 RequestCheckVersionAsync 主动检查更新
切场景 业务层调用 在场景切换间隙检查

2.2 版本校验的实现

2.2.1 Manifest 文件结构

YooAsset 在构建时,会在 PackageManifest_{Version}.bytes 中写入版本信息:

csharp 复制代码
// 简化的 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 中执行的核心逻辑(伪代码):

csharp 复制代码
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 中自定义版本号生成策略:

csharp 复制代码
// 自定义版本号:日期+构建号+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 就会在初始化时自动检查版本:

csharp 复制代码
// 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),玩家可能几天都不重启游戏。此时应在合适的时机主动调用版本检查:

csharp 复制代码
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 修复),可以通过运营后台下发"强制更新"指令:

csharp 复制代码
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 内部剥离出来,交给运营后台控制:

css 复制代码
[游戏启动]
    │
    ▼
[读取 YooAsset 本地清单] ──→ 清单存在?
    │                              │
    │                              ├── 不存在 → 首次启动,进入下载流程
    │
    ▼
[请求运营后台的版本配置] ──→ HTTP GET /api/game-config
    │
    ▼
[运营后台判断]
    │
    ├── 强制更新 → 弹出强制更新界面,玩家必须更新
    │
    └── 非强制更新 → 进入 YooAsset 下载流程
                          │
                          ▼
                     [下载完成后,弹出可选更新提示]

实现代码示例:

csharp 复制代码
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 上做出明确的"立即更新"和"稍后再说"两个选项:

csharp 复制代码
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 设计:

arduino 复制代码
更新界面层次:
├── 强制更新遮罩(不可关闭)
│   ├── Logo
│   ├── 当前版本号
│   ├── 进度条
│   └── 重启按钮(更新完成后)
│
├── 强提示弹窗(可关闭)
│   ├── 标题
│   ├── 更新内容
│   ├── 大小
│   ├── 立即更新按钮
│   └── 稍后再说按钮
│
└── 静默提示条(可关闭)
    └── "有新内容可下载"
3.3.3 更新进度的显示

YooAsset 的下载进度通过 DownloadUpdateOperation 暴露:

csharp 复制代码
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":

json 复制代码
// 运营后台的灰度配置(示意)
{
    "grayEnabled": true,
    "grayPercent": 10,        // 灰度 10% 用户
    "grayUserList": [],       // 白名单用户
    "grayVersion": "1.0.6",   // 灰度版本
    "stableVersion": "1.0.5"  // 稳定版本
}
csharp 复制代码
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 灰度的比例控制

灰度比例的提升需要循序渐进:

shell 复制代码
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 按设备灰度

按设备类型(高端机、中端机、低端机)灰度:

csharp 复制代码
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 按地区灰度

按地区灰度可以避免"全国性故障",特别适合新内容需要运营配合的场景:

csharp 复制代码
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 的本地缓存目录设计:

python 复制代码
{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 的版本切换通过修改"激活清单"实现:

csharp 复制代码
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 自动回滚

通过监控异常率自动触发回滚:

csharp 复制代码
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 手动回滚

运营人员通过后台工具触发:

csharp 复制代码
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 渐进式回滚

对于需要"撤回到旧版本但保留新版本部分特性"的场景:

csharp 复制代码
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. 构建时 就考虑灰度:把版本号设计为"主版本+灰度标签",如 1.0.5-rc.1
  2. 监控先行:灰度发布必须配套监控,没有监控的灰度是"裸奔"
  3. 回滚预案:每次发版前,运营、技术、QA 必须对齐回滚流程
  4. 多版本缓存:至少保留 3 个历史版本,平衡"磁盘占用"与"回滚能力"
  5. 强制更新慎用:频繁的强制更新会严重伤害玩家体验

下一篇 :差量更新与断点续传

相关推荐
Aman198412 小时前
Claude Code做Unity项目移植(二): 自动执行只读操作,告别频繁的
unity·游戏开发·vibe coding·claude code
SmalBox19 小时前
07-03-打包系统-资源变体与多平台适配
游戏开发·图形学
呆呆敲代码的小Y1 天前
【实战精选面试题】C# 面试题详细解答,按难易程度排序
android·java·游戏·c#·游戏开发·unity面试题·c#面试题
ZzT1 天前
vibe coding 做小游戏·打包篇:发 Mac 试玩包、查启动慢,和一次 AI 自己改出来的崩溃
ai编程·游戏开发
应用市场1 天前
用本机跑的开源音乐模型,给安卓小游戏做 20 秒无缝循环背景音乐
android·python·游戏开发·soundpool·音频处理
SmalBox2 天前
07-02-打包系统-压缩与加密策略
unity3d·游戏开发
Aman19842 天前
Claude Code做Unity项目移植(一):从零散工具到团队式
unity·游戏开发·vibe coding·claude code·项目迁移
甲维斯2 天前
白嫖Tripo3D ?Kimi 3D Make可以玩起来!
人工智能·unity3d
ZzT2 天前
vibe coding 做小游戏·玩法篇:8 句试玩反馈,变成无限关卡、局外成长和援军登场
ai编程·游戏开发