ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

ILRuntime热更新继承实战:跨域继承适配器生成与避坑指南

ILRuntime热更新继承实战:跨域继承适配器生成与避坑指南

1. 为什么要在 ILRuntime 里死磕继承这件事

如果你正在用 Unity 做手游或者需要频繁迭代的商业项目,热更新几乎是绕不开的一道坎。ILRuntime 作为国内用得比较多的 C# 热更新方案,核心思路就是把游戏逻辑打成 DLL,运行时用 ILRuntime 解释执行,从而绕开平台审核直接更新代码。前四篇我们聊了环境搭建、CLR 绑定、委托适配这些基础,到了第五篇,继承这个话题必须单独拎出来讲,因为它是实际项目里翻车率最高的地方之一。

为什么这么说?你想想,正常写 C# 的时候,继承是本能操作——基类定义通用逻辑,子类做差异化实现,多态调用一气呵成。但到了热更新环境里,事情变得复杂了:热更 DLL 里的类要继承主工程里的类,或者主工程的类要继承热更 DLL 里的类,甚至热更 DLL 内部自己继承自己,这三种情况在 ILRuntime 里的处理方式完全不同。搞不清楚这些,你写出来的代码要么编译报错,要么运行时抛异常,要么性能莫名其妙掉一大截。

这篇内容适合已经跑通 ILRuntime 基础流程、准备把真实业务逻辑往热更里搬的开发者。我会把继承在 ILRuntime 里的几种典型场景拆开,讲清楚每种场景下 CLR 绑定该怎么写、适配器怎么生成、有哪些坑是文档里不会告诉你的。看完之后,你应该能独立处理热更代码里 90% 以上的继承相关问题。

2. ILRuntime 继承体系的底层逻辑拆解

2.1 跨域继承到底难在哪

要理解 ILRuntime 的继承问题,先得明白一个基本事实:热更 DLL 里的类型和主工程里的类型,运行在不同的“域”里。主工程类型由 Unity 的 Mono 或 IL2CPP 运行时直接管理,而热更 DLL 里的类型由 ILRuntime 的解释器管理。这两个域之间的类型系统是不互通的,就像两个国家各有各的户籍系统,一个人不能同时拥有两个国家的身份证。

当你写一个热更类去继承主工程的类时,ILRuntime 需要做一层“翻译”:它得让解释器里的类型看起来像是主工程类型的子类,同时还要保证虚方法调用、字段访问、构造函数链这些机制都能正常工作。这个翻译层就是 CLR 绑定和适配器。

具体来说,ILRuntime 处理跨域继承的核心机制是适配器模式。它会为每一个需要跨域继承的基类生成一个适配器类,这个适配器继承自基类,同时持有热更类型的引用,把基类的虚方法调用转发到热更类型上。听起来简单,但实际实现时涉及构造顺序、虚方法表映射、字段偏移计算等一堆细节。

2.2 三种继承场景的分类

在实际项目里,继承关系可以分成三类,每类的处理策略不同:

场景基类位置子类位置是否需要适配器典型用途
场景A主工程热更DLL需要热更逻辑继承主工程框架基类
场景B热更DLL主工程需要主工程扩展热更定义的基础类型
场景C热更DLL热更DLL不需要热更内部自己的继承体系

场景C最简单,因为都在解释器内部,ILRuntime 自己就能处理,跟普通 C# 继承没区别。真正麻烦的是场景A和场景B,尤其是场景A,几乎每个热更项目都会遇到。

2.3 适配器的生成时机与方式

适配器不是自动生成的,你得手动触发。ILRuntime 提供了两种方式:一种是运行时动态生成,通过appdomain.DelegateManager和CLRRedirection相关 API;另一种是提前用工具生成好代码,编译进主工程。实际项目里我强烈建议用第二种,原因很简单——动态生成在 IL2CPP 下会有 AOT 兼容问题,而且每次启动都要花时间反射,性能不可控。

生成适配器的工具在 ILRuntime 的 GitHub 仓库里有,叫ILRuntimeCLRBinding,配合CLRBindingGenerateWindow窗口使用。你需要在 Unity 编辑器里打开这个窗口,把热更 DLL 拖进去,它会自动分析所有跨域继承关系,生成对应的适配器代码。生成的代码通常放在Assets/ILRuntime/Generated目录下,记得把这个目录纳入版本管理。

注意:每次热更 DLL 的继承结构发生变化,都必须重新生成适配器。忘了这一步,运行时会直接抛TypeLoadException,而且报错信息往往很隐晦,新手容易卡在这里。

3. 场景A实操:热更类继承主工程基类

