1. 为什么继承在热更新里是个绕不开的坎
如果你正在用 ILRuntime 做 Unity 热更新,并且已经跑通了基本的入口调用,那么接下来大概率会遇到继承相关的问题。这个坑几乎每个从零开始搭 ILRuntime 的人都会踩一遍,而且踩的方式还不太一样。
先说清楚 ILRuntime 是什么。它是一个纯 C# 实现的 IL 解释器,让热更代码以 DLL 的形式在运行时被加载和执行,不需要重新打包 App。主工程(Unity 侧)叫主工程程序集,热更部分叫热更程序集。两者之间的类型系统是隔离的,主工程用的是 Unity 的 Mono 运行时,热更代码跑在 ILRuntime 的虚拟机里。这就意味着,跨程序集的继承不是 CLR 层面天然支持的,而是需要 ILRuntime 通过适配器、绑定和 CLR 重定向来桥接。
继承之所以麻烦,是因为它牵扯到的东西太多了:虚方法表、构造函数链、字段布局、接口实现、泛型约束、反射调用。任何一环没处理好,轻则报TypeLoadException,重则运行时行为诡异——比如父类方法被调用了但字段是默认值,或者虚方法重写失效。
这篇内容适合两类人:一类是刚接触 ILRuntime、正在啃继承这块硬骨头的开发者;另一类是用了一段时间但遇到继承相关报错、想搞清楚底层逻辑的人。我会从继承的几种典型场景出发,把每种场景的配置、代码写法和容易翻车的地方都讲透。
注意:本文基于 ILRuntime 2.x 版本的常见实践,不同小版本之间 API 可能有细微差异,遇到不一致时以你项目实际引用的版本为准。
2. 跨域继承的三种场景与对应处理策略
在 ILRuntime 里讨论继承,必须先分清继承发生在哪一侧。很多人一上来就写代码,结果报错了都不知道是哪个方向的问题。实际上跨域继承只有三种情况,每种的处理方式完全不同。
2.1 热更代码继承热更代码:最省心的同域继承
这是最简单的情况。热更 DLL 内部的类 A 继承类 B,两者都在热更程序集里,ILRuntime 虚拟机自己就能处理,不需要任何额外配置。虚方法、抽象方法、接口实现都跟在普通 C# 项目里一样。
我见过有人连这种同域继承都去写适配器,完全是多余的。判断标准很简单:看父类和子类是不是都在同一个热更 DLL 里。如果是,直接写就行。
不过有一个细节要注意:如果热更代码里的类继承自热更程序集里的泛型基类,比如class MyList : HotfixBase<int>,这种在 ILRuntime 里是支持的,但泛型参数的实例化时机要留意。实测下来,只要泛型参数是值类型或热更程序集内定义的类型,都没问题;如果泛型参数是主工程里的类型,就需要确保该类型已经被正确注册。
2.2 热更代码继承主工程类型:需要适配器
这是最常见的跨域继承场景。比如主工程里有一个BasePanel类,热更代码想写class LoginPanel : BasePanel。这时候 ILRuntime 需要知道怎么把热更侧的LoginPanel和主工程侧的BasePanel关联起来。
处理方式分两步。第一步是在主工程里为BasePanel写一个适配器,继承CrossBindingAdaptor。第二步是在 ILRuntime 的AppDomain初始化时注册这个适配器。
适配器的核心是重写CanAdapt和CreateCLRInstance两个方法,同时要把父类里所有可能被热更侧重写的虚方法都在适配器里声明一遍。这里有个容易忽略的点:适配器里声明的虚方法,方法签名必须和父类完全一致,包括参数类型和返回值。如果父类方法有ref或out参数,适配器里也要对应写上。
public class BasePanelAdapter : CrossBindingAdaptor { public override Type BaseCLRType => typeof(BasePanel); public override Type AdaptorType => typeof(Adaptor); public override object CreateCLRInstance(ILRuntime.Runtime.Enviorment.AppDomain appdomain, ILTypeInstance instance) { return new Adaptor(appdomain, instance); } public class Adaptor : BasePanel, CrossBindingAdaptorType { private ILTypeInstance instance; private ILRuntime.Runtime.Enviorment.AppDomain appdomain; public Adaptor() { } public Adaptor(ILRuntime.Runtime.Enviorment.AppDomain appdomain, ILTypeInstance instance) { this.appdomain = appdomain; this.instance = instance; } public ILTypeInstance ILInstance => instance; public override void OnOpen() { if (instance != null) { appdomain.Invoke("Hotfix.LoginPanel", "OnOpen", instance, null); } } } }上面这段代码里,OnOpen是父类的虚方法,适配器里重写它,然后通过appdomain.Invoke把调用转发到热更侧的实际实现。这就是跨域继承的核心机制——主工程侧持有一个适配器实例,适配器把虚调用转发给热更侧的 ILTypeInstance。
2.3 主工程代码继承热更类型:几乎不会这么做
这种情况在实际项目里极少出现,因为主工程代码在编译期就固定了,不可能去继承一个运行时才加载的类型。如果真有这种需求,通常说明架构设计有问题,应该反过来让热更侧去继承主工程侧。
所以这里不展开,遇到类似需求时建议重新审视模块划分。
3. 适配器注册与虚方法转发的完整实操
上一节讲了适配器的写法,这一节把注册流程和虚方法转发的细节补全。很多人适配器写对了,但忘了注册,结果运行时报Cannot find adaptor之类的错误。
3.1 AppDomain 初始化时的注册顺序
ILRuntime 的初始化有一套固定的顺序,注册适配器必须在加载热更 DLL 之前完成。我一般把初始化代码放在一个单独的ILRuntimeManager类里,按下面的顺序执行:
- 创建
AppDomain实例。 - 注册 CLR 重定向(
CLRRedirection),把主工程里需要被热更调用的方法注册进去。 - 注册跨域继承适配器(
RegisterCrossBindingAdaptor)。 - 注册委托适配器(
RegisterDelegateConvertor和RegisterMethodDelegate)。 - 加载热更 DLL 的字节流。
- 执行热更入口方法。
顺序错了会出问题。比如先加载 DLL 再注册适配器,ILRuntime 在解析类型时找不到适配器,就会抛异常。我踩过一次这个坑,排查了半天才发现是注册顺序反了。
public class ILRuntimeManager { private AppDomain appDomain; public void Init() { appDomain = new AppDomain(); // 1. CLR 重定向 appDomain.RegisterCLRMethodRedirection( typeof(Debug).GetMethod("Log", new Type[] { typeof(object) }), CLRRedirectionHelper.DebugLog); // 2. 跨域继承适配器 appDomain.RegisterCrossBindingAdaptor(new BasePanelAdapter()); appDomain.RegisterCrossBindingAdaptor(new BaseEntityAdapter()); // 3. 委托适配器 appDomain.DelegateManager.RegisterMethodDelegate<Action>(); appDomain.DelegateManager.RegisterDelegateConvertor<UnityAction>((action) => { return new UnityAction(() => { ((Action)action)(); }); }); // 4. 加载 DLL byte[] dllBytes = LoadHotfixDll(); byte[] pdbBytes = LoadHotfixPdb(); appDomain.LoadAssembly(new MemoryStream(dllBytes), new MemoryStream(pdbBytes), new PdbReaderProvider()); // 5. 执行入口 appDomain.Invoke("Hotfix.Entry", "Start", null, null); } }3.2 虚方法转发时参数怎么传
appdomain.Invoke的参数传递是个高频出错点。它的签名是Invoke(string typeName, string methodName, object instance, object[] parameters)。对于无参方法,parameters传null就行;有参方法要传object[],而且参数类型要能自动转换。
如果父类方法有返回值,Invoke会返回object,需要手动强转。这里有个坑:如果热更侧方法返回的是值类型,装箱拆箱会有性能开销,频繁调用的虚方法要留意。我一般会在适配器里缓存IMethod实例,避免每次调用都走字符串查找。
private IMethod onOpenMethod; public override void OnOpen() { if (onOpenMethod == null) { onOpenMethod = appdomain.GetType("Hotfix.LoginPanel") .GetMethod("OnOpen", 0); } appdomain.Invoke(onOpenMethod, instance, null); }用IMethod缓存的方式比每次传字符串快不少,尤其是在Update这类每帧调用的虚方法上,差距很明显。实测在低端机上,字符串查找的开销能占到整个调用链的三成以上。
3.3 字段访问的陷阱
跨域继承里,字段访问是最容易出诡异问题的地方。热更侧的类继承主工程基类后,基类的字段在热更侧能不能直接访问?答案是:能访问,但走的是适配器转发,不是直接内存访问。
这意味着如果你在热更代码里写this.someBaseField = 10,实际执行的是通过适配器去设置主工程实例的字段。如果适配器没有正确暴露这个字段,或者字段是私有的,就会失败。
我的建议是:跨域继承时,基类的字段尽量用属性封装,属性在适配器里重写 getter/setter 转发。这样比直接暴露字段更可控,也更容易排查问题。
4. 构造函数链与对象初始化的那些坑
构造函数是继承里另一个重灾区。热更侧子类实例化时,主工程基类的构造函数什么时候执行、怎么执行,跟普通 C# 完全不一样。
4.1 基类无参构造的隐式调用
如果主工程基类有无参构造函数,热更侧子类实例化时,ILRuntime 会自动调用基类的无参构造。这个过程是隐式的,你不需要在热更代码里写base()。但前提是适配器里要正确实现构造逻辑。
我遇到过一种情况:基类构造函数里有初始化逻辑,比如给某个字段赋值,但热更侧实例化后字段还是默认值。排查后发现是适配器的Adaptor类没有调用基类构造。因为Adaptor继承自BasePanel,它的无参构造会调用BasePanel(),但如果Adaptor是通过CreateCLRInstance创建的,走的是带参构造,基类无参构造就不会自动执行。
解决办法是在适配器的带参构造里手动调用基类构造,或者确保基类的初始化逻辑不依赖构造函数,而是放在一个单独的Init方法里。
4.2 带参构造的跨域传递
基类如果有带参构造,热更侧子类实例化时怎么传参?这个场景在 UI 框架里很常见,比如BasePanel(string name)。
处理方式是在适配器里重写对应的构造转发。但 ILRuntime 的适配器机制对构造函数的支持有限,不能像虚方法那样直接转发。常见的做法是:热更侧子类不直接new,而是通过一个工厂方法创建,工厂方法里先创建适配器实例,再调用热更侧的初始化方法传参。
// 热更侧 public class LoginPanel : BasePanel { public LoginPanel() : base() { } public void Init(string name) { this.PanelName = name; } } // 主工程侧创建 var panel = appDomain.Instantiate("Hotfix.LoginPanel"); appDomain.Invoke("Hotfix.LoginPanel", "Init", panel, new object[] { "LoginPanel" });这种模式虽然绕了一下,但胜在稳定,不会因为构造函数的问题导致初始化失败。我在几个项目里都用这套方案,没出过问题。
4.3 构造顺序对字段初始化的影响
热更侧子类的字段初始化器(比如private int count = 10;)和构造函数体的执行顺序,在 ILRuntime 里跟标准 C# 是一致的:先字段初始化器,再构造函数体,最后基类构造。但跨域情况下,基类构造是在适配器创建时执行的,时机可能早于热更侧字段初始化。
这就导致一个现象:如果基类构造函数里调用了某个虚方法,而这个虚方法被热更侧重写了,那么调用发生时热更侧字段可能还没初始化。这是经典的构造函数调用虚方法问题,在跨域场景下更容易触发。
规避方法很简单:基类构造函数里不要调用虚方法。如果非要调用,确保被调用的虚方法不依赖子类字段。
5. 接口实现与泛型继承的边界情况
接口和泛型是继承里的进阶话题,也是报错最集中的地方。这一节把常见的边界情况梳理一遍。
5.1 热更侧实现主工程接口
热更代码实现主工程定义的接口,这个场景比继承类要简单一些,因为接口没有字段和构造函数的包袱。处理方式是在适配器里实现接口方法,然后转发到热更侧。
但要注意,如果接口方法有泛型参数,比如void Process<T>(T item),适配器里没法直接转发,因为泛型方法在适配器里是开放类型。这种情况需要用反射或者IMethod动态调用。
public override void Process<T>(T item) { var method = appdomain.GetType("Hotfix.MyHandler") .GetMethod("Process", 1) .MakeGenericMethod(new IType[] { appdomain.GetType(typeof(T)) }); appdomain.Invoke(method, instance, new object[] { item }); }这段代码里MakeGenericMethod是关键,它把开放泛型方法实例化成具体类型的方法。实测下来,值类型泛型参数会有装箱开销,高频调用要谨慎。
5.2 泛型基类的继承
热更侧类继承主工程的泛型基类,比如class MyEntity : BaseEntity<int>,这种在 ILRuntime 里是支持的,但适配器要针对每个具体泛型实例分别注册。也就是说,BaseEntity<int>和BaseEntity<string>需要两个不同的适配器。
如果泛型基类的类型参数是热更侧定义的类型,情况会更复杂。ILRuntime 需要先解析热更类型,再实例化泛型基类。这个过程在 DLL 加载后才能完成,所以适配器注册要放在加载之后。但前面说过适配器注册要在加载之前,这就矛盾了。
解决办法是用RegisterCrossBindingAdaptor的延迟注册机制,或者把泛型基类的适配器注册放在LoadAssembly的回调里。ILRuntime 提供了appDomain.LoadAssembly完成后的事件,可以在这个事件里补充注册。
5.3 抽象类与抽象方法的处理
热更侧继承主工程的抽象类,必须实现所有抽象方法。适配器里要把抽象方法都重写一遍,转发到热更侧。如果漏了某个抽象方法,编译主工程时就会报错,因为适配器类没有实现抽象成员。
这个错误在编译期就能发现,算是比较好处理的。麻烦的是抽象方法有默认实现的情况(C# 8.0 之后支持),适配器里重写时要注意是否需要调用base实现。
我一般的原则是:适配器里只做转发,不做逻辑。所有业务逻辑都放在热更侧,适配器保持薄薄一层。这样职责清晰,出问题也容易定位。
6. 排查继承相关报错的实战思路
前面讲了怎么写,这一节讲出错了怎么查。继承相关的报错信息往往很模糊,比如NullReferenceException或者TypeLoadException,堆栈里全是 ILRuntime 内部调用,看不出问题在哪。
6.1 常见报错与对应原因
我把踩过的坑整理成一张表,方便对照排查。
| 报错信息 | 常见原因 | 排查方向 |
|---|---|---|
Cannot find adaptor for type | 适配器未注册或注册顺序错误 | 检查RegisterCrossBindingAdaptor是否在加载 DLL 前调用 |
TypeLoadException | 泛型基类适配器缺失或类型未注册 | 检查泛型实例是否都有对应适配器 |
NullReferenceException在虚方法调用处 | 适配器实例未正确初始化 | 检查CreateCLRInstance返回值 |
Method not found | 方法签名不匹配或方法名拼写错误 | 核对适配器里的方法签名与父类是否完全一致 |
| 字段值为默认值 | 基类构造函数未执行或字段未转发 | 检查适配器构造逻辑和字段访问方式 |
6.2 用日志定位转发断点
适配器里的转发调用是排查问题的关键节点。我习惯在每个转发方法里加一行日志,输出方法名和实例 ID。这样运行时能看到哪些虚方法被调用了、调用顺序是什么。
public override void OnOpen() { Debug.Log($"[Adapter] OnOpen called, instance={instance.GetHashCode()}"); if (onOpenMethod == null) { onOpenMethod = appdomain.GetType("Hotfix.LoginPanel").GetMethod("OnOpen", 0); } appdomain.Invoke(onOpenMethod, instance, null); }日志加多了会影响性能,所以只在排查阶段开,定位到问题后关掉。可以加一个开关变量控制。
6.3 用最小复现工程隔离问题
继承问题往往和项目里的其他逻辑纠缠在一起,直接在项目里排查效率很低。我的做法是建一个最小复现工程,只保留 ILRuntime 初始化、一个基类、一个适配器、一个热更子类。把出问题的代码精简到这个工程里,通常几分钟就能定位。
这个习惯帮我省了大量时间。很多时候在项目里看半天没头绪,搬到最小工程里一跑,问题立刻暴露。
7. 性能与维护上的几点个人经验
最后聊几点实际项目里的体会,这些是文档里不会写的。
第一,适配器数量要控制。每多一个跨域继承适配器,ILRuntime 初始化时就多一份类型解析开销。我一般会把基类设计得尽量少,用组合代替继承。热更侧需要复用的逻辑,优先考虑用接口或者工具类,而不是继承主工程基类。
第二,虚方法转发能缓存就缓存。前面提过IMethod缓存,这里再强调一次。Update、LateUpdate这类每帧调用的虚方法,一定要缓存IMethod,否则字符串查找的开销会拖垮帧率。我在一个项目里测过,缓存前后帧率差了将近 15 帧。
第三,热更侧继承层次不要太深。ILRuntime 对多层继承的支持虽然没问题,但每深一层,虚方法查找和字段访问的链路就长一截。三层以内的继承结构比较稳妥,超过三层建议重新设计。
第四,适配器代码要跟着基类同步更新。基类加了新的虚方法,适配器忘了加,运行时调用就会失败。我一般会在基类里加一个#if UNITY_EDITOR的检查,用反射扫描所有虚方法,对比适配器是否都覆盖了,编译时给出警告。
#if UNITY_EDITOR [UnityEditor.InitializeOnLoad] public static class AdaptorChecker { static AdaptorChecker() { // 扫描 BasePanel 的虚方法,检查 BasePanelAdapter 是否都重写 var baseMethods = typeof(BasePanel).GetMethods( BindingFlags.Public | BindingFlags.Instance | BindingFlags.Virtual); var adaptorMethods = typeof(BasePanelAdapter.Adaptor).GetMethods( BindingFlags.Public | BindingFlags.Instance); // 对比逻辑... } } #endif这个检查脚本帮我避免了好几次线上事故。基类改动频繁的项目强烈建议加上。
继承这块内容说复杂也复杂,说简单也简单。核心就是搞清楚跨域边界在哪,适配器把边界两侧连起来,剩下的就是细心和耐心。把上面这些场景和坑都过一遍,基本能覆盖项目里九成以上的继承需求。