做到系列第二篇,很多人的状态是:ILRuntime 装齐了,AppDomain 能初始化了,热更 DLL 里那句 Hello 也能打印出来了。但紧接着就会遇到一个很尴尬的问题——我确实“调用到了”热更方法,可一旦想让热更代码像普通 MonoBehaviour 那样感知场景的 Start、Update、OnDestroy,就开始各种报错;委托回调传不过去、Unity API 调用时好时坏、类实例不知道该怎么创建。如果你正在这个阶段,那这一篇正是你需要的。这一篇里,我不打算再讲一遍怎么安装库,而是把“从零开始 HelloWorld”真正延伸成一条可持续用的通路:从热更工程的项目结构说起,到委托注册、CLR 绑定生成、Unity 生命周期打通、实例创建,最后再给一套可以直接照做的排查思路。适用对象是已经跑通过第一版 HelloWorld、现在想把热更代码接到真实游戏框架里的 Unity 开发者。1. 为什么说第一阶段的 HelloWorld 只是“程序能跑”,而不是“热更通了”第一版 HelloWorld 的完整链路,通常是这样的:主工程初始化了一个ILRuntime.Runtime.Enviorment.AppDomain,加载了热更 DLL,再用 Invoke 之类的入口调了一个静态方法,控制台输出了字符串。这条链路当然没错,但它只证明了“ILRuntime 这个运行时能执行热更代码”,并没有证明“你的游戏架构能接受热更代码”。“热更通了”的判定标准,我认为应该是:我不用重新启动 Unity,不用重新构建主程序,只替换热更 DLL,运行中的程序在下一次执行时就走到了新逻辑。要做到这个程度,你至少要打通这么几层:主域初始化后能稳定加载热更程序集;热更侧需要用到的委托签名,主域里必须预先注册;Unity 的生命周期事件,要能按帧转发到热更侧;热更侧创建的对象,要在主域和热更域之间保持类型的一致;异常和堆栈信息要能跨域传递,否则出了问题根本没法排查。很多初学者卡在第二步和第三步,是因为没有理解“域”的概念。Unity 本身跑着一个运行时,我们叫它主域;ILRuntime 内部还维护了一套自己的类型系统,我们叫它热更域。两个域里的类型,即使名字一模一样,在运行时也是两个东西。比如主域里有一个UnityEngine.GameObject,热更域里也有一个UnityEngine.GameObject,这两个并不是同一个“运行时身份”。要让它们互相通信,要么走委托注册,要么走 CLR 绑定,要么走跨域继承适配器,直接硬来是不行的。我见过不少朋友在主工程里直接写了这样的代码:从热更域取回一个对象,然后想把它as成主域里的某个类型。结果运行时要么给你null,要么直接抛InvalidCastException。原因就是上面说的“域”隔离。明白这一点以后,后面所有的操作就都有了解释:为什么委托要注册、为什么要写绑定代码、为什么要通过接口而不是具体类去桥接。一句话总结:第一阶段的 HelloWorld 是点亮了引擎,第二阶段的 HelloWorld 才是让引擎和车轮真正咬合上。2. 热更工程的组织方式:解决“主工程里能调用的热更代码在哪”的问题很多项目刚开始做热更时,图省事,把热更代码直接写在 Unity 工程的 Assets 目录下。这样当然能编译,但你会发现两个问题:第一,Unity 启动时会把所有 Assets 下的脚本打进同一个程序集,你的“热更代码”其实变成主工程的一部分了,改一行代码整个编辑器就要重新编译一次;第二,当你需要用 VS 或其他 IDE 单独编译一个独立 DLL 时,目录结构全是乱的。推荐的结构是主工程和热更工程物理分离,互不引用。这是我目前在项目里用得比较稳的一套目录:YourProject/ ├─ Assets/ │ ├─ Scripts/ │ │ ├─ GameMain/ // 主工程启动逻辑、生命周期驱动器 │ │ ├─ ILRuntime/ // ILRuntime 初始化封装、委托注册封装 │ │ └─ HotUpdateBridge/ // 主域与热更域之间的公共接口定义 │ └─ StreamingAssets/ │ ├─ HotUpdate.dll │ └─ HotUpdate.pdb └─ HotUpdate/ ├─ HotUpdate.sln └─ HotUpdate.Core/ ├─ Properties/ └─ Entry.cs主工程目录和热更工程目录完全分开,热更工程是一个独立的类库工程。为什么强调“独立”?因为这能强制你思考哪些代码是热更侧能碰的。热更侧引用的只能是 UnityEngine 里的 API、ILRuntime 运行时接口,以及你们自己定义的一小撮公共契约,而不是整个主工程。在创建热更工程的时候,目标框架建议选择与 Unity 主工程匹配的版本。Unity 2018 以后普遍支持 .NET 4.x,做一个 .NET Standard 2.0 的类库是兼容性最好的选择。用 .NET Framework 4.7.2 也不是不行,但打包到不同平台时偶尔会出现程序集加载差异。这里我建议直接按 .NET Standard 2.0 建,省心。热更工程里需要手动引用 Unity 的托管 DLL,路径一般在 Unity 安装目录下的Editor/Data/Managed/UnityEngine.dll。注意不要引用UnityEditor.dll,那玩意儿只在编辑器里存在,真机上一定会报错。引用添加好之后,热更代码里的UnityEngine.Debug.Log、GameObject这些就能正常编译了。编译产物要拷贝到StreamingAssets下,我一般不用手动复制,而是在热更工程的编译后事件里写一条批处理,DLL 和 PDB 一起拷过去:copy /Y "$(TargetDir)$(TargetName).dll" "$(SolutionDir)..\YourProject\Assets\StreamingAssets\HotUpdate.dll" copy /Y "$(TargetDir)$(TargetName).pdb" "$(SolutionDir)..\YourProject\Assets\StreamingAssets\HotUpdate.pdb"这里 PDB 一定要跟着 DLL 一起拷。Debug 符号文件在后续排查异常时价值极大,没有 PDB,ILRuntime 的堆栈信息基本就废了。正式包可以只带 DLL,但开发调试阶段一定要带上 PDB。还有一个小细节:加载程序集时不要一直持有文件流。如果启动器里写的是FileStream且不 dispose,Windows 会把 DLL 文件锁住,你编译热更工程时会提示文件被别的进程使用,非常难受。正确做法是读成字节数组再释放:byte[] dllBytes = File.ReadAllBytes(Path.Combine(Application.streamingAssetsPath, "HotUpdate.dll")); byte[] pdbBytes = File.ReadAllBytes(Path.Combine(Application.streamingAssetsPath, "HotUpdate.pdb"));这样做之后,StreamingAssets 下的 DLL 随时可以被替换,不会因为加载过一次就被锁定。3. 启动时要给 ILRuntime 补齐的“中间登记”:委托注册与 CLR 绑定生成ILRuntime 的设计里,主域和热更域之间不能隐式地传递任意委托。你在主工程里写一个Action<float>,想直接当成参数传给热更方法,它并不认识。为了防止这种不匹配,ILRuntime 提供了委托注册机制:主域初始化后,要把可能需要使用的委托签名告诉 AppDomain,相当于提前做一次“中间登记”。我自己封装的启动器是这个样子的:public static class ILRuntimeLauncher { private static ILRuntime.Runtime.Enviorment.AppDomain _domain; public static ILRuntime.Runtime.Enviorment.AppDomain Domain { get { if (_domain == null) throw new System.Exception("ILRuntimeLauncher 尚未初始化"); return _domain; } } public static void Init() { if (_domain != null) return; _domain = new ILRuntime.Runtime.Enviorment.AppDomain(); byte[] dllBytes = File.ReadAllBytes( Path.Combine(Application.streamingAssetsPath, "HotUpdate.dll")); byte[] pdbBytes = File.ReadAllBytes( Path.Combine(Application.streamingAssetsPath, "HotUpdate.pdb")); _domain.LoadAssembly(dllBytes, pdbBytes, new ILRuntime.Mono.Cecil.PdbReaderProvider()); RegisterDelegates(_domain); RegisterCLRBindings(_domain); } private static void RegisterDelegates(ILRuntime.Runtime.Enviorment.AppDomain domain) { domain.DelegateManager.RegisterMethodDelegate<System.Action>(); domain.DelegateManager.RegisterMethodDelegate<System.Action<float>>(); domain.DelegateManager.RegisterMethodDelegate<System.Action<string>>(); } private static void RegisterCLRBindings(ILRuntime.Runtime.Enviorment.AppDomain domain) { ILRuntime.Runtime.Enviorment.CLRBindings.Initialize(domain); } public static object Invoke(string typeName, string methodName, object instance, params object[] args) { return _domain.Invoke(typeName, methodName, instance, args); } }这里RegisterMethodDelegate<System.Action<float>>()就是在告诉 ILRuntime:热更侧如果把一个Action<float>类型的委托传回主域,请认这个签名。你没有注册,热更侧一旦尝试传递,大概率会报“找不到对应委托”或者直接抛出转型异常。CLR 绑定则是另一件事。ILRuntime 的好处是热更代码能直接调用 UnityEngine 的 API,但它内部是靠 CLR 反射把调用转回主域的。如果涉及带 ref/out 参数的方法、泛型方法、或者继承链特别深的 Unity API,反射调用速度慢且容易出各种边界问题。为了解决这个,ILRuntime 提供一个代码生成器,它会扫描热更代码对 Unity API 的使用情况,生成一批专门的固定调用代码,这部分代码叫 CLR 绑定。生成方式很简单:在 Unity 菜单栏找到 ILRuntime 相关的菜单项,选择“生成 CLR 绑定代码”,它会解析你热更 DLL 里引用到的 UnityEngine 类型,生成一堆 C# 文件到 Assets 下。第一次生成可能会编译比较久,这正常。生成完之后,我上面代码里的CLRBindings.Initialize(domain)就会把这些绑定注册进 AppDomain。建议一开始就养成这样的习惯:每次热更代码新增了对 Unity API 的调用,就重新生成一次 CLR 绑定。不要只在项目最后才做,否则到时候会有几百个编译错误等着你。这一章的内容就是整个 ILRuntime 启动层最繁琐的部分,但也是最有必要做好的部分。委托注册表相当于主域和热更域之间的“翻译官花名册”,CLR 绑定相当于“高速通道”,两者缺一个,后续跑起来都会很别扭。4. 实例调用与 Unity 生命周期打通:让热更类每帧感知 DeltaTime很多教程讲到这里就停了,只告诉你 Invoke 一个静态方法就算完。但真实游戏里,你更想看到的是一个热更类能像 MonoBehaviour 一样被驱动起来:Start 执行一次,Update 每帧执行一次。我这里给你一种最直白的打通方式,它能跑通,而且容易理解。4.1 最小驱动循环:主工程转发生命周期主工程里写一个驱动器脚本,挂在场景中的任意物体上:public class HotUpdateDriver : MonoBehaviour { private void Start() { ILRuntimeLauncher.Init(); ILRuntimeLauncher.Invoke("HotUpdate.Entry", "OnStart", null, null); } private void Update() { float dt = Time.deltaTime; ILRuntimeLauncher.Invoke("HotUpdate.Entry", "OnUpdate", null, new object[] { dt }); } }热更侧对应这样写:public static class Entry { private static double _accumTime; public static void OnStart() { UnityEngine.Debug.Log("[Hot] OnStart invoked"); } public static void OnUpdate(float deltaTime) { _accumTime += deltaTime; if (_accumTime >= 1.0) { UnityEngine.Debug.Log($"[Hot] 已运行 {_accumTime:F2} 秒"); _accumTime = 0.0; } } }跑起来之后,每帧都会调用一次热更侧的OnUpdate,这个例子虽然简单,但至少证明 Unity 生命周期已经成功接到热更代码这边了。你会看到控制台每一秒左右打印一条日志,说明热更侧的静态状态也在随着帧率累加。4.2 从静态调用升级为实例调用到了正式项目里,你不会想让所有游戏逻辑全堆在静态方法里。更合理的方式是:在主工程里创建一个热更对象实例,把它存起来,然后按帧调用实例上的方法。ILRuntime 的 AppDomain 提供了Instantiate方法,可以直接按类型名创建热更类型实例:object hotLogic = ILRuntimeLauncher.Domain.Instantiate("HotUpdate.GameLogic"); ILRuntimeLauncher.Invoke("HotUpdate.GameLogic", "Init", hotLogic, null);热更侧GameLogic可以是一个普通类:public class GameLogic { private int _count; public void Init() { UnityEngine.Debug.Log("[Hot] GameLogic.Init"); } public void Tick(float deltaTime) { _count++; if (_count % 60 == 0) UnityEngine.Debug.Log($"[Hot] Tick count = {_count}"); } }主工程里保存这个hotLogic对象,之后每次调用都把它作为instance参数传入。这里要注意的是,这个对象在热更域里的类型是ILRuntime.Runtime.Intepreter.ILTypeInstance,你不要试图把它转回主域里的自定义类型,因为没有这种对应关系。你只需要把它当作一个“不透明句柄”,每次 Invoke 时传回去就行。这种思路比一上来就搞“跨域继承适配器”要容易理解得多。HelloWorld 阶段,我强烈建议先用这种“实例句柄 + Invoke 转发”的方式跑通自己的架构。4.3 热更侧回调主域:委托方向的真正难点生命周期打通只是单向的:主域调用热更。但游戏里经常需要反向调用,比如热更代码里某个异步任务完成了,要通知主域的 UI 层刷新。这个方向要比正向调用复杂一点。我推荐的做法是:在主域定义一个静态类作为事件中枢,把回调委托注册给热更侧。主域侧:public static class HotEventCenter { public static System.Action<string> OnMessage; public static void DispatchMessage(string msg) { if (OnMessage != null) OnMessage(msg); } }启动时,把主域里的委托传给热更侧:var callback = new System.Action<string>(HotEventCenter.DispatchMessage); ILRuntimeLauncher.Domain.DelegateManager.RegisterMethodDelegate<System.Action<string>>(); ILRuntimeLauncher.Invoke("HotUpdate.Entry", "SetMainCallback", null, new object[] { callback });热更侧:private static System.Action<string> _mainCallback; public static void SetMainCallback(System.Action<string> callback) { _mainCallback = callback; } public static void NotifyMain() { if (_mainCallback != null) _mainCallback("hot update message"); }这里能看到委托注册的意义了吧:callback是从主域穿越到热更域的,如果没有前面的RegisterMethodDelegate<Action<string>>(),这个穿越过程大概率会卡住。凡是这种跨域委托传递,其实都需要一个落地方案,要么在 DelegateManager 注册,要么走 CLR 绑定,总之不能指望运行时自动猜出你的意图。5. 调试篇:PDB 路径、平台差异和几个我踩过的高频异常HelloWorld 能跑通之后,下一步必定是忘乎所以地开始写复杂逻辑,然后被调试折腾到怀疑人生。ILRuntime 的调试体验肯定不如纯 Unity 工程,但它也不是黑盒。掌握下面几个调试要点,能省下大量时间。5.1 符号文件加载:PDB 的作用我在第二章反复强调带 PDB,这里说为什么。ILRuntime 在执行热更方法时,如果有 PDB 原始信息,抛出的异常里就能看到热更代码里的行号,甚至能定位到具体是哪个文件哪一行出错。没有 PDB,你只能看到一大串at ILRuntime.Runtime.Intepreter.ILIntepreter...这类内部调用栈,很难判断问题出在哪一段热更代码。加载 PDB 的方式就是启动器里写的重载:_domain.LoadAssembly(dllBytes, pdbBytes, new ILRuntime.Mono.Cecil.PdbReaderProvider());这里面有个坑:不同版本的 ILRuntime 对 PDB 加载接口可能微调过。老版本可能不接收ISymbolReader参数,新版本可能要求传流。如果你的接口名对不上,优先查看你用的 ILRuntime 包里的AppDomain.LoadAssembly重载,按实际签名调整。5.2 编辑器与真机的路径差异编辑器环境下,Application.streamingAssetsPath直接指向工程目录,用File.ReadAllBytes没问题。但在 Android 真机上,StreamingAssets 下的文件是被打进 APK 的压缩包里的,不能直接按文件路径读取,必须先通过UnityWebRequest或自定义的下载逻辑把 DLL 拷贝到可写目录,再加载。如果你现在只是在编辑器里做 HelloWorld,先不用考虑这个,但写启动器时最好给文件读取加一层抽象,不要在编写热更代码时才发现启动器没法在真机上跑。5.3 高频异常对照表我把初学者最容易碰到的几个异常整理成一张表,方便你按现象快速定位:异常现象常见原因处理方向加载程序集时报FileNotFoundExceptionPDB 与 DLL 不同名、不同路径,或加载顺序反了确认 StreamingAssets 下 DLL/PDB 都在,且路径正确BadImageFormatException热更工程目标框架 / 平台配置与主工程不匹配将热更工程改为 .NET Standard 2.0,重新编译调用热更方法时报“委托绑定失败”参数里的跨域委托没有在启动器里注册检查RegisterMethodDelegate是否覆盖了对应签名热更侧调用 Unity API 时报InvalidCastException缺少 CLR 绑定,或把主域对象直接强转成热更类型重新生成 CLR 绑定,检查类型是否走接口桥接热更新方法中的异常堆栈只有内部调用PDB 未加载或热更工程未生成 Debug 符号编译热更工程时勾选生成 PDB,并在启动器加载 PDB另外提醒一句:热更侧抛出的异常,在主域里捕获时往往被包了一层ReflectionInvocationException之类的外壳。你第一时间看到的并不是热更代码里的原始异常,而是外壳。这时候尽量不要只打e.Message,要把innerException连同堆栈一起打出来,才能看到真正信息。6. 要验证热更真正“热”起来,自己设计三组测试一路写到这里,你的 HelloWorld 第二版应该已经从“一次调用”变成了“一条通路”。但通路是不是真的稳固,我建议不要靠“看着像通了”来判断,而是主动做下面三组验证。第一组,逻辑热替换验证。把你热更代码里的每秒打印间隔从 1.0 秒改成 0.3 秒,然后重新编译热更工程,编译完把新的 HotUpdate.dll 复制到 StreamingAssets 下。不要重新启动 Unity,直接再次进入 Play 模式,观察热更侧打印节奏是否变成了 0.3 秒一次,以及旧场景里残留的热更对象是否全部重新走了一遍OnStart。这一组验证的是“替换 DLL 后主工程无需重编”的核心目标。第二组,回滚验证。把旧的 HotUpdate.dll 重新覆盖回去,再跑一次,确认热更逻辑回到了旧版本。这组验证看似无聊,但它暴露的问题很实际:如果热更逻辑在初始化时往某个地方写了持久化数据,很容易出现新旧版本互相污染的现象。遇到这种情况,需要检查你的热更状态管理是不是太粗暴了。第三组,异常隔离验证。在热更代码里故意写一个throw new System.Exception("test error");,然后观察主工程在调用热更方法时会不会崩溃,以及日志是否能完整把热更异常和内置异常链路打出来。ILRuntime 的优势之一就是热更崩溃不至于像原生代码一样直接带崩进程,但这个优势需要你在启动器里做好异常捕获和日志输出才能体现出来。这三组测试做完,你对当前这套热更方案的边界就心里有数了。哪些地方是稳定的,哪些地方只是勉强能用,会比任何人告诉你的都准确。最后分享一下我个人的感觉:ILRuntime 的学习曲线其实不在于理解运行时怎么把 IL 翻译成指令,而在于适应“两个域”的思维方式。一旦你想清楚哪些东西属于主域、哪些属于热更域、哪些必须通过委托和绑定来跨越,后面的路就顺了。下一篇我打算接着写得再深入一点,把跨域继承适配器和 CLR 绑定生成的裁剪策略讲透,毕竟这两块才是热更落地时真正容易踩坑的深水区。
做到系列第二篇,很多人的状态是:ILRuntime 装齐了,AppDomain 能初始化了,热更 DLL 里那句 Hello 也能打印出来了。但紧接着就会遇到一个很尴尬的问题——我确实“调用到了”热更方法,可一旦想让热更代码像普通 MonoBehaviour 那样感知场景的 Start、Update、OnDestroy,就开始各种报错;委托回调传不过去、Unity API 调用时好时坏、类实例不知道该怎么创建。如果你正在这个阶段,那这一篇正是你需要的。这一篇里,我不打算再讲一遍怎么安装库,而是把“从零开始 HelloWorld”真正延伸成一条可持续用的通路:从热更工程的项目结构说起,到委托注册、CLR 绑定生成、Unity 生命周期打通、实例创建,最后再给一套可以直接照做的排查思路。适用对象是已经跑通过第一版 HelloWorld、现在想把热更代码接到真实游戏框架里的 Unity 开发者。1. 为什么说第一阶段的 HelloWorld 只是“程序能跑”,而不是“热更通了”第一版 HelloWorld 的完整链路,通常是这样的:主工程初始化了一个ILRuntime.Runtime.Enviorment.AppDomain,加载了热更 DLL,再用 Invoke 之类的入口调了一个静态方法,控制台输出了字符串。这条链路当然没错,但它只证明了“ILRuntime 这个运行时能执行热更代码”,并没有证明“你的游戏架构能接受热更代码”。“热更通了”的判定标准,我认为应该是:我不用重新启动 Unity,不用重新构建主程序,只替换热更 DLL,运行中的程序在下一次执行时就走到了新逻辑。要做到这个程度,你至少要打通这么几层:主域初始化后能稳定加载热更程序集;热更侧需要用到的委托签名,主域里必须预先注册;Unity 的生命周期事件,要能按帧转发到热更侧;热更侧创建的对象,要在主域和热更域之间保持类型的一致;异常和堆栈信息要能跨域传递,否则出了问题根本没法排查。很多初学者卡在第二步和第三步,是因为没有理解“域”的概念。Unity 本身跑着一个运行时,我们叫它主域;ILRuntime 内部还维护了一套自己的类型系统,我们叫它热更域。两个域里的类型,即使名字一模一样,在运行时也是两个东西。比如主域里有一个UnityEngine.GameObject,热更域里也有一个UnityEngine.GameObject,这两个并不是同一个“运行时身份”。要让它们互相通信,要么走委托注册,要么走 CLR 绑定,要么走跨域继承适配器,直接硬来是不行的。我见过不少朋友在主工程里直接写了这样的代码:从热更域取回一个对象,然后想把它as成主域里的某个类型。结果运行时要么给你null,要么直接抛InvalidCastException。原因就是上面说的“域”隔离。明白这一点以后,后面所有的操作就都有了解释:为什么委托要注册、为什么要写绑定代码、为什么要通过接口而不是具体类去桥接。一句话总结:第一阶段的 HelloWorld 是点亮了引擎,第二阶段的 HelloWorld 才是让引擎和车轮真正咬合上。2. 热更工程的组织方式:解决“主工程里能调用的热更代码在哪”的问题很多项目刚开始做热更时,图省事,把热更代码直接写在 Unity 工程的 Assets 目录下。这样当然能编译,但你会发现两个问题:第一,Unity 启动时会把所有 Assets 下的脚本打进同一个程序集,你的“热更代码”其实变成主工程的一部分了,改一行代码整个编辑器就要重新编译一次;第二,当你需要用 VS 或其他 IDE 单独编译一个独立 DLL 时,目录结构全是乱的。推荐的结构是主工程和热更工程物理分离,互不引用。这是我目前在项目里用得比较稳的一套目录:YourProject/ ├─ Assets/ │ ├─ Scripts/ │ │ ├─ GameMain/ // 主工程启动逻辑、生命周期驱动器 │ │ ├─ ILRuntime/ // ILRuntime 初始化封装、委托注册封装 │ │ └─ HotUpdateBridge/ // 主域与热更域之间的公共接口定义 │ └─ StreamingAssets/ │ ├─ HotUpdate.dll │ └─ HotUpdate.pdb └─ HotUpdate/ ├─ HotUpdate.sln └─ HotUpdate.Core/ ├─ Properties/ └─ Entry.cs主工程目录和热更工程目录完全分开,热更工程是一个独立的类库工程。为什么强调“独立”?因为这能强制你思考哪些代码是热更侧能碰的。热更侧引用的只能是 UnityEngine 里的 API、ILRuntime 运行时接口,以及你们自己定义的一小撮公共契约,而不是整个主工程。在创建热更工程的时候,目标框架建议选择与 Unity 主工程匹配的版本。Unity 2018 以后普遍支持 .NET 4.x,做一个 .NET Standard 2.0 的类库是兼容性最好的选择。用 .NET Framework 4.7.2 也不是不行,但打包到不同平台时偶尔会出现程序集加载差异。这里我建议直接按 .NET Standard 2.0 建,省心。热更工程里需要手动引用 Unity 的托管 DLL,路径一般在 Unity 安装目录下的Editor/Data/Managed/UnityEngine.dll。注意不要引用UnityEditor.dll,那玩意儿只在编辑器里存在,真机上一定会报错。引用添加好之后,热更代码里的UnityEngine.Debug.Log、GameObject这些就能正常编译了。编译产物要拷贝到StreamingAssets下,我一般不用手动复制,而是在热更工程的编译后事件里写一条批处理,DLL 和 PDB 一起拷过去:copy /Y "$(TargetDir)$(TargetName).dll" "$(SolutionDir)..\YourProject\Assets\StreamingAssets\HotUpdate.dll" copy /Y "$(TargetDir)$(TargetName).pdb" "$(SolutionDir)..\YourProject\Assets\StreamingAssets\HotUpdate.pdb"这里 PDB 一定要跟着 DLL 一起拷。Debug 符号文件在后续排查异常时价值极大,没有 PDB,ILRuntime 的堆栈信息基本就废了。正式包可以只带 DLL,但开发调试阶段一定要带上 PDB。还有一个小细节:加载程序集时不要一直持有文件流。如果启动器里写的是FileStream且不 dispose,Windows 会把 DLL 文件锁住,你编译热更工程时会提示文件被别的进程使用,非常难受。正确做法是读成字节数组再释放:byte[] dllBytes = File.ReadAllBytes(Path.Combine(Application.streamingAssetsPath, "HotUpdate.dll")); byte[] pdbBytes = File.ReadAllBytes(Path.Combine(Application.streamingAssetsPath, "HotUpdate.pdb"));这样做之后,StreamingAssets 下的 DLL 随时可以被替换,不会因为加载过一次就被锁定。3. 启动时要给 ILRuntime 补齐的“中间登记”:委托注册与 CLR 绑定生成ILRuntime 的设计里,主域和热更域之间不能隐式地传递任意委托。你在主工程里写一个Action<float>,想直接当成参数传给热更方法,它并不认识。为了防止这种不匹配,ILRuntime 提供了委托注册机制:主域初始化后,要把可能需要使用的委托签名告诉 AppDomain,相当于提前做一次“中间登记”。我自己封装的启动器是这个样子的:public static class ILRuntimeLauncher { private static ILRuntime.Runtime.Enviorment.AppDomain _domain; public static ILRuntime.Runtime.Enviorment.AppDomain Domain { get { if (_domain == null) throw new System.Exception("ILRuntimeLauncher 尚未初始化"); return _domain; } } public static void Init() { if (_domain != null) return; _domain = new ILRuntime.Runtime.Enviorment.AppDomain(); byte[] dllBytes = File.ReadAllBytes( Path.Combine(Application.streamingAssetsPath, "HotUpdate.dll")); byte[] pdbBytes = File.ReadAllBytes( Path.Combine(Application.streamingAssetsPath, "HotUpdate.pdb")); _domain.LoadAssembly(dllBytes, pdbBytes, new ILRuntime.Mono.Cecil.PdbReaderProvider()); RegisterDelegates(_domain); RegisterCLRBindings(_domain); } private static void RegisterDelegates(ILRuntime.Runtime.Enviorment.AppDomain domain) { domain.DelegateManager.RegisterMethodDelegate<System.Action>(); domain.DelegateManager.RegisterMethodDelegate<System.Action<float>>(); domain.DelegateManager.RegisterMethodDelegate<System.Action<string>>(); } private static void RegisterCLRBindings(ILRuntime.Runtime.Enviorment.AppDomain domain) { ILRuntime.Runtime.Enviorment.CLRBindings.Initialize(domain); } public static object Invoke(string typeName, string methodName, object instance, params object[] args) { return _domain.Invoke(typeName, methodName, instance, args); } }这里RegisterMethodDelegate<System.Action<float>>()就是在告诉 ILRuntime:热更侧如果把一个Action<float>类型的委托传回主域,请认这个签名。你没有注册,热更侧一旦尝试传递,大概率会报“找不到对应委托”或者直接抛出转型异常。CLR 绑定则是另一件事。ILRuntime 的好处是热更代码能直接调用 UnityEngine 的 API,但它内部是靠 CLR 反射把调用转回主域的。如果涉及带 ref/out 参数的方法、泛型方法、或者继承链特别深的 Unity API,反射调用速度慢且容易出各种边界问题。为了解决这个,ILRuntime 提供一个代码生成器,它会扫描热更代码对 Unity API 的使用情况,生成一批专门的固定调用代码,这部分代码叫 CLR 绑定。生成方式很简单:在 Unity 菜单栏找到 ILRuntime 相关的菜单项,选择“生成 CLR 绑定代码”,它会解析你热更 DLL 里引用到的 UnityEngine 类型,生成一堆 C# 文件到 Assets 下。第一次生成可能会编译比较久,这正常。生成完之后,我上面代码里的CLRBindings.Initialize(domain)就会把这些绑定注册进 AppDomain。建议一开始就养成这样的习惯:每次热更代码新增了对 Unity API 的调用,就重新生成一次 CLR 绑定。不要只在项目最后才做,否则到时候会有几百个编译错误等着你。这一章的内容就是整个 ILRuntime 启动层最繁琐的部分,但也是最有必要做好的部分。委托注册表相当于主域和热更域之间的“翻译官花名册”,CLR 绑定相当于“高速通道”,两者缺一个,后续跑起来都会很别扭。4. 实例调用与 Unity 生命周期打通:让热更类每帧感知 DeltaTime很多教程讲到这里就停了,只告诉你 Invoke 一个静态方法就算完。但真实游戏里,你更想看到的是一个热更类能像 MonoBehaviour 一样被驱动起来:Start 执行一次,Update 每帧执行一次。我这里给你一种最直白的打通方式,它能跑通,而且容易理解。4.1 最小驱动循环:主工程转发生命周期主工程里写一个驱动器脚本,挂在场景中的任意物体上:public class HotUpdateDriver : MonoBehaviour { private void Start() { ILRuntimeLauncher.Init(); ILRuntimeLauncher.Invoke("HotUpdate.Entry", "OnStart", null, null); } private void Update() { float dt = Time.deltaTime; ILRuntimeLauncher.Invoke("HotUpdate.Entry", "OnUpdate", null, new object[] { dt }); } }热更侧对应这样写:public static class Entry { private static double _accumTime; public static void OnStart() { UnityEngine.Debug.Log("[Hot] OnStart invoked"); } public static void OnUpdate(float deltaTime) { _accumTime += deltaTime; if (_accumTime >= 1.0) { UnityEngine.Debug.Log($"[Hot] 已运行 {_accumTime:F2} 秒"); _accumTime = 0.0; } } }跑起来之后,每帧都会调用一次热更侧的OnUpdate,这个例子虽然简单,但至少证明 Unity 生命周期已经成功接到热更代码这边了。你会看到控制台每一秒左右打印一条日志,说明热更侧的静态状态也在随着帧率累加。4.2 从静态调用升级为实例调用到了正式项目里,你不会想让所有游戏逻辑全堆在静态方法里。更合理的方式是:在主工程里创建一个热更对象实例,把它存起来,然后按帧调用实例上的方法。ILRuntime 的 AppDomain 提供了Instantiate方法,可以直接按类型名创建热更类型实例:object hotLogic = ILRuntimeLauncher.Domain.Instantiate("HotUpdate.GameLogic"); ILRuntimeLauncher.Invoke("HotUpdate.GameLogic", "Init", hotLogic, null);热更侧GameLogic可以是一个普通类:public class GameLogic { private int _count; public void Init() { UnityEngine.Debug.Log("[Hot] GameLogic.Init"); } public void Tick(float deltaTime) { _count++; if (_count % 60 == 0) UnityEngine.Debug.Log($"[Hot] Tick count = {_count}"); } }主工程里保存这个hotLogic对象,之后每次调用都把它作为instance参数传入。这里要注意的是,这个对象在热更域里的类型是ILRuntime.Runtime.Intepreter.ILTypeInstance,你不要试图把它转回主域里的自定义类型,因为没有这种对应关系。你只需要把它当作一个“不透明句柄”,每次 Invoke 时传回去就行。这种思路比一上来就搞“跨域继承适配器”要容易理解得多。HelloWorld 阶段,我强烈建议先用这种“实例句柄 + Invoke 转发”的方式跑通自己的架构。4.3 热更侧回调主域:委托方向的真正难点生命周期打通只是单向的:主域调用热更。但游戏里经常需要反向调用,比如热更代码里某个异步任务完成了,要通知主域的 UI 层刷新。这个方向要比正向调用复杂一点。我推荐的做法是:在主域定义一个静态类作为事件中枢,把回调委托注册给热更侧。主域侧:public static class HotEventCenter { public static System.Action<string> OnMessage; public static void DispatchMessage(string msg) { if (OnMessage != null) OnMessage(msg); } }启动时,把主域里的委托传给热更侧:var callback = new System.Action<string>(HotEventCenter.DispatchMessage); ILRuntimeLauncher.Domain.DelegateManager.RegisterMethodDelegate<System.Action<string>>(); ILRuntimeLauncher.Invoke("HotUpdate.Entry", "SetMainCallback", null, new object[] { callback });热更侧:private static System.Action<string> _mainCallback; public static void SetMainCallback(System.Action<string> callback) { _mainCallback = callback; } public static void NotifyMain() { if (_mainCallback != null) _mainCallback("hot update message"); }这里能看到委托注册的意义了吧:callback是从主域穿越到热更域的,如果没有前面的RegisterMethodDelegate<Action<string>>(),这个穿越过程大概率会卡住。凡是这种跨域委托传递,其实都需要一个落地方案,要么在 DelegateManager 注册,要么走 CLR 绑定,总之不能指望运行时自动猜出你的意图。5. 调试篇:PDB 路径、平台差异和几个我踩过的高频异常HelloWorld 能跑通之后,下一步必定是忘乎所以地开始写复杂逻辑,然后被调试折腾到怀疑人生。ILRuntime 的调试体验肯定不如纯 Unity 工程,但它也不是黑盒。掌握下面几个调试要点,能省下大量时间。5.1 符号文件加载:PDB 的作用我在第二章反复强调带 PDB,这里说为什么。ILRuntime 在执行热更方法时,如果有 PDB 原始信息,抛出的异常里就能看到热更代码里的行号,甚至能定位到具体是哪个文件哪一行出错。没有 PDB,你只能看到一大串at ILRuntime.Runtime.Intepreter.ILIntepreter...这类内部调用栈,很难判断问题出在哪一段热更代码。加载 PDB 的方式就是启动器里写的重载:_domain.LoadAssembly(dllBytes, pdbBytes, new ILRuntime.Mono.Cecil.PdbReaderProvider());这里面有个坑:不同版本的 ILRuntime 对 PDB 加载接口可能微调过。老版本可能不接收ISymbolReader参数,新版本可能要求传流。如果你的接口名对不上,优先查看你用的 ILRuntime 包里的AppDomain.LoadAssembly重载,按实际签名调整。5.2 编辑器与真机的路径差异编辑器环境下,Application.streamingAssetsPath直接指向工程目录,用File.ReadAllBytes没问题。但在 Android 真机上,StreamingAssets 下的文件是被打进 APK 的压缩包里的,不能直接按文件路径读取,必须先通过UnityWebRequest或自定义的下载逻辑把 DLL 拷贝到可写目录,再加载。如果你现在只是在编辑器里做 HelloWorld,先不用考虑这个,但写启动器时最好给文件读取加一层抽象,不要在编写热更代码时才发现启动器没法在真机上跑。5.3 高频异常对照表我把初学者最容易碰到的几个异常整理成一张表,方便你按现象快速定位:异常现象常见原因处理方向加载程序集时报FileNotFoundExceptionPDB 与 DLL 不同名、不同路径,或加载顺序反了确认 StreamingAssets 下 DLL/PDB 都在,且路径正确BadImageFormatException热更工程目标框架 / 平台配置与主工程不匹配将热更工程改为 .NET Standard 2.0,重新编译调用热更方法时报“委托绑定失败”参数里的跨域委托没有在启动器里注册检查RegisterMethodDelegate是否覆盖了对应签名热更侧调用 Unity API 时报InvalidCastException缺少 CLR 绑定,或把主域对象直接强转成热更类型重新生成 CLR 绑定,检查类型是否走接口桥接热更新方法中的异常堆栈只有内部调用PDB 未加载或热更工程未生成 Debug 符号编译热更工程时勾选生成 PDB,并在启动器加载 PDB另外提醒一句:热更侧抛出的异常,在主域里捕获时往往被包了一层ReflectionInvocationException之类的外壳。你第一时间看到的并不是热更代码里的原始异常,而是外壳。这时候尽量不要只打e.Message,要把innerException连同堆栈一起打出来,才能看到真正信息。6. 要验证热更真正“热”起来,自己设计三组测试一路写到这里,你的 HelloWorld 第二版应该已经从“一次调用”变成了“一条通路”。但通路是不是真的稳固,我建议不要靠“看着像通了”来判断,而是主动做下面三组验证。第一组,逻辑热替换验证。把你热更代码里的每秒打印间隔从 1.0 秒改成 0.3 秒,然后重新编译热更工程,编译完把新的 HotUpdate.dll 复制到 StreamingAssets 下。不要重新启动 Unity,直接再次进入 Play 模式,观察热更侧打印节奏是否变成了 0.3 秒一次,以及旧场景里残留的热更对象是否全部重新走了一遍OnStart。这一组验证的是“替换 DLL 后主工程无需重编”的核心目标。第二组,回滚验证。把旧的 HotUpdate.dll 重新覆盖回去,再跑一次,确认热更逻辑回到了旧版本。这组验证看似无聊,但它暴露的问题很实际:如果热更逻辑在初始化时往某个地方写了持久化数据,很容易出现新旧版本互相污染的现象。遇到这种情况,需要检查你的热更状态管理是不是太粗暴了。第三组,异常隔离验证。在热更代码里故意写一个throw new System.Exception("test error");,然后观察主工程在调用热更方法时会不会崩溃,以及日志是否能完整把热更异常和内置异常链路打出来。ILRuntime 的优势之一就是热更崩溃不至于像原生代码一样直接带崩进程,但这个优势需要你在启动器里做好异常捕获和日志输出才能体现出来。这三组测试做完,你对当前这套热更方案的边界就心里有数了。哪些地方是稳定的,哪些地方只是勉强能用,会比任何人告诉你的都准确。最后分享一下我个人的感觉:ILRuntime 的学习曲线其实不在于理解运行时怎么把 IL 翻译成指令,而在于适应“两个域”的思维方式。一旦你想清楚哪些东西属于主域、哪些属于热更域、哪些必须通过委托和绑定来跨越,后面的路就顺了。下一篇我打算接着写得再深入一点,把跨域继承适配器和 CLR 绑定生成的裁剪策略讲透,毕竟这两块才是热更落地时真正容易踩坑的深水区。