深入UE5——GameFeatures架构(五)AddComponents

引言

上篇GF的状态机说完了,相信大家对于一个GF插件的加载流程已经有了大致的理解。那在日常的GF使用中,其中最频繁使用的一个GFA就是UGameFeatureAction_AddComponents,因此了解它的背后机制也是很重要的。大致对于其背后的工作的类有了了解之后,在应用中万一发现自己的AddComponents Action工作不正常,至少有一点能力可以顺藤摸瓜调查原因。

应用方式

整体的应用方式其实也挺简单明了,Game Feature里的AddComponents Action,给CoreGame里的Actor添加组件。值得注意的是,多个不同GameFeature可以一起发挥作用,给CoreGame里的同一个Actor添加组件。因此也得注意一下,有可能不同的Component被添加到同一个Actor身上后在逻辑上会起冲突。所以在设计实现Component的时候,要注意尽量把组件的逻辑拆分设计的独立一点,不要依赖别的组件,也不要去给别的组件捣乱,不要用某些黑科技修改Actor的默认行为。

image_1fv7u3apl1sfr1v221uar14ndumul.png-124.3kB

AddComponents作用机制

现在来详细说一下AddComponents的作用机制,这些都是在ModularGameplay模块中UGameFrameworkComponentManager来实现的,我们注意到它是从GameInstanceSubsystem继承下来的,因此它是只能在游戏内运行时使用的。GFCM内部的实现还是蛮复杂和精巧的,可以做到在一个GF激活后,会把激活前已经存在场景中Actor,还有激活后新生成的Actor,都会被正确的添加上Component。

image64.png-176.8kB

这个顺序无关的逻辑是怎么做到的呢?关键的逻辑分为两大部分:

一,AddReceiver的注册

前文反复提过只有那些调用了AddReceiver的Actor才可以正常的被AddComponents,那AddReceiver的调用到底内部做的是什么事情呢?最终代码如下:

  1. 可以看到GFCM里的成员变量TMap<FComponentRequestReceiverClassPath, TSet<UClass*>> ReceiverClassToComponentClassMap记录了ActorClass-ComponentClass集合的映射,代表一个类型的Actor可以在上面添加多个不同的Component实例。FComponentRequestReceiverClassPath其实就是一个TArray<FName>,从OuterPackage到自身类的名字字符串路径而已,用来唯一定位一个类。
  2. 如果发现这个ActorClass已经在GFD的Action里被注册过(ReceiverClassToComponentClassMap里有值),那说明Action的激活发生在SpawnActor之前,这个时候新生成的Actor身上就可以直接添加相对应的组件了,因此就可以CreateComponentOnInstance了。
  3. 可以注意到AllReceivers的添加,用WITH_EDITOR包了起来,说明这个只在编辑器模式下发生,在Runtime下不发生。那有同学就问了,如果我是先SpawnActor,然后再激活GF,这个时候AddReceiver好像就没有起作用啊?你的疑问是正确的,确实在runtime模式下,其实这个时候AddReceiver是空的,并不会添加组件。这也正常,毕竟这个时候GF还不知道会不会被激活呢。大家可能最奇怪的是,这个时候AddReceiver为什么不做一点记录呢?这个问题我们等到讲解完Action里AddComponents Action激活的的逻辑后再对照着来看会更清楚一些。
void UGameFrameworkComponentManager::AddReceiverInternal(AActor* Receiver)
{
#if WITH_EDITOR
    AllReceivers.Add(Receiver); //编辑器模式下记录,发出警告
#endif

    for (UClass* Class = Receiver->GetClass(); Class && Class != AActor::StaticClass(); Class = Class->GetSuperClass())
    {
        FComponentRequestReceiverClassPath ReceiverClassPath(Class);
        if (TSet<UClass*>* ComponentClasses = ReceiverClassToComponentClassMap.Find(ReceiverClassPath)) //如果已经注册过
        {
            for (UClass* ComponentClass : *ComponentClasses)
            {
                if (ComponentClass)
                {
                    CreateComponentOnInstance(Receiver, ComponentClass);
                }
            }
        }
        //...
    }
}

二,AddComponentRequest的作用机制

根据前文的介绍,每个GFA在Activating的时候都会调用OnGameFeatureActivating。而对于UGameFeatureAction_AddComponents这个来说,其最终会调用到AddToWorld。

void UGameFeatureAction_AddComponents::AddToWorld(const FWorldContext& WorldContext, FContextHandles& Handles)
{
    UWorld* World = WorldContext.World();
    UGameInstance* GameInstance = WorldContext.OwningGameInstance;
    if ((GameInstance != nullptr) && (World != nullptr) && World->IsGameWorld())
    {
        if (UGameFrameworkComponentManager* GFCM = UGameInstance::GetSubsystem<UGameFrameworkComponentManager>(GameInstance))
        {
            const ENetMode NetMode = World->GetNetMode();
            const bool bIsServer = NetMode != NM_Client;
            const bool bIsClient = NetMode != NM_DedicatedServer;
            for (const FGameFeatureComponentEntry& Entry : ComponentList)
            {
                const bool bShouldAddRequest = (bIsServer && Entry.bServerComponent) || (bIsClient && Entry.bClientComponent);
                if (bShouldAddRequest)
                {
                    if (!Entry.ActorClass.IsNull())
                    {
                        TSubclassOf<UActorComponent> ComponentClass = Entry.ComponentClass.LoadSynchronous();
                        if (ComponentClass)
                        {   //真正发生调用
                            Handles.ComponentRequestHandles.Add(GFCM->AddComponentRequest(Entry.ActorClass, ComponentClass));
                        }
                        else if (!Entry.ComponentClass.IsNull())
                        {
                        }
                    }
                }
            }
        }
    }
}

在判断一番是否是游戏世界,是否服务器客户端之类的配置之后。最后真正发生发生的作用是Handles.ComponentRequestHandles.Add(GFCM->AddComponentRequest(Entry.ActorClass, ComponentClass));,因此真正的逻辑就在里面:

TSharedPtr<FComponentRequestHandle> UGameFrameworkComponentManager::AddComponentRequest(const TSoftClassPtr<AActor>& ReceiverClass, TSubclassOf<UActorComponent> ComponentClass)
{
    FComponentRequestReceiverClassPath ReceiverClassPath(ReceiverClass);
    UClass* ComponentClassPtr = ComponentClass.Get();

    FComponentRequest NewRequest;
    NewRequest.ReceiverClassPath = ReceiverClassPath;
    NewRequest.ComponentClass = ComponentClassPtr;
    int32& RequestCount = RequestTrackingMap.FindOrAdd(NewRequest);
    RequestCount++;

    if (RequestCount == 1)  //第一次匹配这种组合
    {
        TSet<UClass*>& ComponentClasses = ReceiverClassToComponentClassMap.FindOrAdd(ReceiverClassPath);    //查找或注册类
        ComponentClasses.Add(ComponentClassPtr);

        if (UClass* ReceiverClassPtr = ReceiverClass.Get())
        {
            UGameInstance* LocalGameInstance = GetGameInstance();
            if (ensure(LocalGameInstance))
            {
                UWorld* LocalWorld = LocalGameInstance->GetWorld();
                if (ensure(LocalWorld))
                {
                    for (TActorIterator<AActor> ActorIt(LocalWorld, ReceiverClassPtr); ActorIt; ++ActorIt)  //遍历场景里所有该类型Actor
                    {
                        if (ActorIt->IsActorInitialized())  //已经调用过BeginPlay
                        {
#if WITH_EDITOR //只是在Editor模式下发出报错。
                            ensureMsgf(AllReceivers.Contains(*ActorIt), TEXT("You may not add a component request for an actor class that does not call AddReceiver/RemoveReceiver in code! Class:%s"), *GetPathNameSafe(ReceiverClassPtr));
#endif
                            CreateComponentOnInstance(*ActorIt, ComponentClass); //依然会创建组件
                        }
                    }
                }
            }
        }
        else
        {
            // Actor class is not in memory, there will be no actor instances
        }

        return MakeShared<FComponentRequestHandle>(this, ReceiverClass, ComponentClass);
    }

    return nullptr;
}

