热更新版本、下载与回退¶
新 DLL 配旧元数据¶
更新后,部分玩家卡在登录界面。日志显示下载完成、哈希通过、DLL 也加载成功,进入战斗时才报 TypeLoadException。继续查发现,客户端用的是新 DLL,补充元数据还是上一版;又或者启动包里的 AOT 代码已经被裁剪,新 DLL 引用了没有生成泛型实例的类型。
这类问题只看“热更成功”没有用。下载、验签、解压、切换、加载和初始化是不同阶段,前一步成功不代表后一步能运行。版本管理要让每个阶段知道自己依赖什么、失败后回到哪里。
版本不止一个¶
一次热更至少涉及下面几层:
| 版本 | 谁生成 | 变化时会影响什么 |
|---|---|---|
| 安装包版本 | 应用商店构建 | 安装目录、原生库、引擎版本、启动代码 |
| 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
日志要能回答三个问题:失败发生在哪一步,当前用的是哪套版本,回退后是否真的能启动。只看到“更新失败”无法定位问题。
热更新版本管理最终要保证:每个客户端都能说清楚自己在运行什么、依赖什么、失败后回到哪里。