Unity代码热更实战:Addressable与ILRuntime组合方案详解

发布时间:2026/10/20 3:21:57
Unity代码热更实战:Addressable与ILRuntime组合方案详解
从多个实际项目里折腾下来我最终固定的热更组合是 Addressable 加 ILRuntime。不是没试过别的方案Lua 系的热更框架用起来要维护另一套语言团队里的 C# 同学写起来总觉得隔了一层HybridCLR 类方案能力确实强但部署环节和打包约束比较复杂。到最后选 Addressable ILRuntime核心原因是它够直白——代码还是 C#资源管理交给 Addressable 统一处理两者都只解决自己最擅长的那部分问题。这篇文章是系列笔记的第三篇重点讲代码热更这条链路怎么跑通包括工程拆分、DLL 交付、运行时加载以及我在实际集成中遇到的坑。想省事的可以直接看第 6 节但建议还是从头读一遍。1. 热更方案不少为什么偏偏是 Addressable 加 ILRuntime 的组合1.1 代码热更的本质是给升级流程装上“安全阀”先说清楚我们到底想要什么。客户端发一个版本要经过编译、打包、上传商店、审核、用户下载这一整套流程慢的要几天快的也要大半天。线上出个紧急 bug或者配置表数值填错了等你走完这套流程玩家早就流失一部分了。所以热更不是炫技是想在“发布”和“运行”之间留一个可以快速修正的通道。代码热更要处理的不只是几行代码还有这代码依赖的所有资源、配置、第三方库甚至程序集之间的引用关系。如果只热代码不热资源那新代码一旦引用了没下发的新资源线上直接崩这比不热更新还可怕。所以选型时我不是只看“哪个框架支持代码热更”而是看“谁能让代码和资源走同一条更新管道”这部分我们过会儿再展开。1.2 ILRuntime 是解释器不是编译器这决定了它的使用边界ILRuntime 的本质是用纯 C# 实现了一个 .NET IL 指令的解释器。Unity 打包到 iOS 这类不允许 JIT 的平台时没法在运行时动态生成并执行机器码这是很多方案的硬伤。ILRuntime 的思路不是把 IL 编译成机器码而是直接一条一条解释执行 IL 指令。相当于在游戏进程里内置了一个解释器把热更程序集当成脚本数据来跑。这个取舍带来的好处很明确支持范围广代码逻辑可以动态下发代价也很明确性能比原生编译低了几个数量级而且热更侧和主工程侧之间如果来回频繁调用会因为跨解释器边界产生额外开销。我自己的使用边界是热更层承载玩法逻辑、数值计算、UI 流程、状态机这类“次数不多但逻辑复杂”的东西高频率的渲染、物理、序列化等重活尽量留在主工程。别把 ILRuntime 当成“换了个姿势的原生代码”你的工程架构需要为它重新设计边界。1.3 Addressable 解决的问题不止是“远程加载”很多人一听说 Addressable第一反应是“不就是远程加载资源吗”。实际上它对热更链路最有价值的部分是资源的可寻址性和依赖管理。传统 AssetBundle 方案里你手动维护 Bundle 清单、处理依赖、写加载器版本一多非常容易乱。Addressable 把这部分自动化了资源通过 Address 寻址构建时自动分析依赖关系并打进 Bundle运行时通过 Catalog 记录版本和 hash 信息。更关键的是它抽象出了“本地目录”和“远程目录”两套位置同一份资源路径既能从本地包读出也能从 CDN 拉新版本。把热更代码当作资源来看待就能天然融入这套体系。DLL 文件和美术资源、配置表一样都有唯一的 Address有版本概念有依赖处理。客户端启动时只要把 Catalog 更新一下后续 Load 就能自动命中新版本不需要我自己设计一套额外的热更下载逻辑。2. 动手前先理清工程边界程序集拆分、CLR 绑定和分包策略2.1 热更工程跟主工程的引用关系如何切断ILRuntime 加载的 DLL 必须是独立编译的程序集不能和你主工程的程序集互相“你中有我我中有你”。最推荐的做法是维护一个独立的热更工程比如叫 HotfixModule它作为普通类库工程编译引用 UnityEngine.dll 以及必要的第三方库唯一的要求是主工程绝对不能反向引用它。这样 LoadAssembly 时ILRuntime 才能在一个干净的程序集上下文里解释执行而不会和已经加载的主工程类型产生冲突。实际项目里经常有人图省事把热更源码也放在 Assets 下通过 asmdef 划一个程序集编译出来的 DLL 再拿去给 ILRuntime 加载。这样不是不行但容易踩一个隐蔽的坑Unity 在启动时会同时加载主工程程序集和这个热更程序集如果主工程里某个脚本也引用了热更程序集里的类型那类型信息在同一个进程里是不一致的两份运行时各种诡异的类型转换失败就来了。我建议还是把热更工程独立出去编译产物DLL单独拷入 Assets 对应目录源文件不进 Unity 工程这样最干净。2.2 生成 CLR 绑定与委托适配器这一步影响性能和稳定性ILRuntime 热更代码里经常要调用主工程的方法、访问主工程类的字段比如获取当前玩家的等级、调用 UI 面板的刷新接口。如果每次调用都走反射性能很差甚至有些 AOT 平台的泛型类会直接崩。解决办法是用 ILRuntime 附带的一个代码生成工具扫描主工程里热更侧需要用的关键类型生成一份 CLR 绑定代码一般是 CLRBindings.cs 文件。这段文件里包含了这些类型的字段、方法、属性访问的直接映射把常规的跨边界调用从“反射”优化为“查表 直接调用”。生成绑定这步要定期执行不能只做一次。因为你会发现开发过程中主工程接口不断变化今天这个类被热更代码调到了但没绑定明天那个泛型接口又换签名了。我的习惯是每次主工程对外接口有改动就在打包前重新跑一遍绑定生成而不是等到真机上报异常再去排查。2.3 资源目录与 asmdef 的规划Addressable 的 Group 结构最好在项目早期就规划好。我会把资源分成三类随包资源首包必须包含的如启动界面、首个场景、核心 UI放在 Local Group热更代码组专门放热更 DLL 和其他脚本资源放在 Remote Group版本资源组放配置表、数值表、活动资源也放在 Remote Group但它按内容更新粒度拆分方便只拉 diff。不要把所有远程资源塞进一个大 Group。热更代码发版频率高、体积小应该单独一个 Group美术资源发版频率低、体积大单独一个 Group。这样每次内容更新时构建出来的新 Bundle 只会影响对应分组Catalog 的 diff 也会小很多玩家下载的时候不用为一个新活动把整个远程包重新拉一遍。3. 把 DLL 交给 Addressable资源分组、打包脚本和加载入口3.1 为什么要给 DLL 穿一件 .bytes 的外衣Unity 对 Assets 目录下的 .dll 文件有特殊处理它会当作预编译程序集自动引用并参与编译。如果直接把 HotfixModule.dll 扔进 Assets 文件夹Unity 会试图把它作为原生程序集加载而你真正要的是“把这份编译产物作为普通二进制资源读取”。所以通常做法是把 DLL 重命名为 .bytes比如 HotfixModule.dll.bytesUnity 就会把它当成 TextAsset 导入不参与代码编译只作为资源加载。要注意的是导入时看 Inspector 里是否显示为 TextAsset而不是 MonoScript。如果不放心可以用一个简单的编辑器脚本在导入后检查类型。3.2 Addressable 分组与打包脚本选择在 Addressable Groups 窗口里新建一个 HotfixCode 分组把 HotfixModule.dll.bytes 拖进去给它一个 Address 名称比如 “HotfixModuleBytes”。该 Group 的 Build/Load Path 建议选择 Remote 对应项也就是构建时打入远程组运行时从远端拉取。首次完整包需要把这份 DLL 也打进包里靠的是 Addressable 的本地构建后续热更则靠 Content Update 流程更新到 CDN。打包脚本这块可能有朋友踩过坑编辑器下 Addressable 默认的 Play Mode Script 如果选的是 “Use Asset Database”那么你加载的其实是源资源远程 Catalog、Bundle 依赖全都不参与本地测试没问题但真机一定是错的。做热更验证时至少要用 “Use Existing Build” 或显式跑一次完整构建也就是选择 Packed Mode 生成的 Bundle 来模拟。我见过不少团队在编辑器下测得好好的一上真机就黑屏结果就是 Play Mode Script 没切回来。3.3 从 Addressable 取出程序集字节的完整代码加载 DLL 的过程本质上和加载一个 TextAsset 没有区别核心代码如下using System.IO; using UnityEngine; using UnityEngine.AddressableAssets; using UnityEngine.ResourceManagement.AsyncOperations; public class HotfixLoader : MonoBehaviour { private AsyncOperationHandleTextAsset _handle; private ILRuntime.Runtime.Enviorment.AppDomain _domain; IEnumerator Start() { // 先初始化 Addressable确保远程 Catalog 更新过 yield return Addressables.InitializeAsync(); // 加载热更程序集 _handle Addressables.LoadAssetAsyncTextAsset(HotfixModuleBytes); yield return _handle; if (_handle.Status AsyncOperationStatus.Succeeded) { byte[] dllBytes _handle.Result.bytes; _domain new ILRuntime.Runtime.Enviorment.AppDomain(); using (MemoryStream ms new MemoryStream(dllBytes)) { _domain.LoadAssembly(ms); } // 后续的入口调用、生命周期管理都在这里继续 } else { Debug.LogError(Hotfix module load failed: _handle.OperationException); } } void OnDestroy() { if (_handle.IsValid()) Addressables.Release(_handle); } }注意几个小细节LoadAssetAsyncTextAsset拿到的实例不能随便 Release要等你整个生命周期都不需要这份 DLL 字节了再释放。另外热更组件里一定不要引用 MonoBehaviour 生命周期来驱动逻辑这点下面会讲。4. 运行时链路初始化 ILRuntime、重定向和启动热更逻辑4.1 入口的约定与转发方式DLL 加载进来之后ILRuntime 并不知道哪个类是你的入口。所以要先约定一个固定入口类型和方法最简单的做法是定义一套统一接口热更侧实现主工程按类型名反射调用。比如public interface IHotfixEntry { void OnEnter(); void OnUpdate(float deltaTime); void OnExit(); }主工程在加载完程序集后从_domain.GetType(Hotfix.MainEntry)拿到 IL 类型再让_domain.Instantiate创建实例。之后主工程持有这个实例引用每帧调用对应方法。这块有点反直觉的是ILRuntime 创建的实例和正常 C# 对象一样可以用接口去转型前提是这个接口在主工程程序集里定义CLR 绑定正确生成。否则转型会失败然后你会看到一堆“InvalidCastException”。4.2 注册 CLR 绑定与委托适配器加载程序集之后第一步永远是调用生成好的 CLRBindings 初始化比如ILRuntime.Runtime.Enviorment.AppDomain domain new ILRuntime.Runtime.Enviorment.AppDomain(); CLRBindings.Initialize(domain); domain.LoadAssembly(stream);这两行的顺序不能换。CLRBindings 里注册了大量主工程类型的字段索引、方法索引、委托适配器如果先 LoadAssembly 再 Initialize某些类型的绑定信息已经晚了。另一点是委托适配器热更代码里如果有“调用主工程注册的回调方法”这种需求比如将 UnityAction 传给主工程的 UI 接口ILRuntime 需要知道怎么在你的委托类型和目标方法之间搭桥。绑定时生成的委托适配器代码会在Initialize阶段把这些桥接注册好。每次接口或回调签名变化记得重新生成绑定。4.3 热更侧不挂 MonoBehaviour而是让主工程桥接这是最容易理解错的地方。很多人想在热更组件的脚本里直接写public class HotfixBehaviour : MonoBehaviour但 ILRuntime 解释执行一个类时它没法像 Unity 原生程序集一样由引擎来管理生命周期MonoBehaviour 的消息函数根本不会被引擎调用。就算你能构造实例引擎也不知道它的存在。正解是主工程做一个桥接 MonoBehaviour挂到某个空 GameObject 上由它在 Awake/Start/Update/OnDestroy 里转发到热更入口public class HotfixBridge : MonoBehaviour { private IHotfixEntry _entry; public void BindEntry(IHotfixEntry entry) { _entry entry; } private void Update() { _entry?.OnUpdate(Time.deltaTime); } private void OnDestroy() { _entry?.OnExit(); } }热更入口里要做的事比如创建角色、打开界面、初始化玩法数据都通过调用主工程暴露出来的接口完成。你要是想在热更侧生成一个带 MonoBehaviour 的物体也应该是主工程通过工厂方法创建而不是热更侧直接new GameObject再挂脚本。理解了这个关系后续很多生命周期问题就不会发生了。5. 版本演进时的更新链路Catalog 刷新与资源检查5.1 正式包和热更包在 Addressable 里是两套构建首包构建时Addressable 会生成一组 Catalog 和对应的 Bundle热更 DLL 作为其中一份资源按你配置的路径打进去。之后代码有改动并不是直接替换服务端的 DLL 就完事而是要走一次 Content Update 构建。这个构建过程会基于上一次正式包的构建结果分析出哪些内容发生了变化重新生成新的 Catalog 和对应 Bundle。新的 Bundle 部署到 CDN老包的用户在启动时发现远程 Catalog 版本比本地新就会自动更新 Catalog然后后续资源加载就都会按新 Catalog 走。这里容易被忽略的是“内容更新限制”选项。它的作用类似于锁定一部分被打入安装包的资源不做 diff防止某些引用关系在增量更新中损坏。勾选后Addressable 会保留一个资源哈希列表只有允许变动的内容才能被打进更新包。对于热更代码这种典型高频变更对象我建议单独分组并确保它不被锁定其他低频美术资源组保留默认限制即可。5.2 客户端启动时的一套最小检查流程日常启动流程我建议按这个顺序做调用Addressables.InitializeAsync()让 Addressable 完成初始化加载本地 Catalog调用Addressables.CheckForCatalogUpdates()检查远程服务器是否有新 Catalog有更新则调用Addressables.UpdateCatalogs()把新 Catalog 下载到本地再LoadAssetAsyncTextAsset(HotfixModuleBytes)加载热更代码此时系统会根据新 Catalog 自动从远程拉取最新 DLL 字节对比本地代码版本标记和该 DLL 的版本信息决定是否需要重新创建 ILRuntime AppDomain 并加载新程序集执行热更入口初始化进入游戏流程。为什么要在LoadAssetAsync之前更新 Catalog因为 Addressable 的加载逻辑是“拿着当前 Catalog 去查资源归属和 Bundle 位置”。Catalog 没更新加载的就是旧地址、旧版本。先更新 Catalog后续 Load 才能命中新版本。这个顺序错一字线上资源就会很整齐地全部回到旧版本。5.3 强制更新和灰度更新怎么收口热更路径是客户端逻辑线上严重 bug 可以靠它快速修复但一些强契约的格式变更比如存档结构不兼容、数据库结构调整、涉及客户端底层渲染的改动就未必能靠热更解决需要强制更新。我的做法是在远程配置表里维护“当前可接受最低客户端版本”进入游戏前拉一次配置如果本地版本低于最低线就弹更新界面引导用户去商店更新。具体到代码热更还要分强制热更和可选热更不同 Bundle 版本对应不同状态服务器可以按玩家账号灰度发布某个版本的 Catalog 和 Bundle比如先 5% 再 20% 再全量。这套逻辑和 Addressable 本身无关是你在上层业务模块里实现的。6. 踩坑记录那些文档不会告诉你的细节6.1 编辑器里一切正常真机 Release 却悄悄丢掉回调这是我印象最深的一次排查。某个模拟项目的 UI 回调在编辑器里点击按钮一切正常但打包到 Android Release 后按钮点了没反应也不报错。最后定位是委托适配器没有及时更新。界面流程的按钮事件是从热更代码注册给主工程 UI 框架的闭包目标在热更侧ILRuntime 需要知道这个委托应该走哪条桥接。当时开发中期我重构了一次主工程的接口命名和 asmdef 划分但没有重新跑绑定生成器结果 Release 优化后的代码直接把这个“没注册”的委托适配器路径给裁掉了。修复方法很简单重新生成 CLR 绑定和委托适配器再走一次内容更新构建。但那三天排查的时间完全本可以通过“改接口后必须重生成绑定”这个流程规范避免。6.2 调试热更代码时符号文件、日志和调试器一个都不能少ILRuntime 的代码不是原生编译Unity 的断点是断不进去的。好在 ILRuntime 官方提供了一个调试器集成包能在编辑器里附加到 Unity 的 ILRuntime 实例断点、单步都能用。前提是你的热更 DLL 工程在编译时生成 PDB 调试符号并且把符号随着 DLL 一起暴露给调试器。包里用的HotfixModule.dll.bytes只是给 Addressable 的可读资源调试器还需要能拿到原始的 DLL 和 PDB 路径。我的建议是开发阶段给热更工程编译一个带调试符号的版本发布热更包时再切 Release 编译减小 DLL 体积也提升解释执行速度。另外热更侧一定要有日志通道尽量复用主工程的日志体系否则真机上热更逻辑崩了你只能看到一个“Object reference not set to an instance of an object”排查范围会从一行代码扩大到整个模块。6.3 跨程序集调用是性能上的“地铁闸机”跨主工程和 ILRuntime 程序集边界的一次方法调用比自己调用自己要多走不少步骤要查方法表、解析参数、进入解释器循环。虽然单次也就微妙级但架不住密集触发。真正的问题出在架构设计上如果每帧都让热更代码去改 UI 上的文本、位置、颜色一帧调用几十次帧率立刻给你颜色看。这不是 ILRuntime 的 bug是设计上把它当原生代码用了。建议的做法是构建“数据通道”热更侧把状态写入一个主工程定义好的数据结构主工程在 Update 里读取这个数据并批量刷新 UI。这样每帧跨域调用次数就从中等规模降到几次性能就能保持稳定。我实测过同样逻辑用直调是 7ms改成数据通道后大部分帧都在 1ms 以下。6.4 Catalog 与 Bundle 不一致引发的连锁问题有次测试环境更新了 GitHub 上的 Catalog但没把最新 Bundle 传到 CDN。结果客户端启动时拉到了新 Catalog按新 Catalog 找到的资源地址还是旧 Bundle 的位置加载回来一个陈旧版本代码却已经跑到需要新资源的分支场景里瞬间出现一串资源丢失。这个问题在 Addressable 里相当隐蔽因为编辑器下你很难构造这个状态。我的实践是正式环境里 Catalog 和 Bundle 必须走同一次 Content Update 构建产物打包脚本里做好校验比如部署前对比远程目录的文件 hash 和服务端记录的 hash不通过就直接中断上传流程。发布流程稳定了这种连锁问题就不可能再出现。6.5 热更代码里的泛型、枚举和 Linq 要克制不是不能用而是要想清楚用在哪。ILRuntime 解释执行时复杂泛型类型会走更繁琐的类型解析一些主工程泛型集合在 AOT 裁剪后缺失元数据就会报出“找不到方法”之类的问题。常用泛型 List、Dictionary 一般没问题但自定义复杂泛型有概率踩雷。Linq 也是类似热更侧写了大量 lambda 表达式看着简洁解释器要维护的闭包和委托对象会显著增加 GC 压力。我现在的习惯是热更代码里尽量写显式循环少用Where(...).Select(...)一串链式调用真要用提前声明局部委托变量减少分配。最后再说一个实际运维中的体会热更做出来只是第一步真正让它可靠的是流程规范。绑定生成、内容更新构建、Catalog 和 Bundle 的校验、灰度发布这些步骤只要有一个环节是手动完成的线上迟早出问题。等你把整套链路都固化到构建脚本里你才会觉得这个方案真的稳了。