3.1 定义主工程基类

假设我们在主工程里有一个游戏实体的基类,叫GameEntity,它定义了实体的基本行为和生命周期方法。这个类会被热更代码继承,所以它的设计要提前考虑热更友好性。

// 主工程代码 public class GameEntity { public int EntityId; public string EntityName; public GameEntity() { EntityId = -1; EntityName = "Unknown"; } public virtual void OnInit() { Debug.Log("GameEntity OnInit"); } public virtual void OnUpdate(float deltaTime) { // 基类默认空实现 } public virtual void OnDestroy() { Debug.Log("GameEntity OnDestroy"); } }

这个基类有几个关键点:构造函数是 public 的,虚方法都是 public virtual,字段是 public 的。这些修饰符的选择直接影响适配器能否正确生成。如果基类有 protected 成员,适配器生成工具也能处理,但你需要确保热更 DLL 里的子类能正确访问。

3.2 热更 DLL 里的子类实现

在热更工程里,我们定义一个PlayerEntity继承GameEntity:

// 热更 DLL 代码 public class PlayerEntity : GameEntity { private int _hp; private int _mp; public PlayerEntity() { EntityName = "Player"; _hp = 100; _mp = 50; } public override void OnInit() { base.OnInit(); Debug.Log($"PlayerEntity OnInit, HP={_hp}, MP={_mp}"); } public override void OnUpdate(float deltaTime) { // 玩家每帧逻辑 _hp += 1; } public override void OnDestroy() { Debug.Log("PlayerEntity OnDestroy"); base.OnDestroy(); } }

这段代码在普通 C# 环境里完全没问题,但在 ILRuntime 里,如果没有适配器,PlayerEntity的实例化会直接失败。因为 ILRuntime 不知道如何把PlayerEntity的构造函数链到GameEntity上,也不知道如何把OnInit的虚调用转发过去。

3.3 生成并检查适配器代码

用 ILRuntime 的绑定生成工具处理后,会得到一个GameEntityAdapter类,大致结构如下:

// 自动生成的适配器代码(简化版) public class GameEntityAdapter : GameEntity, IAdapter { private object _instance; private ILType _ilType; public GameEntityAdapter(object instance, ILType ilType) { _instance = instance; _ilType = ilType; } public override void OnInit() { var method = _ilType.GetMethod("OnInit", 0); appdomain.Invoke(method, _instance, null); } public override void OnUpdate(float deltaTime) { var method = _ilType.GetMethod("OnUpdate", 1); appdomain.Invoke(method, _instance, deltaTime); } public override void OnDestroy() { var method = _ilType.GetMethod("OnDestroy", 0); appdomain.Invoke(method, _instance, null); } }

这个适配器继承自GameEntity,所以它能被主工程代码当作GameEntity使用。同时它持有热更实例的引用,把虚方法调用转发到解释器里执行。这就是跨域继承的核心魔法。

实操心得:生成的适配器代码不要手动改,因为下次重新生成会覆盖。如果你发现适配器有问题,应该去调整基类的设计或者热更 DLL 的结构,而不是改生成结果。

3.4 实例化与调用的正确姿势

有了适配器,实例化热更类的方式就变了。你不能直接new PlayerEntity(),而是要通过 ILRuntime 的appdomain.Instantiate:

// 主工程里创建热更对象 ILType playerType = appdomain.LoadedTypes["HotFix.PlayerEntity"]; object playerInstance = appdomain.Instantiate(playerType.FullName); // 转成基类引用,正常调用 GameEntity entity = playerInstance as GameEntity; if (entity != null) { entity.OnInit(); entity.OnUpdate(0.016f); entity.OnDestroy(); }

这里的关键是as GameEntity这个转换。因为适配器继承了GameEntity,所以转换能成功。转换之后,你调用的所有虚方法都会经过适配器转发到热更代码。字段访问也类似,但要注意字段访问不走适配器,而是直接操作适配器上的字段副本,这点后面会详细说。

4. 场景B与场景C的处理差异

4.1 主工程类继承热更基类

场景B相对少见,但某些架构里会用到。比如热更 DLL 定义了一个HotFixBase类,主工程里有个MainLogic类需要继承它。这种情况下,适配器的方向反过来了:适配器在热更侧生成,主工程类通过适配器访问热更基类的方法。

处理方式上,你需要在热更 DLL 里定义基类时,把需要被主工程继承的虚方法标记清楚,然后在主工程侧用appdomain提供的 API 来建立继承关系。这种场景的坑在于构造顺序:主工程类的构造函数会先执行,此时热更基类的字段还没初始化,如果你在构造函数里访问了基类字段,会拿到默认值。

