热更新常见问题与排查¶
先按异常定位¶
同一个“启动失败”可能发生在下载、程序集加载或业务初始化。先看异常类型和日志阶段,能少走很多弯路:
| 现象 | 更可能的位置 | 先查什么 |
|---|---|---|
TypeLoadException | AOT 契约或程序集加载 | 主工程和热更 DLL 是否同一构建,类型是否被裁剪 |
MissingMethodException | 方法签名或裁剪 | 热更 DLL 引用的方法是否还在 AOT 侧 |
DllNotFoundException | 原生插件或 ABI | 插件是否进包,架构是否匹配,依赖库是否齐全 |
FileLoadException | 文件锁或程序集版本 | DLL 是否被占用,是否已加载同名不同版本 |
| 反序列化得到默认值 | 序列化字段变化 | 字段名、类型、程序集和迁移逻辑 |
| 资源变粉或材质丢失 | Shader 或 Bundle | Shader 变体、Catalog 和 Bundle 是否同一版 |
| 进入战斗后空引用 | 入口初始化或旧状态 | 静态字段、事件订阅、对象池和单例 |
| 回退后仍然失败 | 回退不完整 | 程序集、元数据、Catalog 和配置是否一起回退 |
先定位阶段,再查具体 API。只看到 NullReferenceException 就把问题归到业务代码,往往会漏掉元数据没加载、资源版本不一致或旧对象没有清掉。
编辑器能跑,真机缺类型¶
编辑器通常使用 Mono,类型和反射路径比较宽松。真机切到 IL2CPP 后,代码会被提前编译和裁剪,热更程序集直到运行时才出现。
这时经常出现 MissingMethodException、TypeLoadException,或者反序列化时找不到字段。原因通常不是热更代码写错,而是 AOT 侧没有生成对应类型。
先用 link.xml 保护明确需要的类型:
<linker>
<assembly fullname="Game.AOT">
<type fullname="Game.AOT.BattleEvent" preserve="all" />
<type fullname="Game.AOT.SaveData" preserve="all" />
</assembly>
</linker>
不要一开始就 preserve="all" 整个程序集,包体会涨得很快。先看裁剪日志和真机异常,再逐步补。
另一个办法是加 [Preserve],但它只适合少量类型,不能替代完整的裁剪检查。1
泛型没有实例化¶
AOT 编译器只会为构建期见过的值类型泛型生成实现。热更代码里如果突然使用 List<HotfixData>,而主工程没有出现过这个组合,就可能找不到实现。
常见补救方式是在 AOT 侧显式引用。下面的类型必须来自 AOT 可见的共享契约程序集,不能只定义在热更 DLL 里:
public static class AotGenericReferences
{
public static void Keep()
{
_ = new List<HotfixData>();
_ = new Dictionary<int, HotfixData>();
}
}
这段代码必须真的被 AOT 编译进去。只写在热更 DLL 里,没有意义。
如果 HotfixData 只存在于热更程序集,AOT 工程无法直接引用它,单靠这段代码也不会生成对应实现。这种情况要走所用运行时的 AOT 泛型补充流程,并在加载热更程序集前完成元数据加载。
HybridCLR 还会通过补充元数据解决一部分 AOT 泛型问题,但元数据要在加载热更程序集之前加载。4 不同方案的 API 不一样,具体看 HybridCLR 和 ILRuntime。
检查顺序可以固定成:
- 确认异常类型是首次加载就出现,还是第一次调用泛型方法时出现。
- 确认 AOT 侧是否真的编译了该泛型实例。
- 确认补充元数据文件和当前安装包来自同一构建。
- 确认元数据在
Assembly.Load之前加载完成。 - 用目标 ABI、目标系统和真实安装包复测。
只补 link.xml 不能解决所有泛型问题。裁剪保护解决“类型不被删掉”,补充元数据解决“运行时能解析 AOT 类型”,两者作用不同。
热更 DLL 和主工程对不上¶
热更 DLL 编译时会引用主工程的程序集。只要主工程改了方法签名、字段类型、命名空间或程序集版本,热更 DLL 就可能加载失败。
最常见的几种情况:
- 热更代码编译时用的是旧版
Assembly-CSharp.dll。 - 主工程把
int改成了long,热更代码还在调用旧签名。 - 程序集重命名后,旧 DLL 里的引用找不到。
- 两边使用了不同的 Unity 或 .NET API 基线。
比较稳的做法是固定一份 AOT 契约,热更代码只引用这份契约编译。构建时记录:
- 客户端版本范围。
- 程序集版本和哈希。
- 依赖列表。
- 对应的补充元数据版本。
主工程和热更程序集必须来自同一次完整构建,不要拿昨天的包配今天的 DLL。
序列化字段和资源引用¶
热更新不只换代码,还会改变序列化数据。下面这些问题很常见:
- 字段改名后,旧存档里的值读不出来。
- 类换了命名空间或程序集,预制体上变成
Missing Script。 - 删除旧组件后,场景和预制体里留下空引用。
- ScriptableObject 的字段结构变化,旧配置解析失败。
字段改名可以用:
类型移动或改名可以配合 [MovedFrom],但最好保留旧类型一段时间,等旧资源迁移完再删。2
资源和代码还要一起测:
- 旧存档加载新代码。
- 新存档加载旧代码。
- 旧预制体加载新脚本。
- 新 Bundle 配合旧 Catalog。3
- Shader 变体是否被裁剪。
Shader 变体没打进包里时,编辑器里可能正常,真机上会变粉。这个问题和热更代码无关,但经常在同一次发布里一起出现。
原生插件和平台差异¶
C# 热更代码能加载,不代表原生依赖能用。常见问题有:
- Android 只打进了
arm64-v8a,测试机却是x86_64模拟器。 .so的依赖库没有一起进包,加载时才报DllNotFoundException。- iOS 使用静态库,链接参数、架构或 Bitcode 配置不一致。
- Windows 的 DLL 是 32 位,主程序是 64 位。
- WebGL 没有对应线程、Socket 或文件系统能力,插件直接编译失败。
发布前按目标平台列出原生插件清单、ABI、依赖和加载路径。热更只能替换允许动态加载的内容,不能替安装包补一个缺失的原生库。
加载顺序和状态残留¶
一个常见的加载顺序是:
顺序不能反。元数据没加载就构造泛型,或者程序集没加载就调入口,都会失败。
热更新还会留下旧状态:
- 静态字段和缓存。
- 事件订阅和委托。
- 正在运行的协程。
DontDestroyOnLoad对象。- 对象池和 Addressables 的 Handle。
- 第三方插件的全局状态。
可以给热更入口准备明确的 Initialize 和 Shutdown:
public static class HotfixEntry
{
public static void Initialize()
{
BattleEvents.Clear();
BattleCache.Reset();
RegisterSystems();
}
public static void Shutdown()
{
UnregisterSystems();
BattleEvents.Clear();
BattleCache.Reset();
}
}
编辑器关闭 Domain Reload 后,静态字段不会自动清空,更容易掩盖这类问题。测试时要分别跑“重启游戏”和“运行时重新加载”两条路径。
不要把 DLL 当成可随时卸载的插件¶
资源有引用计数,程序集没有同样的生命周期。IL2CPP 下不存在通用的“卸载一个 DLL,再加载同名新 DLL”的机制,HybridCLR、ILRuntime 和 xLua 也不能自动清掉已经创建的对象、静态字段、事件和原生引用。
所以代码热更要明确边界:
- 战斗或副本结束后再切换。
- 回到登录界面,必要时重启进程。
- 提供
Shutdown,清事件、缓存、对象池和定时器。 - 不保留旧程序集对象的引用。
- 记录当前加载的程序集版本,方便线上定位。
“程序集加载成功”只说明类型能解析。旧对象还引用着旧方法、旧配置还在被读取,都会让新版本看起来时好时坏。版本切换和回退流程见热更新版本、下载与回退。
下载和回退¶
热更包下载到临时目录,校验通过后再切换,不要直接覆盖正在使用的文件。
清单至少要有:
{
"buildId": "1.8.3-hotfix.1",
"clientVersion": "1.8.0-1.8.9",
"mainAssembly": "Game.Hotfix.dll",
"metadata": "AOTMetadata.dll",
"sha256": "...",
"signature": "..."
}
下载流程要处理:
- 断网、超时和重试。
- 磁盘空间不足。
- 下载到一半退出。
- CDN 缓存了旧文件。
- 文件哈希不对。
- 新版本加载失败后回退旧版本。
回退不是把 URL 改回去就结束了。旧程序集、旧元数据和旧资源必须还在,客户端也要能识别“当前版本不可用”。
真机检查清单¶
至少覆盖:
| 场景 | 检查内容 |
|---|---|
| 全新安装 | 内置版本能启动,首次下载失败也能进游戏 |
| 覆盖安装 | 旧存档、旧资源、旧配置能迁移 |
| 增量热更 | 只下载变化内容,哈希和依赖正确 |
| 完整热更 | 旧版本能完整切到新版本 |
| 断网启动 | 有超时和错误提示,不无限等待 |
| 下载中断 | 临时文件可清理,不破坏当前版本 |
| 回退 | 新版本失败后能回到上一版 |
| iOS | 裁剪、泛型、解释执行和审核限制 |
| Android | ABI、分包、存储权限和低内存设备 |
| Windows | 文件占用、权限和更新器替换 |
热更新常见的问题是只有一部分初始化成功。启动日志要把下载、校验、元数据加载、程序集加载和入口初始化分开记录,否则线上只能看到一个笼统的启动失败。
真机验证可以按下面的顺序跑,前一阶段失败就不要继续下一步:
每一步都记录 buildId、程序集哈希、元数据版本、Catalog 版本和失败原因。这样回退时才知道是没下载成功,还是下载成功但新版本根本进不了游戏。