不少朋友在上篇里已经把 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:Configuration=Release /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加载完成后,主工程想调用热更代码,最直白的办法是反射查找类型并调用方法。比如热更工程里有一个静态类App:namespace 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.Instantiate:object 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.RegisterMethodDelegate<float>(); var type = ILRuntimeManager.Domain.GetType("HotFix.Game"); var method = type.GetMethod("OnUpdate"); var updateAction = method.CreateDelegate(typeof(Action<float>), null) as Action<float>;之后每次调用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 Action<string> DebugLog; public static Func<string, UnityEngine.Object> LoadAsset; public static Action<string> TrackEvent; } }主工程在加载完 DLL、初始化热更入口之前,把实现注入进去:var bridgeType = ILRuntimeManager.Domain.GetType("HotFix.Bridge"); bridgeType.GetField("DebugLog").SetValue(null, new Action<string>(Debug.Log)); bridgeType.GetField("TrackEvent").SetValue(null, new Action<string>(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 确实能带来极高的发版灵活性,但它不是银弹,工程上的约束、调试上的成本、性能上的损耗都要提前想明白。第一版宁可少热更,也要先把热更链路跑稳,后面再一步步扩大范围。
不少朋友在上篇里已经把 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:Configuration=Release /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加载完成后,主工程想调用热更代码,最直白的办法是反射查找类型并调用方法。比如热更工程里有一个静态类App:namespace 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.Instantiate:object 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.RegisterMethodDelegate<float>(); var type = ILRuntimeManager.Domain.GetType("HotFix.Game"); var method = type.GetMethod("OnUpdate"); var updateAction = method.CreateDelegate(typeof(Action<float>), null) as Action<float>;之后每次调用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 Action<string> DebugLog; public static Func<string, UnityEngine.Object> LoadAsset; public static Action<string> TrackEvent; } }主工程在加载完 DLL、初始化热更入口之前,把实现注入进去:var bridgeType = ILRuntimeManager.Domain.GetType("HotFix.Bridge"); bridgeType.GetField("DebugLog").SetValue(null, new Action<string>(Debug.Log)); bridgeType.GetField("TrackEvent").SetValue(null, new Action<string>(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 确实能带来极高的发版灵活性,但它不是银弹,工程上的约束、调试上的成本、性能上的损耗都要提前想明白。第一版宁可少热更,也要先把热更链路跑稳,后面再一步步扩大范围。