跳转至

热更新版本、下载与回退

新 DLL 配旧元数据

更新后,部分玩家卡在登录界面。日志显示下载完成、哈希通过、DLL 也加载成功,进入战斗时才报 TypeLoadException。继续查发现,客户端用的是新 DLL,补充元数据还是上一版;又或者启动包里的 AOT 代码已经被裁剪,新 DLL 引用了没有生成泛型实例的类型。

这类问题只看“热更成功”没有用。下载、验签、解压、切换、加载和初始化是不同阶段,前一步成功不代表后一步能运行。版本管理要让每个阶段知道自己依赖什么、失败后回到哪里。

版本不止一个

一次热更至少涉及下面几层:

安装包版本
  → 引擎和脚本后端版本
  → AOT 契约与补充元数据版本
  → 热更程序集版本
  → Addressables Catalog
  → Bundle 和资源内容
  → 网络协议版本
版本 谁生成 变化时会影响什么
安装包版本 应用商店构建 安装目录、原生库、引擎版本、启动代码
AOT 契约 安装包构建时的裁剪和泛型实例化 热更 DLL 能否找到类型、方法和泛型实现
补充元数据 HybridCLR 构建流程 泛型、反射和 AOT 类型解析
热更程序集 热更构建 业务代码、序列化字段、入口签名
Catalog Addressables 构建 资源定位、依赖和远端地址
协议版本 客户端和服务端共同定义 登录、战斗、背包和结算字段

这些版本不能各自独立变化。只换 DLL 不换元数据,可能启动时报类型缺失;只换 Catalog 不换 Bundle,可能找不到资源;程序集字段改了但旧存档还在,反序列化会得到默认值或直接失败。

Addressables 的 content update 需要单独处理 Catalog 和远程资源,不能把它当成代码热更的替代。1

AOT 契约为什么会断

IL2CPP 构建会提前生成大部分泛型实例和类型代码,未使用或被裁剪的代码不会自动出现在包里。热更 DLL 里新增一个泛型组合,不代表运行时能生成对应的 AOT 实现。

HybridCLR 通过补充元数据解决一部分问题,但补充元数据必须在加载热更程序集之前加载。顺序通常是:

初始化运行时
  → 加载对应版本的补充元数据
  → 加载热更程序集
  → 执行入口和业务初始化

顺序反了,程序集可能先报找不到类型,后面的元数据再也没有机会生效。具体 API 和加载时机按所用版本确认,不能只照搬旧工程的启动顺序。2

ILRuntime 也会有类似的版本关系:CLR 重定向、热更程序集和主工程的类型适配要一起更新。它不等于把 CLR 完整搬进游戏,反射、泛型和跨域调用仍需要显式适配。3

以下变化都可能导致契约不一致:

  • 公共方法签名变了,旧热更 DLL 仍按旧参数调用。
  • 字段类型从 int 改成 long,旧序列化数据还在读取。
  • 命名空间、类型名或程序集版本变了,反射和存档引用失效。
  • 新增泛型实例,但安装包里没有对应的 AOT 代码。
  • 入口类改名,启动流程仍然加载旧入口。

编辑器里能跑,不等于真机 IL2CPP 能跑。发布前要在目标平台、目标架构和实际安装包上验证。

用状态机约束流程

更新逻辑不要散落在登录界面、活动弹窗和资源加载器里。用一个状态机串起来,每个状态有输入、输出和超时:

Idle
  → Check
  → Download
  → Verify
  → Stage
  → Switch
  → Restart
  → Load
  → HealthCheck

任意一步失败
  → Rollback
  → Builtin / LastKnownGood

Switch 只切换指针,不启动游戏。Load 只说明程序集和资源能加载。HealthCheck 才说明新版本能进入可用状态,例如:

  • 能进入登录界面。
  • 能读取本地存档头,不修改账号数据。
  • 能创建基础 UI 和本地化资源。
  • 能完成一次网络握手。
  • 能跑一次不涉及奖励的本地战斗模拟。

状态和失败原因要持久化。应用在下载中被杀掉,下次启动不能把半个文件当成完整版本;应用在加载中崩溃,下次启动要能识别这是第几次启动尝试。