这一段代码其实有挺多细节点值得一说的:

  1. RequestTrackingMap是TMap<FComponentRequest, int32>的类型,可以看到Key是ReceiverClassPath和ComponentClassPtr的组合,那为什么要用int32作为Value来计数呢?这是因为在不同的GF插件里有可能会出现重复的ActorClass-ComponentClass的组合,比如GF1和GF2里都注册了A1-C2的配置。那在卸载GF的时候,我们知道也只有把两个GF1和GF2统统都卸载之后,这个A1-C2的配置才失效,这个时候这个int32计数才=0。因此才需要有个计数来记录生效和失效的次数。
  2. ReceiverClassToComponentClassMap会记录ActorClass-多个ComponentClass的组合,其会在上文的AddReceiver的时候被用来查询。
  3. 同样会发现根据代码逻辑,ensureMsgf这个报错也只在WITH_EDITOR的时候才生效。在Runtime下,依然会不管不顾的根据GFA里的配置为相应ActorClass类型的所有Actor实例添加Component。因此这个时候我们明白,AddReceiver的调用准确的说不过是为了GF生效后为新Spawn的Actor添加一个依然能添加相应组件的机会。
  4. 返回值为何是TSharedPtr<FComponentRequestHandle>?又为何要Add进Handles.ComponentRequestHandles?其实这个时候就涉及到一个逻辑,当GF失效卸载的时候,之前添加的那些Component应该怎么卸载掉?所以这个时候就采取了一个办法,UGameFeatureAction_AddComponents这个Action实例里(不止一个,不同的GF会生成不同的UGameFeatureAction_AddComponents实例)记录着由它创建出来的组件请求,当这个GF被卸载的时候,会触发UGameFeatureAction_AddComponents的析构,继而释放掉TArray<TSharedPtr<FComponentRequestHandle>> ComponentRequestHandles;,而这是个智能指针,只会在最后一个被释放的时候(其实就是最后一个相关的GF被卸载时候),才会触发FComponentRequestHandle的析构,继而在GFCM里真正的移除掉这个ActorClass-ComponentClass的组合,然后在相应的Actor上删除Component实例。
FComponentRequestHandle::~FComponentRequestHandle()
{
    UGameFrameworkComponentManager* LocalManager = OwningManager.Get();
    if (LocalManager)
    {
        if (ComponentClass.Get())
        {
            LocalManager->RemoveComponentRequest(ReceiverClass, ComponentClass);
        }
        if (ExtensionHandle.IsValid())
        {
            LocalManager->RemoveExtensionHandler(ReceiverClass, ExtensionHandle);
        }
    }
}

RemoveComponentRequest的逻辑我就不演示了。在这个时候我们可以来回聊之前的两个问题了:

思考:为何AddReceiver在Runtime下不先记录Receiver的实例呢?

反过来问,假如每次AddReceiver的时候都调用AllReceivers.Add(Receiver),而RemoveReceiver的时候调用AllReceivers.Remove(Receiver),会怎么样?一是直接可以想到的是在GF还没有生效的时候,这个时候每次添加和移除其实都是在消耗性能,因此在GF生效前的Actor应该至少工作地和原本的消耗一致!二是既然已经在GFA里配置了ActorClass,但是就假如忘记了AddReceiver会怎么样呢?要嘛忽略掉不添加组件,要嘛依然添加组件。前者运作起来会有点限制,假如想给一个现成的项目的Actor添加Component,原项目不做修改的话就达成不了了,这就限制了GF被动态加载的能力。因此尽管还是有点灰色漏洞(GF生效后新SpawnActor不会添加组件),但是也尽量在编辑器模式下给出警告了,至少可以部分工作。

