想拒绝呼吸 发表于 2023-8-15 16:09

基于Puerts的编纂器UI开发-Mixin的非最佳实践

Puerts是一个相对较新的可适用于UE引擎的脚本插件,虽然在性能上还没有颠末实际的查验但是个人认为TS的语法风格会比Lua更适合进行工程化的开发,因此在Puerts发布初期我就开始测验考试使用Puerts做Demo的开发。
由于Puerts发布的时间还相对较短,缺乏较好的实践指引,因此把本身的部门实践经验进行分享,但愿能够抛砖引玉
需求布景

出于特殊的项目需求,我们需要开发大量的UI界面,此中包含Editor界面也包含Runtime的界面,自然而然的会想到使用Puerts来支撑界面逻辑的开发,同时由于Editor界面和Runtime界面的需求量相当所以我但愿可以使用同一种开发流程来措置Editor和Runtime的界面(这个思路就目前来看可能不是最佳的实践)
基础方案

Puerts对UE的撑持相对丰硕,即我们可以使用多种范式来进行开发,基于我的工作经验主要会想到两种风格:

[*]通过TS调用UE的接口来实现逻辑(动态对象主要由TS逻辑持有)
[*]通过TS重载UE的接口来实现逻辑 (动态对象主要由C++逻辑持有)
这两种用法并非冰炭不洽,从我的角度来看也并无明显的优劣之分,但是出于尽可能框架简单及尽可能符合UE现有流程的思路我采用了后者,即主要通过TS来重载C++的接口来实现对脚本层的使用。
PuerTs撑持的TS重载UE的方式有两种:

[*]Mixin
[*]TS类生成蓝图类
Mixin和TS类生成蓝图类的实此刻道理上是一致的,区别只是在于自动导出蓝图类是自动的,而Mixin则需要手动控制,由于TS类生成的蓝图不适用于UMG编纂器而我但愿可以使用UMG来构建UI因此只能使用手动Mixin的方式,事实上我也认为手动Mixin的方式会更灵活更容易控制一些。
在确定了基本的方案之后就是进行相关的实现,Mixin本身的使用并不困难,在TS层只需要调用blueprint.mixin(); 函数即可,详细使用方式可以参考ref=”https://github.com/chexiongsheng/puerts_unreal_demo/blob/master/TsProj/UsingMixin.ts”>官方文档Mixin 部门,因此我们就会有一个如下的创建UI的流程:

[*]创建UMG Asset,进行UI布局搭建
[*]创建TS文件并实现需要重载的函数(可以声明在CPP类中,也可以声明在蓝图中)
[*]加载UMG Asset并通过mixin创建混合后的UClass
[*]使用混合后的UClass来创建需要的Widget对象
如我上文所求的一致在这个流程中除了需要额外调用一次Mixin之外其他的所有流程都适合UE的默认用法一致的,那么在这个流程中就只剩下两个问题了:

[*]如何调用Mixin
[*]如何将创建出来的UMG作为编纂器UI打开
1. 如何调用Mixin

可能听着会有点奇怪,对于如何调用Mixin这个接口官方文档中给了很详细的示例,为什么还说这会是一个问题呢?
问题在之一在于官方提供的调用方式必需要在TS层调用才可以,而我则但愿可以在CPP层进行调用。如上文所言我认为使用TS作为脚本语言第一个需要存眷的问题就是UObject对象由谁创建,由谁持有,在这个问题上由于我处事于一个较小的团队,所以我但愿框架简单易懂,落在应用上就是但愿仅用重载UE接口这一方式来实现逻辑,避免在TS层有过多的互相引用(类似于Unlua的设计思路)。因此我但愿Mixin函数的调用实在CPP层进行的,而Puerts目前的原生撑持中该函数是没有对CPP层开放的,因此我定义了一个动态委托对象并在创建JS虚拟机时在js层注册委托,并在需要调用Mixin函数的时候调用相应的委托,代码示例如下
//Cpp
DECLARE_DYNAMIC_DELEGATE_RetVal_SixParams(UClass*, FScriptMixinDelegate, UClass*, ParentClass, const FString&, ModulePath,
        bool, ObjectTakeByNative, bool, Inherit, bool, NoMixinedWarning, bool, ReMixed);

