不少朋友在上篇里已经把 HelloWorld 跑通了加载了一个 DLL调了一个方法看到控制台打出一行字然后就停住了。因为接下来要面对的现实问题是Demo 跑得通但真正塞进自己的项目里该怎么组织热更代码主工程和热更工程怎么划分为什么热更代码不能直接挂在 GameObject 上每次调试报错连行号都没有这篇文章就接着上一篇往下讲把从“能跑通”到“能落地”的这条链完整过一遍包括热更程序集的工程结构、AppDomain 的加载管理、主工程与热更代码互相调用的几种方式、常见异常排查、性能瓶颈以及最后怎么和版本更新流程接上。内容偏实战适合已经装了 ILRuntime、能跑通最简示例、但还不太清楚怎么往正式项目里放的读者。1. 热更工程结构从“单个脚本”到“独立程序集”1.1 为什么一定要把热更代码单独做成程序集很多人第一次尝试 ILRuntime 时会直接在 Unity 的 Assets 目录下建一个HotFix文件夹用普通 C# 脚本写热更逻辑然后想把这部分代码编译成 DLL。事实上 Unity 默认会把 Assets 下所有脚本编译进同一个程序集Assembly-CSharp就算你把这个目录里的脚本内容再复制到一个类库工程里也会面临两份代码不一致的问题。真正的热更逻辑必须放在 Unity 工程之外的独立 C# 类库工程里输出成一个单独的 DLL。原因很简单Unity 打出来的主程序包已经包含了 Assets 下所有脚本的编译结果你不可能在发版后再对 Assets 里的 C# 脚本做替换。ILRuntime 的核心思路就是把那些“以后可能要改”的玩法、活动、UI 流程抽到外部 DLL 中主包只保留稳定的框架和 ILRuntime 宿主。玩家每次启动后去下载最新的 DLL然后主工程通过 ILRuntime 加载和解释执行这个 DLL就能实现不发新包更新逻辑。这和 Lua 热更新的思路有本质区别。Lua 是换一种语言去写ILRuntime 是说“我还是用 C# 写只是这些代码不被打进主包”。对于一个团队里全是 C# 开发者、没人愿意碰 Lua 的项目来说ILRuntime 的上手成本确实低很多但也因为它是解释执行所以工程结构必须比 Lua 项目更讲究否则后面每次热更都容易出一堆幺蛾子。1.2 热更代码与主工程的目录划分我比较推荐的项目结构大概长这样UnityClient/ Assets/ Scripts/ Main/ # 主工程框架ILRuntime宿主、UI框架、网络层等 HotFix/ # 主工程与热更的交互层接口、桥接类、委托注入 StreamingAssets/ HotFix/ HotFix.dll HotFix.pdb HotFixProject/ # 独立的C#类库工程所有热更逻辑都在这里 HotFix.csproj Properties/ AssemblyInfo.cs Tools/ BuildHotFix.bat # 编译脚本这里有一个很容易被忽略的点Assets/Scripts/HotFix目录下的代码是主工程的一部分会被编译进主包。但这个目录里的代码不应该包含任何具体玩法逻辑它只做两件事一是定义热更代码需要的主工程接口二是作为承接热更调用的桥梁。热更工程里真正写的App.cs、GameLogic.cs这些文件全部放在HotFixProject里面。为什么要在主工程单独留一个交互层因为直接让热更 DLL 引用主工程程序集会碰到循环依赖和程序集体积膨胀的问题。热更 DLL 可以引用UnityEngine.dll但如果你的主工程代码里包含大量业务类热更 DLL 一旦引用主工程就等于把整个主包逻辑都挂上了依赖后续拆包、做热更增量都会变得混乱。所以主工程只暴露最小必要的接口热更代码面向这些接口写具体的实现通过委托注入或者适配器传给热更侧。1.3 类库工程与编译参数设置在 Visual Studio 里创建一个“类库.NET Framework”项目或者用.NET Standard 2.0类库都可以。ILRuntime 本身不挑高版本语法但考虑到 Unity 的 Mono/IL2CPP 环境目标框架我一般选.NET Framework 4.6.1或.NET Standard 2.0后面生成的 DLL 在 Unity 里加载兼容性最好。需要给类库添加 UnityEngine 的引用。Unity 安装目录下的Editor/Data/Managed/UnityEngine/里能找到这些程序集Unity 2019 之后拆分成多个模块比如UnityEngine.CoreModule.dll、UnityEngine.IMGUIModule.dll等。你不需要全部引用用到了哪些 API 就引用哪些模块。如果你不想手动维护 csproj一个取巧的办法是先在 Unity 工程里创建HotFixProject这样一个普通文件夹把热更源码写进去然后用 Unity 的Editor scripting将这个文件夹从 Assembly-CSharp 编译中排除再通过一个独立的.csproj生成工具来编译。不过这个方法要额外写不少编辑器工具我自己的习惯是直接维护一个独立类库工程编译脚本用 MSBuild 一行命令搞定${MSBUILD_PATH} HotFixProject/HotFix.csproj /p:ConfigurationRelease /p:OutputPath../Assets/StreamingAssets/HotFix/注意编译时要把DebugType设置为pdbonly同时生成 DLL 和 PDB 文件。PDB 文件在开发阶段非常重要没有它ILRuntime 抛异常时你就看不到行号定位问题会非常痛苦。后面调试部分我会详细说。2. 装载与初始化从创建 AppDomain 到首个方法调用2.1 AppDomain 是唯一的运行时宿主ILRuntime 的AppDomain并不等同于 .NET 里的AppDomain它是 ILRuntime 自己实现的一个解释执行环境。整个 Unity 进程里只需要创建一个 ILRuntime 的 AppDomain之后所有热更程序集都加载到这个域里。很多新手的第一个坑就是为了图方便每次调用热更方法都new AppDomain()然后再LoadAssembly。这样做不是不能运行但每次加载都是一次完整的初始化代价很高而且类静态字段会被重置热更代码里的状态全都没了。正确做法是把 AppDomain 做成全局单例类似public class ILRuntimeManager : MonoBehaviour { public static ILRuntime.Runtime.Enviorment.AppDomain Domain { get; private set; } private void Awake() { Domain new ILRuntime.Runtime.Enviorment.AppDomain(); var dllPath Path.Combine(Application.streamingAssetsPath, HotFix/HotFix.dll); var pdbPath Path.Combine(Application.streamingAssetsPath, HotFix/HotFix.pdb); byte[] dllBytes File.ReadAllBytes(dllPath); byte[] pdbBytes File.ReadAllBytes(pdbPath); using (var dllStream new MemoryStream(dllBytes)) using (var pdbStream new MemoryStream(pdbBytes)) { Domain.LoadAssembly(dllStream, pdbStream, new ILRuntime.Mono.Cecil.PdbReaderProvider()); } } }LoadAssembly的第一个参数是 DLL 的流第二个参数是 PDB 符号流第三个参数是符号读取器。一定要用流而不是直接用字符串路径因为后续做热更新时DLL 是从网络下载到持久化目录的你不可能保证它一直躺在 StreamingAssets 里。如果你的项目从 AssetBundle 里加载 DLL也是把 AB 里的TextAsset转成字节数组再包成 MemoryStream流程完全一致。2.2 调用热更入口方法GetType 与 Invoke加载完成后主工程想调用热更代码最直白的办法是反射查找类型并调用方法。比如热更工程里有一个静态类Appnamespace HotFix { public static class App { public static void Main() { UnityEngine.Debug.Log(Hello ILRuntime); } } }主工程里这样调ILRuntimeManager.Domain.Invoke(HotFix.App, Main, null, null);注意Invoke方法的前两个参数分别是类型全名和方法名这里的类型名必须带命名空间写App是找不到的。第三个参数是要调用方法的类型实例对于静态方法传null第四个参数是参数列表的object[]没有参数就传null。如果是带参数的实例方法就要先拿到类型再创建实例var type ILRuntimeManager.Domain.GetType(HotFix.Game); var gameObj type; // 这里并不是直接构造实际上 ILRuntime 中创建热更类型的实例可以使用AppDomain.Instantiateobject game ILRuntimeManager.Domain.Instantiate(HotFix.Game); type.GetMethod(Start).Invoke(game, new object[] { });不要试图用new去构造一个 ILRuntime 里的热更类主工程是不能直接new出来的。2.3 高频调用的正确姿势缓存 MethodInfo 和委托Invoke每个方法查找一次类型碰到高频调用会有点伤。虽然 ILRuntime 内部会做一些缓存但我的建议是入口方法、Update 循环、UI 回调这类每秒调用几十次以上的方法至少要把MethodInfo或者委托缓存下来。缓存的写法并不复杂public class HotFixInvoker { private static MethodInfo _updateMethod; public static void CacheUpdateMethod() { var type ILRuntimeManager.Domain.GetType(HotFix.Game); _updateMethod type.GetMethod(OnUpdate); } public static void Update(float deltaTime) { _updateMethod.Invoke(_gameInstance, new object[] { deltaTime }); } }不过MethodInfo.Invoke仍然有参数数组装箱的开销。想要更好的性能可以借助 ILRuntime 提供的委托注册机制。ILRuntime 有专门的DelegateManager先把某个方法签名注册进去之后把反射出来的方法直接转换成对应的强类型委托ILRuntimeManager.Domain.DelegateManager.RegisterMethodDelegatefloat(); var type ILRuntimeManager.Domain.GetType(HotFix.Game); var method type.GetMethod(OnUpdate); var updateAction method.CreateDelegate(typeof(Actionfloat), null) as Actionfloat;之后每次调用updateAction(deltaTime)就非常快了。这里有一个注意事项CreateDelegate需要方法签名与注册的委托签名完全一致而且DelegateManager的注册要在创建委托之前完成。注册可以集中放在一个初始化方法里方便管理不要散落在各处。3. 核心交互主工程与热更代码互相调用3.1 主工程调用热更代码反射、MethodInfo、委托三选一上一篇的 HelloWorld 可能只演示了Invoke但实际项目里你会发现调用方式选不好后面会很被动。我自己的经验是分场景处理一次性入口调用比如游戏启动后调用热更的App.Main()用Invoke就够了简单直接。低频业务调用比如点击按钮、任务结算、打开某个 UI使用缓存好的MethodInfo避免每次都查类型。高频生命周期调用比如Update、OnGUI、战斗驱动逻辑必须用强类型委托否则性能和 GC 压力都不好看。如果热更方法内部本身逻辑很重比如几十毫秒的计算那调用方式的差别可以忽略。但如果热更方法是个每秒调用几十次的空壳反射调用的开销就会暴露出来。先想清楚热更代码所在的分层再去选择调用方式。3.2 热更代码调用主工程方法委托注入比直接引用更省心反方向的问题更常见热更代码想打日志、想播放音效、想加载资源怎么调用主工程的方法最直接的做法是热更 DLL 引用主工程程序集然后直接调用某个静态类。但这么做会让热更工程与主工程强耦合之后每改一次主工程接口就要同步重编热更 DLL。我在实际项目里更喜欢用“委托注入”的方式把主工程能力通过静态委托暴露给热更。先在热更工程里定义一个桥接静态类namespace HotFix { public static class Bridge { public static Actionstring DebugLog; public static Funcstring, UnityEngine.Object LoadAsset; public static Actionstring TrackEvent; } }主工程在加载完 DLL、初始化热更入口之前把实现注入进去var bridgeType ILRuntimeManager.Domain.GetType(HotFix.Bridge); bridgeType.GetField(DebugLog).SetValue(null, new Actionstring(Debug.Log)); bridgeType.GetField(TrackEvent).SetValue(null, new Actionstring(Tracking.TrackEvent));这样热更代码里只需要Bridge.DebugLog(进入活动界面)完全不知道主工程是怎么实现的。以后主工程改了实现细节只要签名不变热更 DLL 一行代码都不用改。而且这也有利于测试你可以在热更侧跑单元测试时把Bridge.DebugLog注入成另一个输出源。委托注入唯一要留意的是时机必须在调用任何热更逻辑之前完成注入否则热更代码里第一次调用Bridge.DebugLog就会抛NullReferenceException。建议在ILRuntimeManager加载完 Assembly 后立刻调用一个InjectBridge()方法然后才执行App.Main。3.3 跨域继承 MonsterBehaviour为什么不能直接挂组件这个坑几乎是每个 ILRuntime 新手都会踩的。热更工程里写了一个类想继承UnityEngine.MonoBehaviour然后AddComponent到 GameObject 上Unity 直接报错或者组件变成 null。原因很简单MonoBehaviour 这类原生 UnityEngine 类型的生命周期由引擎的 C 侧管理ILRuntime 解释器生成的类实例无法直接作为原生组件挂载。解决思路也很明确不要试图在热更里继承 MonoBehaviour而是在主工程准备一个通用的桥接组件。例如public class HotFixBridgeBehaviour : MonoBehaviour { public string HotClassName; private object _hotInstance; private MethodInfo _onUpdateMethod; private void Awake() { var type ILRuntimeManager.Domain.GetType(HotClassName); _hotInstance ILRuntimeManager.Domain.Instantiate(HotClassName); type.GetMethod(OnAwake).Invoke(_hotInstance, new object[] { gameObject }); _onUpdateMethod type.GetMethod(OnUpdate); } private void Update() { _onUpdateMethod.Invoke(_hotInstance, new object[] { Time.deltaTime }); } }主工程做 MonoBehaviour 的载体热更类只负责逻辑。复杂一点的方案是给 ILRuntime 写CrossBindingAdaptor实现真正的跨域继承比如热更类继承主工程的一个抽象类。但我个人认为对于一个新手项目来说桥接组件的成本远低于适配器维护起来也简单。等你真正需要热更代码去多态实现主工程接口时再研究适配器也不迟。4. 调试与错误排查跑通之后最容易踩的坑4.1 调试信息从哪来PDB 和异常堆栈ILRuntime 代码在编辑器里无法直接打断点Debug.Log也只能打部分信息。这不代表完全没法定位问题。你只要在加载时传入了 PDB 流ILRuntime 抛出的异常一般都会包含热更侧代码的文件名和行号。如果发现异常只有方法名没有行号大概率是 PDB 没传对。检查一下HotFixProject编译时有没有生成.pdb文件以及LoadAssembly是不是真的传了 PDB 流。小技巧在主工程里写一个统一的异常捕获try { ILRuntimeManager.Domain.Invoke(HotFix.App, Main, null, null); } catch (Exception e) { UnityEngine.Debug.LogError($热更调用异常{e}); }注意e.StackTrace里可能同时包含主工程和 ILRuntime 侧的两段堆栈。ILRuntime 侧的堆栈格式会包含at HotFix.xxx.xxx这样的段落里面往往有IL行号信息。如果你看到的是原生 CLR 堆栈那说明异常其实发生在主工程侧。4.2 常见异常速查表下面这个表格是我在项目里整理出来的遇到异常先对号入座。异常信息常见原因解决办法FileNotFoundException依赖的程序集没加载或者 DLL 缺失检查 StreamingAssets 路径热更 DLL 引用到的主工程程序集是否已加载TypeLoadException类型不存在类型名拼错或命名空间不对用Domain.LoadedTypes.Keys打印所有已加载类型对比名称ArgumentException: 参数不匹配Invoke传参时类型不严格匹配比如把int传成Int64确认方法签名尽量使用强类型委托NotSupportedException: 不支持该类跨域继承类没注册适配器例如热更中直接继承 MonoBehaviour改用桥接组件或写 CrossBindingAdaptorNullReferenceException静态委托未注入或者热更对象实例未创建检查 Bridge 注入时机和顺序MethodAccessException调用了热更类型里私有/内部方法将方法改成 public或者改用公开入口方法4.3 拿不到堆栈时的排查思路有些情况下ILRuntime 的异常信息非常模糊尤其是跨域调用层层嵌套的时候。我遇到过一次热更侧写了个数组越界报错信息却指向了一个主工程委托调用。那时候断点没得打只能靠二分打日志。我的习惯是在主工程与热更的每个关键桥接点加一行日志不管发布时怎么裁剪开发期先把这些日志保留public static bool CrossDebugMode true; public static void CrossLog(string msg) { if (!CrossDebugMode) return; UnityEngine.Debug.Log($[Cross][{DateTime.Now:HH:mm:ss.fff}] {msg}); }然后在热更侧入口、退出点、异常捕获处都调用。这样一旦出问题翻日志基本能锁定是哪个环节崩了。比用调试器省心。5. 性能与内存热更代码到底慢不慢5.1 实测下来差多少ILRuntime 是解释执行性能肯定不能和原生编译的 C# 比。网上有各种 Benchmark数字差异很大但总体来说一次热更方法调用相比原生方法调用大概有几倍到十几倍的开销。不过这只是“单次方法调用”的开销对于游戏逻辑里的事件触发、状态切换、UI 刷新这种频率完全感知不到。真正需要担心的是在 Update 里做高频轮询比如角色 AI 状态机每帧要跑好几百次跨域调用帧率能明显掉。遇到这种情况一般有两种解法一是把跨域调用从每帧改成增量触发比如缓存上一次的时间超过阈值才唤醒二是把高频逻辑放进主工程低频业务逻辑放热更。5.2 性能优化三板斧缓存、委托、少跨域第一个是缓存。不要每次访问类型和方法都走反射已经讲过了。第二个是委托。高频方法用DelegateManager注册成强类型委托而不是每次MethodInfo.Invoke。第三个是“批量进入热更”。意思是把原来碎片的多次跨域调用合并成一次大调用。比如你原本每帧调用热更的UpdatePlayer()、UpdateEnemy()、UpdateUI()改成每帧只调用热更的UpdateFrame(float dt)内部再去拆解。这样把多次上下文切换变成一次性能能好不少。代价是热更侧承担更多职责所以要在设计时权衡。5.3 内存管理静态引用是最大的隐患ILRuntime 加载的热更类实例也是普通托管对象会被 Unity 的 GC 回收。但热更侧的静态字段以及通过委托注入的静态引用生命周期会非常长。如果热更代码不小心把某个主工程的大对象缓存到静态字段里那就形成了强引用垃圾回收永远清不掉。我见过一个案例热更侧为了缓存资源写了一个静态字典存了所有下载下来的 Texture结果游戏一局下来内存涨了几百兆因为被热更字典持有主工程无法卸载。写热更代码时同样要遵守“不持有不需要的引用”原则所有跟资源加载有关的缓存要么由主工程统一管理要么在更换场景时主动清理。如果要做“热更重置”比如玩家从登录界面重新进游戏不要试图在一个已经加载了热更 DLL 的 AppDomain 里卸载静态变量。最稳妥的办法是重建 AppDomain重新加载所有程序集。只不过重建时要小心旧的委托和缓存方法都不再有效这点要在设计 ILRuntimeManager 时预留一个ReloadDomain()接口。6. 热更流程落地一个能用于生产的更新方案6.1 DLL 的打包与传输开发环境下DLL 放到 StreamingAssets 是最简单的。但生产环境不能这么做因为 StreamingAssets 的内容打进包内后是只读的无法被替换。最常见的方案是把 DLL 和 PDB开发包才带 PDB打进 AssetBundle 放到 CDN客户端启动时下载并校验。详细流程大概是这样启动时先读本地的version.json拿到当前版本号。请求 CDN 上的最新version.json对比版本号。如果版本不一致下载对应的HotFix_xxx.bytes到Application.persistentDataPath。对下载文件做 MD5 校验确保下载没被篡改或者断点续传出错。校验通过后把旧 DLL 备份成.bak再把新文件替换过去写回新的version.json。下一次ILRuntimeManager初始化时优先加载持久化目录里的新 DLL没有才回退到 StreamingAssets 里的内置版本。这里有一个关键点不要把 DLL 直接写进StreamingAssets因为不同平台对只读目录的限制不一样。统一放到persistentDataPath这块大家都能读能写。6.2 加载失败时要能回滚网络下载这种东西最怕的是文件不完整或者写入了一半。你在下载过程中如果崩溃了本地 DLL 可能是坏的下一次启动直接加载失败玩家卡在启动页这就是事故。解决方案是分两步写入先写.tmp文件等完整的 MD5 校验通过后再改名成正式文件名。同时保留一份上一次可以正常运行的 DLL 作为回滚点persistentDataPath/ HotFix/ HotFix.dll # 当前使用的 HotFix.bak # 上一版本 version.json主工程加载时如果发现当前 DLL 加载失败不要直接报错退出尝试加载.bak文件并通知热更侧进入“降级模式”。很多公司上线 ILRuntime 后第一时间出事故都是因为加载失败没有兜底。6.3 热更代码的版本兼容与数据迁移DLL 本身能热更了还有一个容易被忽略的问题旧版本的数据。比如玩家本地用老版本生成了一份存档新版本热更代码读取存档时发现字段对不上直接抛异常。最常见的是PlayerPrefs里存了int新版本想读成float或者新增了一个SaveData字段但没给默认值。我建议在热更入口统一加一个版本检查把数据迁移也做成可热更的。在App.Main里先调用UpgradeManager.Check(),内部根据上一次保存的本地dataVersion执行对应版本的迁移函数public static class UpgradeManager { public static void Check() { var savedVersion PlayerPrefs.GetInt(local_data_version, 0); if (savedVersion 2) { // 迁移到版本2 PlayerPrefs.SetString(hero_id, PlayerPrefs.GetInt(hero_id).ToString()); } if (savedVersion 3) { // 迁移到版本3 } PlayerPrefs.SetInt(local_data_version, 3); PlayerPrefs.Save(); } }这样每次发新 DLL 时带上对应的数据迁移逻辑玩家从任意旧版本升级过来都能平滑过渡不用每次都在主工程里写死迁移代码。如果你正在评估项目里哪些模块适合热更我个人的建议是先把活动逻辑、任务系统、玩法开关这类“规则频繁变化”的部分丢到热更里试水核心战斗、渲染相关、性能敏感的AI保持原生。ILRuntime 确实能带来极高的发版灵活性但它不是银弹工程上的约束、调试上的成本、性能上的损耗都要提前想明白。第一版宁可少热更也要先把热更链路跑稳后面再一步步扩大范围。
阅读完成 · 觉得有帮助?