资讯详情

资讯详情

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 Actionfloat 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 下更隐蔽因为适配器的构造顺序和原生不一样。解决办法很简单构造函数里不要调虚方法。第二个坑是泛型基类。热更类继承了一个泛型基类BaseT适配器生成工具对泛型的支持有限生成的代码可能编译不过。实测下来泛型基类的跨域继承最好避免如果必须用把泛型参数具体化比如Baseint和Basestring分别生成适配器。第三个坑是接口和抽象类的混用。基类是抽象类同时实现了接口热更子类继承抽象类并实现接口方法。这种情况下适配器需要同时处理抽象方法转发和接口方法映射生成工具偶尔会漏掉接口方法。解决办法是在热更侧显式实现接口方法不要依赖基类的实现。第四个坑是热重载。开发期改了热更 DLL 的继承结构重新加载时旧的适配器还在内存里导致类型冲突。必须完全重启 appdomain不能只重新加载 DLL。这个在编辑器里调试时特别容易忘。8. 继承体系的扩展与后续演进把继承跑通之后下一步可以考虑几个方向。一是把适配器生成纳入自动化构建流程每次热更 DLL 编译完自动触发绑定生成避免手动操作遗漏。二是建立继承规范比如规定基类必须有无参构造函数、虚方法必须有默认实现、禁止在构造函数里调虚方法从源头减少问题。三是监控跨域调用性能在适配器里埋点统计调用次数和耗时找出热点方法针对性优化。这套继承机制跑顺之后热更代码的组织方式就跟普通 C# 项目差不多了你可以放心地用面向对象的方式组织业务逻辑而不用为了热更牺牲代码结构。我在几个中大型项目里用下来只要适配器生成和规范执行到位继承相关的线上问题基本可以控制在个位数。
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →