跳转至

热更新常见问题与排查

先按异常定位

同一个“启动失败”可能发生在下载、程序集加载或业务初始化。先看异常类型和日志阶段,能少走很多弯路:

现象 更可能的位置 先查什么
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。

检查顺序可以固定成:

  1. 确认异常类型是首次加载就出现,还是第一次调用泛型方法时出现。
  2. 确认 AOT 侧是否真的编译了该泛型实例。
  3. 确认补充元数据文件和当前安装包来自同一构建。
  4. 确认元数据在 Assembly.Load 之前加载完成。
  5. 用目标 ABI、目标系统和真实安装包复测。

只补 link.xml 不能解决所有泛型问题。裁剪保护解决“类型不被删掉”,补充元数据解决“运行时能解析 AOT 类型”,两者作用不同。

热更 DLL 和主工程对不上

热更 DLL 编译时会引用主工程的程序集。只要主工程改了方法签名、字段类型、命名空间或程序集版本,热更 DLL 就可能加载失败。

最常见的几种情况:

  • 热更代码编译时用的是旧版 Assembly-CSharp.dll。
  • 主工程把 int 改成了 long,热更代码还在调用旧签名。
  • 程序集重命名后,旧 DLL 里的引用找不到。
  • 两边使用了不同的 Unity 或 .NET API 基线。

比较稳的做法是固定一份 AOT 契约,热更代码只引用这份契约编译。构建时记录:

  • 客户端版本范围。
  • 程序集版本和哈希。
  • 依赖列表。
  • 对应的补充元数据版本。

主工程和热更程序集必须来自同一次完整构建,不要拿昨天的包配今天的 DLL。

序列化字段和资源引用

热更新不只换代码,还会改变序列化数据。下面这些问题很常见:

  • 字段改名后,旧存档里的值读不出来。
  • 类换了命名空间或程序集,预制体上变成 Missing Script。
  • 删除旧组件后,场景和预制体里留下空引用。
  • ScriptableObject 的字段结构变化,旧配置解析失败。

字段改名可以用:

[FormerlySerializedAs("oldHp")]
public int currentHp;

类型移动或改名可以配合 [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 版本和失败原因。这样回退时才知道是没下载成功,还是下载成功但新版本根本进不了游戏。