// 热更 DLL 里的基类 public class HotFixBase { protected int BaseValue; public HotFixBase() { BaseValue = 100; } public virtual void DoSomething() { Debug.Log($"BaseValue={BaseValue}"); } }

主工程继承时,BaseValue的初始化时机取决于适配器的实现。实测下来,ILRuntime 会先调用主工程类的构造函数,再调用热更基类的构造函数,所以你在主工程构造函数里读BaseValue会得到 0。这个行为跟普通 C# 继承相反,必须特别注意。

4.2 热更内部继承的注意事项

场景C是热更 DLL 内部的继承,比如EnemyEntity继承GameEntity,两个类都在热更 DLL 里。这种继承 ILRuntime 原生支持,不需要适配器,写法和普通 C# 完全一样。但有几个细节要注意:

第一,热更内部的继承链不要跨域。也就是说,如果GameEntity是主工程的,EnemyEntity是热更的,那EnemyEntity的子类如果也在热更里,它继承的是适配器版本的GameEntity,而不是原始的。这个区别在反射和类型判断时会有影响。

第二,热更内部的虚方法调用走的是解释器的虚方法表,性能比主工程的原生虚调用慢。如果继承链很深,方法调用频繁,建议把热点逻辑下沉到主工程,或者用接口代替继承。

第三,热更 DLL 里的sealed类不能被主工程继承,这个跟普通 C# 一样,但 ILRuntime 在生成适配器时会跳过 sealed 类,如果你不小心让主工程继承了 sealed 的热更类,编译期不会报错,运行时会抛异常。

4.3 三种场景的选型建议

实际项目里怎么选?我的经验是:

  • 框架层用场景A:主工程定义基类,热更实现具体逻辑。这样框架稳定,热更灵活。
  • 工具层用场景C:热更内部自己组织继承体系,减少跨域开销。
  • 场景B尽量少用:除非有特殊需求,否则不要让主工程继承热更类,构造顺序和字段初始化的问题很难排查。

5. 继承场景下的字段与属性访问陷阱

5.1 字段访问不走适配器

这是最容易踩的坑。适配器只转发虚方法调用,字段访问是直接操作适配器实例上的字段。什么意思呢?看这个例子:

// 主工程基类 public class GameEntity { public int EntityId; } // 热更子类 public class PlayerEntity : GameEntity { public void SetId(int id) { EntityId = id; // 这里操作的是适配器上的字段 } }

当热更代码里访问EntityId时,ILRuntime 会通过适配器实例来读写这个字段。但如果你在主工程里这样写:

GameEntity entity = playerInstance as GameEntity; entity.EntityId = 100; // 直接操作适配器字段

然后热更代码里读EntityId,读到的也是适配器上的值,所以能同步。但如果你在热更代码里用反射或者this引用去访问字段,可能会绕过适配器,导致数据不一致。

避坑技巧:跨域继承时,尽量用属性代替公开字段。属性本质上是方法调用,会走适配器转发,数据一致性有保障。如果必须用字段,确保所有访问都通过基类引用进行。

5.2 属性访问器的适配

属性在 ILRuntime 里被当作方法处理,get_XXX和set_XXX是两个独立的方法。适配器生成工具会自动为虚属性生成转发代码,但非虚属性不会。这意味着如果基类的属性不是 virtual 的,热更子类无法重写它,只能继承基类的实现。

// 基类 public class GameEntity { public virtual int Hp { get; set; } // 虚属性,可被重写 public int Mp { get; set; } // 非虚属性,不可重写 }

热更子类重写Hp没问题,但想改Mp的行为就不行。所以设计基类时,如果某个属性需要热更定制,必须标记为 virtual。

5.3 静态成员与继承的关系

静态成员不参与继承的多态,但在 ILRuntime 里有个特殊问题:热更 DLL 里的静态字段和主工程里的静态字段是分开存储的。如果基类在主工程,子类在热更,子类访问基类的静态字段时,ILRuntime 会通过适配器转发到主工程的静态字段,这个行为是正确的。但如果子类自己定义了同名静态字段,会遮蔽基类的,跟普通 C# 行为一致。

实测下来,静态字段的跨域访问性能比实例字段差,因为每次访问都要经过解释器的静态字段解析。如果热更代码里频繁访问基类静态字段,建议在主工程里包一层静态方法,热更侧调用方法而不是直接访问字段。

6. 常见问题排查与性能优化

6.1 继承相关的典型报错与解决