下载:先把文件完整落到临时目录

不要边下载边覆盖正在使用的文件。先写到版本目录旁边的临时位置,确认响应、长度和哈希后再提交。

using UnityEngine.Networking;

public async Task DownloadAsync(
    string url,
    string tempPath,
    CancellationToken token)
{
    using var request = new UnityWebRequest(
        url,
        UnityWebRequest.kHttpVerbGET);

    request.downloadHandler =
        new DownloadHandlerFile(tempPath);
    request.timeout = 20;

    var operation = request.SendWebRequest();
    while (!operation.isDone)
    {
        token.ThrowIfCancellationRequested();
        await Task.Yield();
    }

    if (request.result !=
        UnityWebRequest.Result.Success)
    {
        throw new IOException(request.error);
    }

    if (request.responseCode != 200)
    {
        throw new IOException(
            $"http_{request.responseCode}");
    }
}

这段代码只处理“下载到一个临时文件”。完整实现还要核对清单里的长度、实际写入大小、HTTP 头、磁盘空间和 SHA-256。DownloadHandlerFile 也会把错误页写进文件,所以不能因为 isDone 就认为内容正确。

需要处理的边界:

  • CDN 返回 200,内容是 HTML 错误页。
  • 断网、超时或切后台后任务被暂停。
  • 服务器返回 206,本地却把它当成完整文件。
  • 下载到一半进程被杀,留下可解析但不完整的临时文件。
  • 磁盘空间在写入过程中耗尽。
  • 多个请求同时写同一个临时路径。

临时文件带请求 ID 或 build ID,下载完成后再原子改名。失败时删除临时文件;不要直接删当前版本目录。

版本目录和指针

内容按构建版本隔离,当前版本通过指针读取:

patch/
  versions/
    build-20261114-1/
      manifest.json
      code/Game.Hotfix.dll
      metadata/
      bundle/
    build-20261113-2/
  current.json
  previous.json

current.json 只保存当前版本和必要校验字段,完整清单单独保存并验签。启动时先读指针,再检查目录和关键文件。指针损坏、目录缺失或校验失败时,尝试上一版,再回退内置版本。

指针替换的细节和路径安全见热更新包与资源验签。这里只强调一点:切换失败不能把最后一版删掉。先确认新版本完整,再保留旧版本至少一个回退窗口。

健康检查不等于加载成功

程序集 Assembly.Load 成功,只证明字节格式能被运行时接受。下面这些问题都要等初始化后才出现:

  • 补充元数据版本不对,首次使用某类型时报错。
  • 入口类存在,但构造函数依赖未注册的服务。
  • 配置字段改名,默认值让流程继续运行但结果错误。
  • Catalog 加载成功,某个远端 Bundle 在真机上不存在。
  • 新 UI 依赖新的本地化键,运行时才走到空引用。

健康检查要尽量无副作用。不要把“能登录账号”作为唯一检查,因为登录会写会话和设备记录,坏版本可能在检查过程中已经污染状态。

建议记录启动尝试:

{
  "candidateBuildId": "1.8.3.20261114.1",
  "lastKnownGood": "1.8.3.20261113.2",
  "attempt": 2,
  "stage": "entry_init",
  "result": "failed",
  "reason": "config_missing",
  "startedAt": 1763090000
}

连续失败达到阈值后熔断该版本,直接回退,不要每次启动都重新尝试。健康检查成功后清空尝试计数,并记录 lastKnownGood。

代码热更不能当作 DLL 卸载

资源可以按引用计数和依赖卸载,DLL 没有这么简单。

Unity 的 IL2CPP 运行时没有给普通业务程序集提供 AssemblyLoadContext.Unload() 那样的通用卸载路径。HybridCLR 加载进来的热更程序集、ILRuntime 的 CLR AppDomain、xLua 的 Lua 环境,都需要按各自机制处理,但都不能自动清理业务状态。

即使框架支持卸载,已经存在的这些对象仍然会拖住旧代码:

  • 静态字段和单例。
  • 事件订阅和委托。
  • 协程、定时器和异步回调。
  • 对象池、缓存和原生引用。
  • 正在运行的 UI 和场景对象。