思考:为何要用FComponentRequestReceiverClassPath来Hash而不是直接从UClass*获得Hash?

有些人可能会疑惑ReceiverClassToComponentClassMap为何不能是TMap<UClass*, TSet<UClass*>>? 其实这是因为在GF里配置UGameFeatureAction_AddComponents的时候,用的是弱引用。用TSoftClassPtr弱引用而不是UClass*这种强引用的原因是,这个时候这个GF插件只是配置一个规则,不应该强制引用别的插件模块里的ActorClass。想获得一个UClass*对象,就必须加载特定的模块才能获得这个指针。因此你看上面AddComponentRequest的两个参数都是TSoftClassPtr。对于TSoftClassPtr,其内部本质其实就是字符串路径,因此可以直接解析赋给FComponentRequestReceiverClassPath。那为何Component就可以是UClass*了呢?因为AddComponentRequest这个时候是GF生效的时候,其作用就是要添加相应的Component实例,这个时候当然就必须得加载了。ComponentClass.Get()的调用时机也刚刚好。当已经在ReceiverClassToComponentClassMap里添加完数据,真正要添加组件之前,还要先测试一下if (UClass* ReceiverClassPtr = ReceiverClass.Get())的返回值,这个时候如果Get的值是null的话,说明这个ActorClass还不在内存中,即是有可能这个ActorClass所在的Module还没有加载进来。那就现在不生效,等之后这个Module加载进来后,该ActorClass的BeginPlay调用继而调用AddReceiver之后,到那时就自然再添加组件。而GFA里配置的Component,一般不会定义在别的模块里(想想要真那样,那这个GF定义的规则保证性也太弱了),因此这里ComponentClass就直接是UClass*了。一个小小的点,这里确实有挺多的弯弯绕绕的设计纠结点,希望我的讲述不会让你更加的迷糊。

USTRUCT()
struct GAMEFEATURES_API FGameFeatureComponentEntry
{
    GENERATED_BODY()

    // The base actor class to add a component to
    UPROPERTY(EditAnywhere, Category="Components", meta=(AllowAbstract="True"))
    TSoftClassPtr<AActor> ActorClass;   //弱引用

    // The component class to add to the specified type of actor
    UPROPERTY(EditAnywhere, Category="Components")
    TSoftClassPtr<UActorComponent> ComponentClass;//弱引用

    //...
};  

FComponentRequestReceiverClassPath(const TSoftClassPtr<AActor>& InSoftClassPtr) //解析路径
{
    TArray<FString> StringPath;
    InSoftClassPtr.ToString().ParseIntoArray(StringPath, TEXT("."));
    Path.Reserve(StringPath.Num());
    for (const FString& StringPathElement : StringPath)
    {
        Path.Add(FName(*StringPathElement));
    }
}

三,一套UGameFrameworkComponent

在两大部分之后,再加一个挺重要的部分。ModularGamepla模块其实还帮我们预定义了一些很方便用到的组件。因为一般来说我们想在组件里写一些逻辑的话,常常就需要获得Owner Actor的引用,因此这些写好的组件其实就是帮你写好了一些GetGameMode之类的便利方法而已。但是也推荐我们自己想要定义的组件可以从这些继承。这里以UPawnComponent为例:

UCLASS()
class MODULARGAMEPLAY_API UPawnComponent : public UGameFrameworkComponent
{
    GENERATED_BODY()
public:
    UPawnComponent(const FObjectInitializer& ObjectInitializer);

    template <class T>
    T* GetPawn() const
    {
        return Cast<T>(GetOwner());
    }

    template <class T>
    T* GetPawnChecked() const
    {
        return CastChecked<T>(GetOwner());
    }

    template <class T>
    T* GetPlayerState() const
    {
        return GetPawnChecked<APawn>()->GetPlayerState<T>();
    }

    template <class T>
    T* GetController() const
    {
        return GetPawnChecked<APawn>()->GetController<T>();
    }
};

