ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

ILRuntime热更新继承机制详解:从CLR绑定到适配器实战

ILRuntime热更新继承机制详解:从CLR绑定到适配器实战

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 : BaseEntity,ILRuntime 在解释PlayerEntity的时候,需要知道BaseEntity是什么、它的方法怎么调用、字段怎么访问。如果没有任何适配,ILRuntime 的虚拟机根本找不到BaseEntity对应的运行时表示。

所以整体设计的核心思路就是:在 ILRuntime 虚拟机和主工程 CLR 之间建立一座桥。这座桥由两部分组成,一是类型绑定,让 ILRuntime 知道热更代码里引用的主工程类型对应到 CLR 里的哪个 Type;二是适配器,让跨运行时的继承、接口实现、方法重写能够正确工作。

2.2 为什么选择 CLR 绑定加适配器这套方案

ILRuntime 提供了几种跨域交互的方式,常见的有 CLR 绑定(CLR Bind)和适配器(Adapter)。CLR 绑定解决的是“热更代码调用主工程代码”的问题,比如热更里new一个主工程的类、调用主工程的方法。适配器解决的是“主工程调用热更代码”以及“热更代码继承主工程类型”的问题。

对于继承场景,两者都需要。假设热更程序集里有一个Enemy : MonoBehaviour,MonoBehaviour 是主工程的类型。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 的 Instantiate:

var 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 的委托、事件、协程结合起来讲,这些机制在跨域时的表现和继承有相似之处,也有各自的坑。如果这篇反馈不错,我再整理一篇关于跨域委托和事件的实操记录。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进