UCLASS()
class UContext: public UObject{
//...
FScriptMixinDelegate ScriptMixInHandle;
UClass* execMixin(...);
}

//TS
function _Mixin(...){...}
RuntimeContext.ScriptMixInHandle.Bind(_Mixin)

//CPP调用
GameScript = MakeShared<puerts::FJsEnv>(std::make_shared<puerts::DefaultJSModuleLoader>(TEXT(”JavaScript”)),
                                        std::make_shared<puerts::FDefaultLogger>(),
                                        8080);
UContext* RuntimContext = NewObject<UContext>();
Arguments.Add(TPair<FString, UObject*>{TEXT(”Context”), RuntimContext});
GameScript->Start(ModulePath,Arguments);

RuntimContext->execMixin()但其实这种方案的独一优势就在于实现简单,在实际调用的时候需要先调用到TS层,后面又会调用到CPP层,CPP层完成真正的mixin后又会将UClass对象返回到JS层进而再返回到CPP层,颠末了很多次毫无必要的对象传递,长短常浪费性能的,实际更好的做法应该是将函数 FJsEnvImpl::Mixin 开放至CPP层,当然需要一些调整,但是性能上会好得多。
问题之二在于Mixin时的参数应该如何选择,这里指的参数主要是指ObjectTakeByNative和Inherit这两个参数
type MixinConfig = {
objectTakeByNative?:boolean, //默认为false,暗示“stub对象持有ue对象”,为true暗示“ue对象持有stub对象”
inherit?:boolean, //inherit和generatedClass是配合使用的,默认为false,暗示重定向的是原蓝图类,如果为true的话,将会先动态生成一个担任类,然后重定向生成的类,然后该生成类会通过generatedClass字段返回
generatedClass?: Class,
noMixinedWarning?:boolean
};由于我之前已经说了我但愿对象的生命周期统一由CPP来打点所以objectTakeByNative 我就直接置为True,相对斗劲纠结的是inherit 区别注释也说的斗劲清楚,如果为True的话就会创建一个新的UClass并在新的UClass长进行重定向,如果为False则在本来的UClass长进行重定向。针对于常规的需求来看两种使用方式区别不大,最主要的区别在于对于Inherit为false的情况,重定向会对之前创建出来的UObject也生效,如果Inherit为false则不能如此,而且需要让遍地代码都有意识的使用重定向后的UClass。但是由于我但愿在编纂器下使用Puerts并基于Mixin和UMG来构建编纂器面板,在这种情况下就必需要采用创建新的UClass的方式来进行重定向。
2. 如何将创建出来的UMG作为编纂器UI打开

这个问题较为简单,可以参考官方的EditorUtilsWidget的实现。由于UMG实际上是对Slate的封装,所以创建出来对应的Widget之后可以直接使用TakeWidget取到Slate的Widget对象,并将其塞到一个Tab中即可,实现如下
//UCustomWidget
SharedRef<SDockTab> UCustomWidget::SpawnEditorUITab(const FSpawnTabArgs& SpawnTabArgs){
        auto DockTab = SNew(SDockTab)
                .Label(GetDisplayName())
                .Content()
                [
                        SNew(SVerticalBox)
                                + SVerticalBox::Slot()
                                .HAlign(HAlign_Fill)
                                [
                                        TakeWidget()
                                ];
                ];
        return DockTab;
}

//创建使用Widget

UCustomWidget* Widget = ...//创建逻辑自定义,一般Create出来就可以了
GetCustomTabManager()->RegisterTabSpawner(TabId, FOnSpawnTab::CreateUObject(Widget , &UCustomWidget::SpawnEditorUITab));
//此处需要一个自行实现GetCustomTabManager函数,按照需求可以直接使用FGlobalTabmanager::Get()或者本身创建一个新的TablManager编纂器下的重编译问题