我把注释和断言都删除了。代码看着简单,但确实挺便利的。否则我们自己创建的Component,想获取外部的Actor再进行一下类型的转换,总得麻烦再写一个胶水函数。这下省了一些事。

那如何定义Component?

那AddComponent的Component部分,我们怎么定义哪些Component以便添加到Actor身上呢?一般来说,我们想要影响CoreGame的方式主要就是通过不同的Component,因此我们就得在游戏逻辑的各个部分定义不同功能的组件,例如UI,动画,输入绑定,GAS等。Component的BeginPlay和EndPlay也是逻辑的入口和出口,对应GF的Activating和Deactivating。我们可以在这里相应的进行一些逻辑。这里我示范了一个功能,做一个UI的组件,往屏幕上添加一个UMG,代码也是非常简单的。

void UViewportWidgetComponent::BeginPlay()
{
    Super::BeginPlay();

    Widget = CreateWidget(GetGameInstance<UGameInstance>(), WidgetClass);   //这个组件生效的时候创建UI
    if (Widget)
    {
        Widget->AddToViewport(ZOrder);
    }
    ApplyInputMode(NewInputMode);
}

void UViewportWidgetComponent::EndPlay(const EEndPlayReason::Type EndPlayReason)
{
    Super::EndPlay(EndPlayReason);

    if (Widget)
    {
        Widget->RemoveFromParent();//这个组件失效的时候销毁UI
        Widget = nullptr;
    }

    ApplyInputMode(RevertInputMode);
}

Component里面一般会写的逻辑有一种是会把Owner的事件给注册进来,比如为Pawn添加输入绑定的组件,会在UActorComponent的OnRegister的时候,把OwnerPawn的Restarted和ControllerChanged事件注册进来监听,以便在合适时机重新应用输入绑定或移除。这里我是想向大家说明,这也是编写Component的一种常用的范式,提供给大家参考。

void UPlayerControlsComponent::OnRegister()
{
    Super::OnRegister();

    UWorld* World = GetWorld();
    APawn* MyOwner = GetPawn<APawn>();

    if (ensure(MyOwner) && World->IsGameWorld())
    {
        MyOwner->ReceiveRestartedDelegate.AddDynamic(this, &UPlayerControlsComponent::OnPawnRestarted);
        MyOwner->ReceiveControllerChangedDelegate.AddDynamic(this, &UPlayerControlsComponent::OnControllerChanged);

        // If our pawn has an input component we were added after restart
        if (MyOwner->InputComponent)
        {
            OnPawnRestarted(MyOwner);
        }
    }
}

那如何定义Actor?

那接下来说下Actor部分,那该如何编写Actor以配合AddComponents的需要呢?

BP Actor

先从软柿子开始捏,从最简单的蓝图Actor开始,再给大家回忆一下,Actor要能支持AddComponent,得先把自己注册为接收者。方法已经说过,也在截图上表明了。

image34.png-164.6kB

C++ Actor

最具扩展性的当然是C++ Actor了,在编写这些C++ Actor的时候,每个新的Actor类,都得在BeginPlay和EndPlay调用AddReceiver和RemoveReceiver,还是挺麻烦的。因此Epic在《古代山谷》里已经写好一个模块,叫做ModularGameplayActors。

image66.png-42.7kB

里面定义的是一些更Modular的Gameplay基础Actor,重载的逻辑主要有两部分,一是把自己注册给GFCM,二是把自己的一些有用的事件转发给GameFeature的Component,比如截图上的ReceivedPlayer和PlayerTick。

image67.png-96.7kB

这里我也是强烈建议大家从《古代山谷》那里把模块给拷贝到自己的项目,然后从这些Actor继承新的Actor。

总结

至此就已经把AddComponents的逻辑都说完了,作用机制,Component的继承,Actor的继承。内部细节依然是挺多的,也有点弯弯绕绕,不过也如一开头所说,理解这个最常用的AddComponents作用机制,对于成功应用GameFeature框架是挺不可或缺的一个技能点。希望你在日后有用到的时候,还可以依稀梦回到这篇文章来参考。

  • 1
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值