ILRuntime热更新继承机制详解:从CLR绑定到适配器实战
发布时间:2026/10/21 6:38:31 锦皓数字建站

1. 为什么继承在热更新里是个绕不开的坎如果你正在用 Unity 做手游或者需要频繁迭代的项目热更新基本是绕不开的一环。而提到 Unity 热更新方案ILRuntime 是很多团队会认真评估的一个选项。它允许你用 C# 写热更逻辑在运行时动态加载程序集省去了重新打包发版的麻烦。但真到动手接入的时候很多人会在“继承”这个看似基础的问题上卡住。我最初接触 ILRuntime 的时候觉得把热更代码写成一个独立程序集主工程引用一下就能跑起来能有多难结果第一个继承场景就翻车了热更程序集里定义了一个类继承主工程里的基类运行时报错说找不到类型或者方法调用直接崩掉。后来才明白ILRuntime 的运行机制决定了它和 Unity 原生的 Mono 运行时是两套东西跨程序集的继承需要额外的适配工作。这篇内容就是围绕“继承”这个主题把 ILRuntime 从零接入过程中关于继承的坑、原理和实操方案讲清楚。适合已经对 ILRuntime 有初步了解、正在做热更架构设计或者被继承问题卡住的开发者。我会从整体设计思路讲到具体代码实现再到常见问题排查尽量把每一步为什么这么做都说明白。2. ILRuntime 继承机制的整体设计与思路拆解2.1 热更程序集与主工程的边界到底在哪要理解继承为什么需要特殊处理得先搞清楚 ILRuntime 的基本运行模型。ILRuntime 本质上是一个用 C# 实现的 IL 解释器它加载热更 DLL 后在运行时解释执行里面的 IL 指令。主工程跑在 Unity 自带的 Mono 或 IL2CPP 运行时上热更程序集跑在 ILRuntime 的虚拟机里。这是两个独立的运行时环境。这就带来一个根本问题主工程里的类型和热更程序集里的类型在 CLR 层面并不是同一个东西。主工程里有一个BaseEntity类热更程序集里写一个PlayerEntity : BaseEntityILRuntime 在解释PlayerEntity的时候需要知道BaseEntity是什么、它的方法怎么调用、字段怎么访问。如果没有任何适配ILRuntime 的虚拟机根本找不到BaseEntity对应的运行时表示。所以整体设计的核心思路就是在 ILRuntime 虚拟机和主工程 CLR 之间建立一座桥。这座桥由两部分组成一是类型绑定让 ILRuntime 知道热更代码里引用的主工程类型对应到 CLR 里的哪个 Type二是适配器让跨运行时的继承、接口实现、方法重写能够正确工作。2.2 为什么选择 CLR 绑定加适配器这套方案ILRuntime 提供了几种跨域交互的方式常见的有 CLR 绑定CLR Bind和适配器Adapter。CLR 绑定解决的是“热更代码调用主工程代码”的问题比如热更里new一个主工程的类、调用主工程的方法。适配器解决的是“主工程调用热更代码”以及“热更代码继承主工程类型”的问题。对于继承场景两者都需要。假设热更程序集里有一个Enemy : MonoBehaviourMonoBehaviour 是主工程的类型。ILRuntime 需要知道 MonoBehaviour 对应的 CLR Type这是绑定要做的事。同时当 Unity 引擎回调Awake、Update这些方法时调用的是热更里重写的版本这需要适配器把 CLR 侧的调用转发到 ILRuntime 虚拟机里执行。选择这套方案的原因很直接它是 ILRuntime 官方推荐的标准做法社区资料最全踩坑后容易找到参考。另一个考量是性能CLR 绑定生成的是静态委托调用比反射快很多适配器虽然有一定开销但在继承场景下是必要的成本。如果试图绕过这套机制自己用反射硬搞代码会变得极其脆弱版本升级也容易出问题。2.3 继承场景的分类与对应策略实际项目里遇到的继承场景不是单一的我把它分成几类每类的处理策略有差异。第一类是热更类继承热更类。比如热更程序集里Warrior : Character两个类都在热更 DLL 里。这种情况最简单ILRuntime 虚拟机内部自己就能处理不需要额外适配。第二类是热更类继承主工程类。比如PlayerController : MonoBehaviour基类在主工程。这是最常见的场景需要 CLR 绑定基类同时可能需要适配器来处理虚方法重写和生命周期回调。第三类是主工程类继承热更类。这种比较少见通常出现在框架设计里比如主工程提供一个基类热更里定义具体实现然后主工程再派生。这种场景对适配器的要求更高因为主工程的类需要继承一个 ILRuntime 虚拟机里的类型CLR 层面是不允许直接继承一个动态类型的必须通过接口或者适配器模式来绕。第四类是接口实现。热更类实现主工程定义的接口或者主工程类实现热更定义的接口。接口的情况比类继承稍微简单一些因为不涉及基类构造和字段布局但同样需要绑定和适配。理解这些分类很重要因为不同场景下需要写的绑定代码和适配器代码是不一样的。很多人卡住就是因为没分清自己遇到的是哪一类用错了方案。3. 核心细节解析与实操要点3.1 CLR 绑定到底绑了什么CLR 绑定的本质是告诉 ILRuntime热更代码里引用的某个类型对应到主工程 CLR 里的哪个 Type以及这个类型上的方法、字段、属性怎么访问。ILRuntime 提供了一个自动生成绑定代码的工具在 Unity 菜单里可以找到它会扫描你指定的程序集生成一份绑定代码文件。这份生成的代码里核心是ILRuntime.Runtime.Enviorment.AppDomain的RegisterCrossBindingAdaptor和DelegateManager的注册。对于继承场景绑定代码会为基类的每个虚方法生成一个调用委托这样 ILRuntime 虚拟机在解释热更代码调用基类方法时能直接跳到 CLR 的实现。但自动生成的绑定代码有个问题它只绑定你指定的程序集里的类型。如果你热更代码里继承的基类在另一个程序集而那个程序集没有被扫描运行时就找不到。我踩过这个坑热更类继承了一个在第三方库里的基类忘了把那个库加进绑定列表结果报错信息很模糊排查了半天。注意绑定代码生成后需要手动挂到场景里或者通过代码注册很多人生成完就忘了注册这一步运行时报错说类型找不到。3.2 适配器在继承链中扮演什么角色适配器CrossBindingAdaptor是 ILRuntime 处理跨域继承的核心机制。当热更类继承主工程类时ILRuntime 不能直接让热更类的实例继承 CLR 的基类因为热更类的类型是动态生成的。适配器的做法是在 CLR 侧创建一个中间类这个中间类继承主工程的基类同时持有 ILRuntime 虚拟机里热更对象的引用。所有对基类虚方法的调用都通过这个中间类转发到虚拟机里执行。举个例子主工程有public class BasePanel热更里有public class LoginPanel : BasePanel。ILRuntime 会为 BasePanel 生成一个适配器类比如BasePanelAdapter : BasePanel。当主工程代码调用basePanel.OnOpen()时如果实际对象是热更的 LoginPanel调用会先进入 BasePanelAdapter.OnOpen()然后适配器通过 ILRuntime 的机制调用虚拟机里 LoginPanel 重写的 OnOpen。这个转发过程有性能开销但换来的是继承体系的正确性。适配器需要注册到 AppDomain 里注册的时机是在加载热更 DLL 之前。注册代码通常是appDomain.RegisterCrossBindingAdaptor(new BasePanelAdapter())。3.3 虚方法重写的绑定细节虚方法重写是继承里最容易出问题的地方。ILRuntime 需要知道热更类重写了基类的哪些虚方法才能正确建立调用链。自动生成的绑定代码会处理一部分但有些情况需要手动干预。比如基类里有一个protected virtual void OnInitialize()热更类重写了它。如果绑定代码没有正确生成这个方法的委托调用时就会走到基类的默认实现而不是热更里的重写版本。我遇到过这种情况表现是热更逻辑完全不执行但也不报错非常隐蔽。排查方法是检查生成的绑定代码里有没有对应方法的DelegateManager注册。如果没有需要手动在绑定代码里补上或者检查生成工具的配置是不是漏了那个方法。另一个常见问题是泛型虚方法ILRuntime 对泛型虚方法的重写支持有限某些复杂泛型场景需要改写设计来规避。3.4 构造函数的调用顺序陷阱继承体系里构造函数的调用顺序在跨运行时场景下会变得微妙。CLR 里派生类构造会先调用基类构造。但在 ILRuntime 里热更类的实例化过程是ILRuntime 虚拟机创建热更对象然后通过适配器关联到 CLR 侧的中间类实例。这个顺序如果理解错了在构造函数里访问基类字段就会拿到错误的值。我的经验是尽量不要在构造函数里做依赖基类初始化状态的事情。把初始化逻辑放到显式的 Init 方法里在对象完全构造完成后再调用。这样能避开大部分构造顺序带来的问题。如果确实需要在构造阶段传递数据通过构造参数传不要在构造里读基类的虚属性因为那时候虚方法表可能还没完全建立。4. 实操过程与核心环节实现4.1 环境准备与基础接入假设你已经有一个 Unity 工程现在要从零接入 ILRuntime 并跑通继承场景。第一步是获取 ILRuntime把它的运行时库放到工程里。通常是把 ILRuntime 的源码或者 DLL 放到 Plugins 目录下确保主工程能引用到。然后创建一个热更程序集。在 Unity 里新建一个文件夹比如叫 HotFix在里面创建 C# 脚本。关键一步是把这个程序集标记为热更程序集通常通过 asmdef 文件来管理或者在打包时单独编译成 DLL。我习惯用 asmdef这样 Unity 编译时能自动处理引用关系但要注意热更程序集不能直接引用主工程的程序集否则打包会出问题。接下来初始化 ILRuntime 的 AppDomain。在游戏启动的某个时机比如一个 Launch 脚本的 Awake 里创建 AppDomain 实例加载热更 DLL 的字节流然后调用appDomain.LoadAssembly。这一步完成后热更程序集里的类型就可以通过 AppDomain 来实例化了。using ILRuntime.Runtime.Enviorment; using System.IO; using UnityEngine; public class ILRuntimeLauncher : MonoBehaviour { private AppDomain appDomain; void Awake() { appDomain new AppDomain(); StartCoroutine(LoadHotFixAssembly()); } private System.Collections.IEnumerator LoadHotFixAssembly() { // 从 StreamingAssets 或 AB 包加载 DLL 字节 string dllPath Path.Combine(Application.streamingAssetsPath, HotFix.dll); byte[] dllBytes File.ReadAllBytes(dllPath); byte[] pdbBytes null; appDomain.LoadAssembly(dllBytes, pdbBytes, new ILRuntime.Mono.Cecil.Pdb.PdbReaderProvider()); // 注册绑定和适配器 RegisterBindings(); // 实例化热更里的入口类 var entryType appDomain.GetType(HotFix.GameEntry); var entryObj appDomain.Instantiate(HotFix.GameEntry); appDomain.Invoke(HotFix.GameEntry, Start, entryObj, null); yield return null; } private void RegisterBindings() { // 绑定代码注册 ILRuntime.Runtime.Generated.CLRBindings.Initialize(appDomain); // 适配器注册 appDomain.RegisterCrossBindingAdaptor(new BasePanelAdapter()); } }这段代码是接入的骨架。实际项目里 DLL 的加载方式可能更复杂比如从 AB 包加载、做加密解密但核心流程是一样的。4.2 编写继承场景的热更代码现在在热更程序集里写一个继承主工程类的例子。假设主工程有一个BasePanel类// 主工程代码 public class BasePanel { public virtual void OnOpen() { Debug.Log(BasePanel OnOpen); } public virtual void OnClose() { Debug.Log(BasePanel OnClose); } }热更程序集里写一个LoginPanel继承它// 热更程序集代码 namespace HotFix { public class LoginPanel : BasePanel { public override void OnOpen() { Debug.Log(LoginPanel OnOpen - 热更逻辑); // 可以调用基类实现 base.OnOpen(); } public override void OnClose() { Debug.Log(LoginPanel OnClose - 热更逻辑); } } }这段代码在普通 C# 工程里完全没问题但在 ILRuntime 里如果没有绑定和适配器运行时会报错。错误通常出现在base.OnOpen()这一行因为 ILRuntime 不知道 BasePanel 的 OnOpen 在 CLR 里怎么调用。4.3 生成绑定代码与适配器ILRuntime 提供了自动生成工具。在 Unity 菜单栏找到 ILRuntime 相关的菜单项选择生成绑定代码。工具会让你选择要绑定的程序集把主工程的程序集加进去。生成完成后会得到一个 CLRBindings.cs 文件里面包含了 BasePanel 的绑定代码。但自动生成的绑定代码不一定包含适配器。适配器需要手动写或者用工具生成后手动调整。BasePanel 的适配器大概长这样using ILRuntime.CLR.TypeSystem; using ILRuntime.CLR.Method; using ILRuntime.Runtime.Enviorment; using ILRuntime.Runtime.Intepreter; public class BasePanelAdapter : CrossBindingAdaptor { public override System.Type BaseCLRType typeof(BasePanel); public override System.Type AdaptorType typeof(Adaptor); public override object CreateCLRInstance(AppDomain appdomain, ILTypeInstance instance) { return new Adaptor(appdomain, instance); } public class Adaptor : BasePanel, CrossBindingAdaptorType { private ILTypeInstance instance; private AppDomain appdomain; private ILRuntime.Runtime.Enviorment.InvocationDelegate onOpenDelegate; private ILRuntime.Runtime.Enviorment.InvocationDelegate onCloseDelegate; public ILTypeInstance ILInstance instance; public Adaptor() { } public Adaptor(AppDomain appdomain, ILTypeInstance instance) { this.appdomain appdomain; this.instance instance; } public override void OnOpen() { if (onOpenDelegate null) { var method instance.Type.GetMethod(OnOpen, 0); if (method ! null) { onOpenDelegate appdomain.BeginInvoke(method); } } if (onOpenDelegate ! null) { appdomain.ExecuteInvoke(onOpenDelegate, instance, null); } else { base.OnOpen(); } } public override void OnClose() { if (onCloseDelegate null) { var method instance.Type.GetMethod(OnClose, 0); if (method ! null) { onCloseDelegate appdomain.BeginInvoke(method); } } if (onCloseDelegate ! null) { appdomain.ExecuteInvoke(onCloseDelegate, instance, null); } else { base.OnClose(); } } } }这个适配器的逻辑是当 CLR 侧调用 OnOpen 时先检查热更实例里有没有重写这个方法有就转发到虚拟机执行没有就调用基类默认实现。BeginInvoke和ExecuteInvoke是 ILRuntime 提供的调用机制比反射快适合频繁调用的场景。写完适配器后记得在初始化时注册appDomain.RegisterCrossBindingAdaptor(new BasePanelAdapter())。注册必须在加载热更 DLL 之前完成否则 ILRuntime 在解析类型时找不到适配器。4.4 实例化热更对象并验证继承链绑定和适配器都就绪后就可以实例化热更对象了。注意不能直接用new LoginPanel()因为那是 CLR 的 new热更类型不在 CLR 里。要用 AppDomain 的 Instantiatevar loginPanel appDomain.Instantiate(HotFix.LoginPanel); // 调用方法 appDomain.Invoke(HotFix.LoginPanel, OnOpen, loginPanel, null);如果一切正常控制台会输出 “LoginPanel OnOpen - 热更逻辑” 和 “BasePanel OnOpen”。这说明继承链和虚方法重写都工作了。但实际项目里热更对象往往需要被主工程代码持有和调用。比如主工程的 UI 管理器持有一个 BasePanel 引用调用它的 OnOpen。这时候需要把 ILTypeInstance 转成 CLR 的 BasePanel 类型。转换方式是var clrPanel loginPanel as BasePanel; // 或者 var clrPanel (BasePanel)appDomain.Instantiate(HotFix.LoginPanel);这个转换能成功正是因为适配器在中间起了作用。ILRuntime 会把 ILTypeInstance 包装成适配器实例而适配器继承自 BasePanel所以转换是合法的。5. 常见问题与排查技巧实录5.1 类型找不到或绑定失效最常见的报错是 “Type not found” 或者 “Method not found”。这类问题九成出在绑定环节。排查顺序是这样的先确认绑定代码有没有生成再确认绑定代码有没有注册最后确认绑定的程序集是不是包含了所有需要的类型。我遇到过一个隐蔽的情况绑定代码生成了也注册了但热更代码里继承的基类在一个被条件编译排除的程序集里。Unity 在不同平台下编译的程序集可能不同绑定代码生成时用的是编辑器环境打包时程序集变了绑定就失效了。解决办法是在打包后也跑一遍绑定生成或者确保绑定代码覆盖所有目标平台。另一个常见原因是热更 DLL 更新了但绑定代码没重新生成。热更代码里新增了一个继承关系绑定代码还是旧的运行时自然找不到。养成习惯每次热更代码有类型结构变化就重新生成绑定代码。5.2 虚方法不执行或执行了基类版本这个问题的表现是热更逻辑静默失效不报错但也不执行。原因通常是适配器里没有正确转发方法调用。检查适配器代码确认对应的方法有没有生成 InvocationDelegate以及 GetMethod 的参数是否正确。有个细节容易忽略GetMethod的参数个数要匹配。如果基类方法是OnOpen()无参热更里重写也是无参那GetMethod(OnOpen, 0)是对的。但如果基类方法是OnOpen(int type)热更里重写时参数类型不同GetMethod 可能找不到。ILRuntime 对方法签名的匹配比较严格参数类型和个数都要对上。还有一种情况是方法被sealed修饰或者基类方法不是 virtual。非虚方法在 ILRuntime 里不会被重写调用时永远走基类实现。如果热更里写了同名方法但没有 override 关键字那只是隐藏了基类方法不是重写通过基类引用调用时还是走基类版本。5.3 构造函数报错或字段初始化异常构造函数相关的问题通常表现为 NullReferenceException 或者字段值不对。前面提过跨运行时的构造顺序和普通 CLR 不一样。如果热更类的构造函数里访问了基类的字段而基类字段的初始化还没完成就会拿到 null 或者默认值。我的建议是热更类的构造函数只做最简单的赋值不要调用任何虚方法不要访问基类的复杂属性。把初始化逻辑抽到一个显式的 Initialize 方法里在对象构造完成后由外部调用。这样虽然多了一步但能避开构造顺序的坑。如果基类的构造函数有参数热更类继承时需要正确传递。ILRuntime 对带参构造的继承支持是有的但适配器里需要额外处理。我一般尽量避免带参构造的继承用工厂方法或者初始化方法来传参代码更清晰问题也少。5.4 性能问题与优化方向继承场景下的性能开销主要来自适配器的转发。每次跨域虚方法调用都要经过适配器的委托调用比原生 CLR 调用慢。如果 Update 里每帧调用大量跨域虚方法帧率会受影响。优化方向有几个。一是减少跨域继承的层级能扁平化就扁平化。二是把频繁调用的逻辑放到热更内部减少跨域调用次数。三是用缓存适配器里的 InvocationDelegate 已经做了缓存但 GetMethod 的调用可以进一步优化在适配器构造时就缓存好方法引用。实测下来一个中等复杂度的继承体系适配器带来的开销在可接受范围内。但如果你的项目对性能极其敏感比如每帧有上千次跨域虚方法调用那可能需要重新评估架构考虑用接口代替继承或者把更多逻辑放到主工程侧。5.5 常见问题速查表问题现象可能原因排查方向解决方式Type not found绑定代码未生成或未注册检查 CLRBindings 是否调用重新生成并注册绑定代码Method not found方法签名不匹配检查 GetMethod 参数调整参数个数和类型虚方法不执行适配器未转发检查适配器代码补全 InvocationDelegate构造报错构造顺序问题检查构造函数逻辑改用 Initialize 方法性能下降跨域调用过多Profiler 查看调用次数减少跨域继承层级打包后失效平台程序集差异对比编辑器与打包环境打包后重新生成绑定这张表是我在实际项目中遇到问题后整理的基本覆盖了继承场景下八成以上的报错。遇到新问题时先按表里的方向排查大部分情况能快速定位。6. 继承体系的设计建议与扩展思路6.1 什么时候该用继承什么时候该用接口ILRuntime 的继承虽然能跑通但成本比普通 C# 高。我的经验是能用接口就用接口必须用继承时才用继承。接口在 ILRuntime 里的适配比类继承简单不涉及基类构造和字段布局性能开销也小一些。比如 UI 面板的管理主工程定义一个IPanel接口热更里的面板实现这个接口。主工程的 UI 管理器持有IPanel引用调用接口方法。这样不需要适配器继承只需要接口绑定代码更简洁问题也更少。但有些场景确实需要继承比如热更类需要继承 MonoBehaviour 才能挂到 GameObject 上。这种时候继承是必须的适配器也省不了。所以设计阶段就要想清楚哪些类型需要跨域继承哪些可以用接口替代。6.2 热更代码的版本兼容性考虑热更的一个核心价值是能动态更新代码但继承关系的变化会给版本兼容带来挑战。比如线上版本的热更 DLL 里LoginPanel继承BasePanel。新版本的热更 DLL 里LoginPanel改成继承NewBasePanel。如果主工程里的绑定代码和适配器还是针对BasePanel的新 DLL 加载后就会出问题。处理方式是绑定代码和适配器要尽量稳定不要频繁变动。如果继承关系必须变那主工程也要跟着发版这就失去了热更的意义。所以设计热更代码的继承体系时要预留扩展空间基类的接口尽量稳定用组合代替继承来应对变化。另一个实践是热更 DLL 里保留旧类型的兼容代码。比如LoginPanel改成继承NewBasePanel后仍然保留对BasePanel的适配器注册这样旧版本的主工程加载新 DLL 时至少不会直接崩溃。当然这需要额外的兼容代码会增加维护成本适合对稳定性要求极高的项目。6.3 后续可以扩展的方向继承跑通之后下一步可以研究 ILRuntime 的泛型继承、协变逆变这些高级特性。泛型在 ILRuntime 里的支持是有限的某些泛型继承场景需要特殊处理。如果你的项目里大量使用泛型基类这块需要提前做技术验证。另一个方向是热更代码的调试。ILRuntime 支持在 VS 里附加调试热更代码但配置起来有点繁琐。调试通了之后排查继承问题的效率会高很多不用靠猜和打日志。还有就是性能监控。在适配器的转发方法里埋点统计跨域调用的次数和耗时能帮你发现性能瓶颈。我一般会在开发阶段打开这个统计上线前关掉既能优化性能又不影响线上。这个内容后续还可以这样扩展把继承场景和 ILRuntime 的委托、事件、协程结合起来讲这些机制在跨域时的表现和继承有相似之处也有各自的坑。如果这篇反馈不错我再整理一篇关于跨域委托和事件的实操记录。
锦
锦皓数字建站
深耕本土企业品牌数字化升级,专注原创端正雅致商务官网,从视觉设计到稳定运维全程保驾护航。