报错信息原因解决方法
TypeLoadException: Could not load type适配器未生成或未注册重新生成 CLR 绑定,检查 Generated 目录
MissingMethodException: OnInit虚方法签名不匹配检查基类和子类的方法签名是否完全一致
NullReferenceException in adapter适配器实例未正确初始化确保用 appdomain.Instantiate 创建对象
InvalidCastException类型转换失败确认适配器已正确继承基类
StackOverflowException虚方法递归调用检查 base 调用是否形成循环

这些报错里,TypeLoadException最常见,九成以上是适配器没生成或者生成后没重新编译。MissingMethodException通常是方法签名问题,比如基类方法是public virtual void OnInit(),子类写成了public override void OnInit(int x),参数列表不一致。

6.2 性能开销与优化手段

跨域继承的虚方法调用比普通调用慢,因为要经过适配器转发和解释器执行。实测数据:主工程原生虚调用大约 2-5 纳秒,经过适配器的跨域调用大约 200-500 纳秒,差了近百倍。如果每帧调用几千次,累积起来就很可观了。

优化手段有几个:

第一,减少跨域虚调用频率。把每帧调用的逻辑合并成一次调用,比如用OnUpdate统一处理,而不是每个子系统单独调用。

第二,热点逻辑下沉到主工程。如果某个虚方法调用极其频繁,考虑在主工程里实现,热更只做配置。

第三,用委托代替虚方法。ILRuntime 的委托调用比虚方法调用快,如果基类方法不需要多态,可以改成委托字段。

// 用委托代替虚方法的示例 public class GameEntity { public Action<float> UpdateAction; public void OnUpdate(float dt) { UpdateAction?.Invoke(dt); } }

热更侧给UpdateAction赋值,调用时走委托转发,比虚方法快不少。

6.3 调试继承问题的实用技巧

ILRuntime 的报错信息有时候很模糊,调试继承问题建议开appdomain.DebugService的日志,能看到具体的 IL 指令执行过程。另外,在适配器的转发方法里加日志,能快速定位是哪个方法调用出了问题。

// 在适配器里临时加日志 public override void OnInit() { Debug.Log("Adapter OnInit called"); var method = _ilType.GetMethod("OnInit", 0); appdomain.Invoke(method, _instance, null); }

如果日志显示适配器方法被调用了,但热更代码没执行,说明appdomain.Invoke出了问题,检查方法名和参数个数。如果适配器方法根本没被调用,说明主工程侧的虚调用没走到适配器,检查对象是否真的被转成了基类引用。

7. 我踩过的几个真实坑

第一个坑是构造函数里的虚方法调用。主工程基类构造函数里调了一个虚方法,热更子类重写了它。结果实例化时,基类构造函数执行时子类字段还没初始化,虚方法里访问子类字段拿到 null,直接空指针。这个在普通 C# 里也有,但 ILRuntime 下更隐蔽,因为适配器的构造顺序和原生不一样。解决办法很简单:构造函数里不要调虚方法。

第二个坑是泛型基类。热更类继承了一个泛型基类Base<T>,适配器生成工具对泛型的支持有限,生成的代码可能编译不过。实测下来,泛型基类的跨域继承最好避免,如果必须用,把泛型参数具体化,比如Base<int>和Base<string>分别生成适配器。

第三个坑是接口和抽象类的混用。基类是抽象类,同时实现了接口,热更子类继承抽象类并实现接口方法。这种情况下适配器需要同时处理抽象方法转发和接口方法映射,生成工具偶尔会漏掉接口方法。解决办法是在热更侧显式实现接口方法,不要依赖基类的实现。

第四个坑是热重载。开发期改了热更 DLL 的继承结构,重新加载时旧的适配器还在内存里,导致类型冲突。必须完全重启 appdomain,不能只重新加载 DLL。这个在编辑器里调试时特别容易忘。

8. 继承体系的扩展与后续演进

把继承跑通之后,下一步可以考虑几个方向。一是把适配器生成纳入自动化构建流程,每次热更 DLL 编译完自动触发绑定生成,避免手动操作遗漏。二是建立继承规范,比如规定基类必须有无参构造函数、虚方法必须有默认实现、禁止在构造函数里调虚方法,从源头减少问题。三是监控跨域调用性能,在适配器里埋点统计调用次数和耗时,找出热点方法针对性优化。

这套继承机制跑顺之后,热更代码的组织方式就跟普通 C# 项目差不多了,你可以放心地用面向对象的方式组织业务逻辑,而不用为了热更牺牲代码结构。我在几个中大型项目里用下来,只要适配器生成和规范执行到位,继承相关的线上问题基本可以控制在个位数。

RELATED READING

延伸阅读

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