所以不要承诺“战斗中途无缝替换全部代码”。更稳的边界是:

  • 配置和纯资源可以在安全点更新。
  • 业务代码在返回登录界面或重启进程后切换。
  • 提供明确的 Shutdown,逐个清理静态状态、事件和对象池。
  • 记录当前加载的程序集版本和入口哈希。

如果必须在战斗内更新,通常只更新不影响当前战斗的展示资源或配置,并让新逻辑在下一局生效。

兼容矩阵

发布前至少跑这些组合:

客户端 热更内容 预期
旧安装包 新热更 检查 minClient,不满足时拒绝加载
新安装包 旧热更 检查元数据、程序集和资源依赖,必要时拒绝
新安装包 新热更 正常路径,完成健康检查
旧安装包 旧热更 仍能启动,不能被新版本配置带坏
任一版本 下载中断 继续运行当前版本
任一版本 验签或解压失败 删除临时文件,不切换指针
任一版本 新版本启动失败 自动回退并上报阶段
任一版本 连续启动失败 熔断坏版本,禁止再次加载

“旧安装包配新热更”最容易漏。开发机通常装最新包,线上仍有旧客户端。热更发布前要拿上一两个仍在支持的安装包实测,而不是只测最新包。

灰度和服务器版本

灰度发布要保证同一个账号每次拿到同一个分组。可以按账号 ID、平台和渠道计算稳定哈希,再由服务器返回是否进入灰度。客户端自己随机决定会导致同一账号反复切换版本。

服务器下发的信息至少包括:

{
  "channel": "prod",
  "platform": "android",
  "buildId": "1.8.3.20261114.1",
  "sequence": 1842,
  "minClient": "1.8.0",
  "maxClient": "1.8.9",
  "rollout": 5,
  "killSwitch": false,
  "reason": "catalog_read_failure"
}

服务器也要校验客户端版本,不能只在客户端里写 minClient。旧客户端一旦连上来,服务端应返回明确的升级错误,而不是让它在不兼容的协议上继续跑。

灰度过程中重点看:

  • 启动失败率是否高于当前版本。
  • 热更下载和验签失败是否集中在某个 CDN 区域。
  • 程序集加载和入口初始化是否出现新的原因码。
  • 崩溃是否发生在同一个阶段。
  • 旧版本回退是否成功。

如果指标异常,先停止放量,再决定回滚。killSwitch 只停止新下载,不要立刻删除已经切换的版本;让仍在线的玩家完成当前会话,再在安全点回退。

CDN 和缓存

版本化路径适合长缓存,清单适合短缓存:

/patch/manifest/current.json         no-cache / max-age=60
/patch/builds/<buildId>/...          max-age=31536000, immutable

同一个 URL 发布不同内容会让缓存和回滚都变复杂。构建目录按 buildId 隔离,新版本换路径;清单短缓存,服务器需要撤销时可以快速指向新清单。

回滚时不要只覆盖一个 DLL。下面几项必须作为一套版本移动:

  • 热更程序集。
  • 补充元数据。
  • Addressables Catalog。
  • Bundle 和配置。
  • 服务器允许的 buildId 和协议版本。

只回退 DLL、资源还在新版本,或者只回退资源、代码仍按新字段解析,都会留下更难查的问题。

日志

每个阶段都记录 buildId、sequence、platform、stage、reason、elapsedMs 和当前回退版本:

[patch] check sequence=1842 build=1.8.3.20261114.1
[patch] manifest verify ok key=release-2026
[patch] download 3/5 files bytes=1204832
[patch] payload verify ok
[patch] switch current ok
[patch] metadata load ok version=aot-20261114
[patch] assembly load ok sha256=a4f9
[patch] entry init failed code=config_missing
[patch] rollback previous=1.8.3.20261113.2

日志要能回答三个问题:失败发生在哪一步,当前用的是哪套版本,回退后是否真的能启动。只看到“更新失败”无法定位问题。

热更新版本管理最终要保证:每个客户端都能说清楚自己在运行什么、依赖什么、失败后回到哪里。