Puerts 蓝图 Mixin 机制深度指南:用 TypeScript 增强与覆盖 UE 蓝图类
【免费下载链接】puertsPUER(普洱) Typescript. Let's write your game in UE or Unity with TypeScript.项目地址: https://gitcode.com/GitHub_Trending/pu/puerts
在 Puerts 的 Unreal 集成方案中,blueprint.mixin提供了一种将 TypeScript 类(下称 TS 类)的能力注入到既有蓝图类的能力:把 TS 类的方法、字段与事件逻辑"混合"进蓝图类,从而在不改动蓝图资产的情况下,用脚本扩展甚至覆盖蓝图实现。本文围绕 doc/unreal/zhcn/mixin.md 展开,结合仓库中的脚本与 C++ 实现,系统讲解 mixin 的核心机制、基本用法、进阶配置(生命周期、继承重定向、super调用、原生类混入等)与注意事项,读完即可在真实项目中使用该能力完成蓝图类的脚本化改造。
什么是蓝图 Mixin
把一个 TS 类(假设是类 A)mixin 到一个蓝图类(类 B)的能力,核心行为如下:
- 如果 A 和 B 都有同样的函数,A 的逻辑会替换 B 的;
- 支持 UE 的事件(比如
ReceiveBeginPlay); - 可新增方法或字段。
在底层,这个能力由 JS 侧入口blueprint.mixin与 C++ 侧的UJSGeneratedClass::Mixin共同完成。JS 侧实现在 unreal/Puerts/Content/JavaScript/puerts/uelazyload.js#L274-L303:它遍历 mixin 类原型上的函数属性,收集成一个mixinMethods表,然后调用原生函数__tgjsMixin(to.StaticClass(), mixinMethods, ...)完成 UE 侧的 UFunction 注册与替换;C++ 侧在 unreal/Puerts/Source/JsEnv/Private/JSGeneratedClass.cpp#L179-L260 中为每个被 mixin 的函数复制生成一个UJSGeneratedFunction(以__puerts_mixin__结尾命名),并把Super的NativeFunc重定向为execCallMixin,最终调用到 TS 实现。
特点
- 安全:如果 TS 类和蓝图类有同名函数,将会检查两者的兼容性(符合 TS 的协变逆变规则),不兼容的签名会报错,避免运行时才暴露问题;
- 高效:TS 类可以调用蓝图类的方法,且有代码提示;
- 强大:
- TS 可新增方法(但蓝图不可见);
- TS 能新增字段(但蓝图不可见);
- 支持网络相关方法(RPC)的 mixin;
- 支持事件 mixin 并能被回调;
- 对象生命周期支持脚本持有和引擎持有两种模式;
- 支持原生类的
BlueprintNativeEvent、BlueprintImplementableEvent方法的 mixin。
注意事项
如果要覆盖 UE 的事件,要注意被 mixin 的类中有对应的事件(逻辑可以为空),否则在子类调用时可能会有可能调用不到 TS 的逻辑。这是 mixin 事件覆盖的已知边界,参见仓库历史 issue #1762 的讨论结论。
基本用法
完整可运行例子可参考仓库配套演示工程中的 TypeScript/UsingMixin.ts(注:该示例位于外部示例工程),将 Start 脚本改为 UsingMixin 即可运行。下面按步骤拆解。
第一步:加载被 mixin 的蓝图类
使用UE.Class.Load加载蓝图类路径(注意路径以_C结尾表示生成的类),再通过blueprint.tojs把 UE 类对象转换为 TS 可用的类(构造器):
let ucls = UE.Class.Load('/Game/StarterContent/MixinTest.MixinTest_C'); const MixinTest = blueprint.tojs<typeof UE.Game.StarterContent.MixinTest.MixinTest_C>(ucls);blueprint.tojs的实现在 unreal/Puerts/Content/JavaScript/puerts/uelazyload.js#L269,其类型签名(见 unreal/Puerts/Typing/puerts/index.d.ts#L53)为tojs<T extends typeof Object>(cls: Class): T。
第二步:编写 TS 扩展类
TS 扩展类的声明要点:先声明同名interface继承目标蓝图类的 TS 类型,再在class中编写要覆盖或新增的方法。
interface Loggable extends UE.Game.StarterContent.MixinTest.MixinTest_C {}; class Loggable { // 可以覆盖蓝图对应的函数,函数签名和 MixinTest_C 声明的不兼容(不需要严格一致,能满足协变逆变要求即可)会报错 Log(msg: string): void { console.log(this.GetName(), msg); console.log(`1 + 3 = ${this.TsAdd(1, 3)}`); } // 蓝图没有的纯 TS 方法 TsAdd(x: number, y: number): number { console.log(`Ts Add(${x}, ${y})`) return x + y; } }注意:这里的interface Loggable extends ...与class Loggable同名合并,是 TypeScript 的声明合并技巧——interface 负责让类实例拥有蓝图类全部成员的类型(包括GetName等),class 则提供具体实现。Log覆盖蓝图同名函数,TsAdd是蓝图没有的新增方法。
第三步:执行 mixin
调用blueprint.mixin,传入被 mixin 的蓝图类(TS 构造器)与 TS 扩展类:
const MixinTestWithMixin = blueprint.mixin(MixinTest, Loggable);第四步:使用新类
MixinTestWithMixin即为新类,可直接用于生成 Actor:
world.SpawnActor(MixinTestWithMixin.StaticClass(), undefined, UE.ESpawnActorCollisionHandlingMethod.Undefined, undefined, undefined) as Loggable;blueprint.mixin的完整类型签名(unreal/Puerts/Typing/puerts/index.d.ts#L54-L57):
function mixin<T extends typeof Object, R extends InstanceType<T>>(to: T, mixinMethods: new (...args: any) => R, config?: MixinConfig): { new (Outer?: Object, Name?: string, ObjectFlags?: number): R; StaticClass(): Class; };进阶用法
前置知识:stub 对象与生命周期
一个 UE 对象传入到 TS,TS 侧会建立一个 stub(TS)对象与之相对应(TS 调用这个 stub 对象会被转发到真实的 UE 原生调用)。在 Puerts 中,它们的生命周期关系有两种:
- stub 对象由 JS GC 管理,stub 对象持有 UE 对象的强引用(下称"stub 对象持有 UE 对象")
- 如果 stub 对象在 TS 无引用,将会被 GC,进而释放对 UE 对象的强引用;
- 如果进一步在 UE 引擎也没有该 UE 对象,该 UE 对象会被 GC。
- UE 对象由 UE GC 管理,UE 对象持有 stub 对象的强引用(下称"UE 对象持有 stub 对象")
- 如果 UE 对象在 UE 引擎无引用,该 UE 对象会被 GC,进而释放对 stub 对象的强引用;
- 如果进一步在 TS 也没有引用该 stub 对象,该 stub 对象会被 GC。
这两种模式分别对应下方MixinConfig.objectTakeByNative的false与true。
blueprint.mixin 的参数 3(MixinConfig)
该参数声明(typing 中实际还包含noMixinedWarning,见 unreal/Puerts/Typing/puerts/index.d.ts#L52):
type MixinConfig = { objectTakeByNative?: boolean, inherit?: boolean, generatedClass?: Class, noMixinedWarning?: boolean };objectTakeByNative:默认为false,表示"stub 对象持有 UE 对象";为true表示"UE 对象持有 stub 对象"。inherit与generatedClass:配合使用。默认为false,表示重定向的是原蓝图类;如果为true,将会先动态生成一个继承类,然后重定向生成的类,该生成类会通过generatedClass字段返回。noMixinedWarning:当目标函数已被另一个 VM(另一个 JS 环境)mixin 过时,控制是否打印警告。在 JS 侧它被直接传给原生__tgjsMixin(见 unreal/Puerts/Content/JavaScript/puerts/uelazyload.js#L285);对应 C++ 实现中,若Warning为真且函数已被 mixin,会输出日志 "Try to mixin a function[%s:%s] already mixin by anthor vm"(unreal/Puerts/Source/JsEnv/Private/JSGeneratedClass.cpp#L202-L210)。
在 JS 侧实现中(unreal/Puerts/Content/JavaScript/puerts/uelazyload.js#L274-L303),mixin返回前还会把mixinMethods中未在返回类原型上定义的方法补挂到原型上,从而保证新增的纯 TS 方法也能被调用;当config.inherit为真时,config.generatedClass会被赋值为原生生成的类。
super 关键字的说明
假设有个蓝图类MixinSuperTestDerived继承了蓝图类MixinSuperTestBase,这两个类都有Foo方法,我们要通过 mixin 覆盖MixinSuperTestDerived上的Foo,在 TS 逻辑中需要调用基类(蓝图类)的Foo要怎么处理?
直接在前面介绍的不extends任何类的 mixin 类中调用super会报错:
class DerivedClassMixin { Foo(): void { console.log("i am ts mixin"); super.Foo(); } }上述代码会报语法错误。这时可以通过添加一个中转类来解决问题:先用interface/class同名合并声明一个指向蓝图基类的占位类,再通过Object.setPrototypeOf把它的原型链接到蓝图基类 TS 类的原型上,最后让 mixin 类extends这个占位类,即可合法使用super.Foo():
interface MixinSuperTestBasePlaceHold extends UE.Game.StarterContent.MixinSuperTestBase.MixinSuperTestBase_C {}; class MixinSuperTestBasePlaceHold {} Object.setPrototypeOf(MixinSuperTestBasePlaceHold.prototype, MixinSuperTestBase.prototype); class DerivedClassMixin extends MixinSuperTestBasePlaceHold { Foo(): void { console.log("i am ts mixin"); super.Foo(); } }其原理是:extends之后,TS 会生成对MixinSuperTestBasePlaceHold.prototype上Foo的super调用,而该占位类的原型链已经被接驳到蓝图基类的 TS 原型上,于是super.Foo()会被转发到蓝图基类的真实实现。
新增字段
新增字段其实是存放在 stub 对象里,因而:
objectTakeByNative为false时,需要保持对 stub 对象的引用,否则 stub 对象释放后,UE 对象回传将会建立一个新对象,原来的数据就丢失了;objectTakeByNative为true不需要保持 stub 对象引用,但注意不要期望通过持有 stub 对象进而引用 UE 对象,该 UE 对象应保证被引擎持有。
这与前置知识的两种生命周期一一对应:字段数据存在于 stub,stub 存活时间决定了字段数据的存续时间。
原生类的 mixin
只支持BlueprintNativeEvent、BlueprintImplementableEvent方法。比如如下 C++ 声明的函数:
class UMainObject : public UObject { GENERATED_BODY() public: UFUNCTION(BlueprintNativeEvent) int32 Mult(int32 a, int32 b) const; UFUNCTION(BlueprintImplementableEvent) int32 Div(int32 a, int32 b) const; int32 Mult_Implementation(int32 a, int32 b) const { UE_LOG(LogTemp, Warning, TEXT("wrong implementation div %d %d"), a, b); return a + b; } };TypeScript 这样 mixin:
let obj = new UE.MainObject(); console.log('before mixin start....') obj.Mult(1, 2); obj.Div(4, 5); console.log('before mixin end....') class Calc { // 声明为 BlueprintNativeEvent 的原生方法 Mult(x: number, y: number): number { console.log(`Ts Mult(${x}, ${y})`) return x * y; } // 声明为 BlueprintImplementableEvent 的方法 Div(x: number, y: number): number { console.log(`Ts Div(${x}, ${y})`) return x / y; } } interface Calc extends UE.MainObject {}; blueprint.mixin(UE.MainObject, Calc); console.log('after mixin start....') obj.Mult(1, 2); obj.Div(4, 5); console.log('after mixin end....')输出:
before mixin start.... wrong implementation div 1 2 before mixin end.... after mixin start.... Ts Mult(1, 2) Ts Div(4, 5) after mixin end....可以看到,即使是已经new出来的对象,mixin 后调用也会调用到新的 TS 方法——这是因为 mixin 重定向的是类上的 UFunction 的NativeFunc,对既有实例同样生效。
从源码看,mixin 对原生类与蓝图类的处理路径一致:UJSGeneratedClass::Mixin会把原函数复制为UJSGeneratedFunction,将Super(原函数)的FunctionFlags加上FUNC_Native并重定向其NativeFunc为execCallMixin(unreal/Puerts/Source/JsEnv/Private/JSGeneratedClass.cpp#L240-L257),从而"让 UE 不走解析",直接进入 TS 调用链;调用时由UJSGeneratedFunction::execCallMixin通过InvokeMixinMethod把参数转发给 TS 实现(unreal/Puerts/Source/JsEnv/Private/JSGeneratedFunction.cpp#L29-L50)。
C++ BlueprintNativeEvent 函数 bug 修复
如果你的 C++ 函数声明为BlueprintNativeEvent的话,如果有 bug,可以用该功能替换成正确逻辑。即:不需要改动 C++ 源码与重新编译,直接在 TS 侧用 mixin 覆盖BlueprintNativeEvent方法(连同其_Implementation逻辑一起被替换),即可修复线上/编辑器中的函数逻辑。
撤销 Mixin:unmixin
与mixin配套,blueprint命名空间还提供了unmixin用于还原(unreal/Puerts/Content/JavaScript/puerts/uelazyload.js#L307-L311):
function unmixin(to: typeof Object): void;其实现为调用__tgjsMixin(to.StaticClass(), {}, undefined, undefined, undefined, true)——传入空的方法表并置 unmixin 标志。C++ 侧对应的还原逻辑在UJSGeneratedClass::Restore(unreal/Puerts/Source/JsEnv/Private/JSGeneratedClass.cpp#L262-L356):它会遍历类上所有UJSGeneratedFunction,恢复其Original指向的原函数的NativeFunc与FunctionFlags、还原被重命名(前缀__puerts_old__)的旧函数,并清理函数映射缓存,从而实现 mixin 效果的回滚。
小结
blueprint.mixin让 TS 可以在运行时覆盖蓝图类(乃至原生类的事件函数)的实现、新增方法与字段,同时通过 TS 的协变逆变签名检查保证覆盖的安全性,通过objectTakeByNative灵活控制 stub 对象与 UE 对象的持有关系。使用时牢记三点:覆盖 UE 事件前确认类中存在对应事件定义;新增字段依赖 stub 对象存活,需按生命周期模式正确保持引用;跨 VM 重复 mixin 同一函数会产生警告,可用noMixinedWarning控制。相关源码入口:JS 侧 mixin 实现、C++ 侧 UJSGeneratedClass::Mixin、类型声明,文档原文见 doc/unreal/zhcn/mixin.md。
【免费下载链接】puertsPUER(普洱) Typescript. Let's write your game in UE or Unity with TypeScript.项目地址: https://gitcode.com/GitHub_Trending/pu/puerts
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考