实际上使用Mixin的方式来制作编纂器UI会有非常多的坑点,此中最大的坑点在于如何措置编纂器下对相应UI界面的改削,目前为止我没有找到完美的解决方案,只有一个斗劲普通的解决方案。大体思路非常简单,在使用担任的重定向方式时即便此时打开了重定向的蓝图类进行编纂也不会直接崩溃,只要在发生蓝图类的编译逻辑时再进行一次Mixin即可(这些逻辑只需要在编纂器下做即可):
//在编纂器下可以监听蓝图编纂的开始与结束
GEditor->OnBlueprintPreCompile().AddUObject(this, &UContext::OnBlueprintPreCompiled);
GEditor->OnBlueprintCompiled().AddUObject(this, &UContext::OnBlueprintCompiled);

//在编译开始时将重定向清理掉,这里包罗要清理掉已经生成的ClassTemplate,也需要注意如果是担任式的重定向,需要重置重定向后生成Class的父类,将其指向不会被编译改削的类
void UContext::OnBlueprintPreCompiled(UBlueprint* Blueprint)
{
#if WITH_EDITOR
        auto Class = Blueprint->GeneratedClass;
        JsEnv->TryReleaseClassTemplate(Class);

        TMap<FString, FMixedClassInfo> CompilingClassInfos;
        UnMixed(Class, &CompilingClassInfos);
       
        this->CompilingClass.Append(CompilingClassInfos);
#endif
}

void UContext::RestoreMixedClass(UClass* Raw, UClass* Mixed)
{
        if(Mixed == Raw)
        {
                if(ScriptMixInHandle.IsBound())
                        UClass* MixRet = ScriptMixInHandle.Execute(Raw, ””, false, false, false, true);
        }
        else
        {
                UBlueprintGeneratedClass* GeneratedParentClass = dynamic_cast<UBlueprintGeneratedClass*>(Raw);
                UClass* NativeSuperClass = UObject::StaticClass();
                if(GeneratedParentClass )
                {
                        NativeSuperClass = GetParentNativeClass(GeneratedParentClass);
                }
                //之所以需要重设父类是因为对于蓝图类 ,父类在编译时也会触发子类的编译,而Mixin生成出来的类是没有对应的蓝图资源的,会导致引擎崩溃
                (Mixed)->SetSuperStruct(NativeSuperClass);
                UClass* MixRet = ScriptMixInHandle.Execute(Mixed, ””, false, false, false, true);
        }
}

//可以在蓝图编译完之后对之前
bool UContext::ReMixinClass(...)
{
        if(!ScriptMixInHandle.IsBound())
        {
                return false;
        }
        //从头重定向的逻辑可以完全照搬JSEnv中重定向的逻辑,只是此处要注意必需从头生成一次TokenSrteam,否则可能会导致GC犯错
#if ENGINE_MAJOR_VERSION == 4 && ENGINE_MINOR_VERSION > 12
        // 拷贝创建的Class需要手动从头创建ReferenceTokenStream
        TargetClass->AssembleReferenceTokenStream(true);//New->ReferenceTokenStream = To->ReferenceTokenStream;
#endif

}结语

将Puerts用于编纂器下的开发时会发现其实无论使用Mixin还是TS生成蓝图的方式都不是很合适,城市遇到和UE原生编纂器功能的复杂耦合与冲突,所以如果想要将Puerts用于编纂器的扩展或许应该测验考试以脚本为主的开发方式,例如使用类似官方Python插件的开发方式。
以上对于Puerts的使用是一次较为不成熟的实践方式,但应该算得上是一次有价值的测验考试
其他信息

需要注意的是,mixin的情况下分歧于直接TS生成蓝图的方案实际生成JS对象的时机是在第一次调用TS函数时构造的
页: [1]
查看完整版本: 基于Puerts的编纂器UI开发-Mixin的非最佳实践