FeastSC 发表于 2022-8-16 10:39

游戏引擎应用——Unreal Engine的CommonUI插件分析

0. 前言

上次跟着教程搞了下CommonUI,感觉不痛不痒,所以想通过查阅源代码的方式看下CommonUI的工作流程以及熟悉一下Unreal Engine的一系列操作。DebugGame Editor目前的所有操作都是在这个模式下启动的,本人是虚幻小白,所以可能有部分理解不是很充分的地方,望指正。
上篇:
项目资源(注意是Test分支):
1. UCommonGameViewportClient的初始化

为什么要从这个开始呢,因为各个教程都把这个文件的载入作为第一步,每个教程都必须将Project Setting->General Setting->Game Viewport Client设置为CommonUI Client的类,说明这个文件还挺重要的,而我们看他的创建与执行,可以梳理Unreal Engine对多数部件的加载逻辑。
要开始这个部分十分简单,只需要给其父类,也就是UGameViewportClient的构造函数打上断点就可以了,他有两个构造函数,其中我在调试过程中有效的是UGameViewportClient::UGameViewportClient(const FObjectInitializer& ObjectInitializer) ,打上断点,我们就可以看见调用栈了。



Client初始化调用栈

1.1 通过调用栈向上看Tick

以一个新手的视角,从命名上看,最下面的三层栈看起来像一个启动过程,到第四层第五层,看见Tick和Loop有点门道了,第五层的void FEngineLoop::Tick() 应该就是整个Unreal Engine主循环的一部分,这个主循环里面调用了UEngine::Tick(float DeltaSeconds, bool bIdleMode)并且从调用栈中可以看出,我们的GEngine是UEngine子类UUnrealEdEngine实例化,具体引擎内部C++代码如下。
// LaunchEngineLoop.cpp
void FEngineLoop::Tick() {
...
                // main game engine tick (world, game objects, etc.)
                GEngine->Tick(FApp::GetDeltaTime(), bIdleMode); //-----------------------------------1
...}
// Engine.h
UCLASS(abstract, config=Engine, defaultconfig, transient)
class ENGINE_API UEngine
        : public UObject
        , public FExec{
...
      virtual void Tick( float DeltaSeconds, bool bIdleMode ) PURE_VIRTUAL(UEngine::Tick,);
...}
//--------------------------------------EditorEngine-------------------------------------------------
// EditorEngine.h
UCLASS(config=Engine, transient)
class UNREALED_API UEditorEngine : public UEngine {
...
      virtual void Tick(float DeltaSeconds, bool bIdleMode) override;
...}
// EditorEngine.cpp
void UEditorEngine::Tick( float DeltaSeconds, bool bIdleMode ) {
...
        // Kick off a Play Session request if one was queued up during the last frame.
      // 所以看起来Editor系统和直接进行游戏好像有点不一样,触发PIE的命令在UEditorEngine的Tick中,
      // 当我们在引擎Editor中申请说,我要使用PIE了,然后就会申请一个PlaySessionRequest,
      // 这样进入StartQueuedPlaySessionRequest函数可以初始化我们PIE的世界。
        if (PlaySessionRequest.IsSet())        {
                StartQueuedPlaySessionRequest(); //--------------------------------------------------3
        }
...}
//-------------------------------------UnrealEdEngine-------------------------------------------------
// UnrealEdEngine.h
class UNREALED_API UUnrealEdEngine : public UEditorEngine, public FNotifyHook
// UnrealEdEngine.cpp
void UUnrealEdEngine::Tick(float DeltaSeconds, bool bIdleMode) {
        Super::Tick( DeltaSeconds, bIdleMode ); // Call EditorEngine.cpp-----------------------------2
...}
1.2 Tick之后的触发条件

从上面的代码可以看出来,Tick其实是长时间不断执行的,而唯一改变了的是PlaySessionRequest的Set状态,我们其实很容易想到,这是由于我们按下了PIE,我们设置PlaySessionRequest里面的bIsSet状态作为修改断点(因为修改断点只能打在很小的变量上),再重新使用PIE,调用栈如下



PlaySessionRequest设置成功调用栈

可以看见ToolBarButtonBlock的OnClick 调用了PlayInViewport_Clicked函数,并且其又调用了RequestPlaySession ,这个时候才真正设置好我们在PIE中的请求。
1.3 如何获得类及初始化命令

在DefaultEngine.ini中可以发现,所以我们在Project Setting中应该修改的就是这个文件。
// DefaultEngine.ini

+ActiveGameNameRedirects=(OldGameName="TP_FirstPerson",NewGameName="/Script/LearningProject")
+ActiveGameNameRedirects=(OldGameName="/Script/TP_FirstPerson",NewGameName="/Script/LearningProject")
+ActiveClassRedirects=(OldClassName="TP_FirstPersonProjectile",NewClassName="LearningProjectProjectile")
+ActiveClassRedirects=(OldClassName="TP_FirstPersonGameMode",NewClassName="LearningProjectGameMode")
+ActiveClassRedirects=(OldClassName="TP_FirstPersonCharacter",NewClassName="LearningProjectCharacter")
GameViewportClientClassName=/Script/CommonUI.CommonGameViewportClient随后可以在Engine.h中找到GameViewportClientClassName与GameViewportClientClass
UCLASS(abstract, config=Engine, defaultconfig, transient)
class ENGINE_API UEngine
        : public UObject
        , public FExec{
...       
      UPROPERTY()
        TSubclassOf<class UGameViewportClient>GameViewportClientClass;
        /** Sets the class to use for the game viewport client, which can be overridden to change game-specific input and display behavior. */
        UPROPERTY(globalconfig, noclear, EditAnywhere, Category=DefaultClasses, meta=(MetaClass="GameViewportClient", DisplayName="Game Viewport Client Class", ConfigRestartRequired=true))
        FSoftClassPath GameViewportClientClassName;
...}
在UEditorEngine::CreateInnerProcessPIEGameInstance中可以获得Engine中的GameViewportClientClass,也就是这里是开始创建CommonUI Client的时候,下面给出Client初始化调用栈的呼叫逻辑。
void UEditorEngine::StartQueuedPlaySessionRequest() //检查,运行调用,调用完毕重置Request
                            ↓
void UEditorEngine::StartQueuedPlaySessionRequestImpl() //检查,检查是否多个运行,分支三种情况运行调用,
                            ↓
void UEditorEngine::CreateNewPlayInEditorInstance(FRequestPlaySessionParams &InRequestParams, const bool bInDedicatedInstance, const EPlayNetMode InNetMode)
                            ↓
void UEditorEngine::OnLoginPIEComplete_Deferred(int32 LocalUserNum, bool bWasSuccessful, FString ErrorString, FPieLoginStruct DataStruct)
                            ↓
UGameInstance* UEditorEngine::CreateInnerProcessPIEGameInstance(FRequestPlaySessionParams& InParams, const FGameInstancePIEParameters& InPIEParameters, int32 InPIEInstanceIndex) {
...
                ViewportClient = NewObject<UGameViewportClient>(this, GameViewportClientClass);
                ViewportClient->Init(*PieWorldContext, GameInstance);
...
                GameViewport = ViewportClient;
                GameViewport->bIsPlayInEditorViewport = true;
                // Update our World Context to know which Viewport Client is associated.
                PieWorldContext->GameViewport = ViewportClient;
...}
在写Lua的时候就可以经常看见我们需要调用Context来获取很多东西,说明Context是我们需要理解的一个大头,在初始化完毕之后,我们的GameViewport也被纳入了Context。
2. UCommonGameViewportClient的运行时

Client运行时肯定需要触发按键输入响应,我们可以分析下Engine怎么触发的响应,Client怎么处理的响应。
2.1 Input响应调用栈

首先是输入进来时的调用栈,这个调用栈中鼠标键盘还有区别,从WindowsApplication.cpp出现差别
int32 FWindowsApplication::ProcessDeferredMessage( const FDeferredWindowsMessage& DeferredMessage )
// 查看了这个函数,发现是一个大型的信息分装机器,这里为分水岭也是在合适不过了。
// 可以发现所有的信息响应也经过了void FEngineLoop::Tick()这个函数,主循环无疑了



鼠标左键输入



键盘T输入

不过他中间的信息传递过程似乎还很复杂,并且是专门给Windows做的,内部还有很多Windows API,但是仔细看了下,应该还行
void FEngineLoop::Tick() {
...
                // Don't pump messages if we're running embedded as the outer application
                // will pass us messages instead.
                if (!GUELibraryOverrideSettings.bIsEmbedded) {
                        GEngine->SetInputSampleLatencyMarker(CurrentFrameCounter);
                        //QUICK_SCOPE_CYCLE_COUNTER(STAT_PumpMessages);
                        FPlatformApplicationMisc::PumpMessages(true); // --------------------------------1
                }
...}
主循环FEngineLoop::Tick()
void FWindowsPlatformApplicationMisc::PumpMessages(bool bFromMainLoop) {
... // 检查是否从主循环中进来
        WinPumpMessages(); // ---------------------------------------------------------------------------2
...
}

static void WinPumpMessages() { // 都是WinAPI,以Message的方式检测输入
        TRACE_CPUPROFILER_EVENT_SCOPE(WinPumpMessages); {
                MSG Msg;
                while( PeekMessage(&Msg,NULL,0,0,PM_REMOVE) ) {
                        TranslateMessage( &Msg );
                        DispatchMessage( &Msg );
                }
        }
}
这个都是WinAPI,接收函数就是FWindowsApplication::AppWndProc
// Defined as a global so that it can be extern'd by UELibrary
LRESULT WindowsApplication_WndProc(HWND hwnd, uint32 msg, WPARAM wParam, LPARAM lParam) {
        ensure( IsInGameThread() );
        return WindowsApplication->ProcessMessage( hwnd, msg, wParam, lParam ); // ------------------------5
}
/** Windows callback for message processing (forwards messages to the FWindowsApplication instance). */
LRESULT CALLBACK FWindowsApplication::AppWndProc(HWND hwnd, uint32 msg, WPARAM wParam, LPARAM lParam) {
        return WindowsApplication_WndProc( hwnd, msg, wParam, lParam ); // --------------------------------4
}
接收Windows传来的函数
int32 FWindowsApplication::ProcessMessage( HWND hwnd, uint32 msg, WPARAM wParam, LPARAM lParam ) {
...
                case WM_KEYDOWN:
                case WM_SYSKEYUP:
                case WM_KEYUP:
                case WM_LBUTTONDBLCLK:
                case WM_LBUTTONDOWN:
                case WM_MBUTTONDBLCLK:
                case WM_MBUTTONDOWN:
                case WM_RBUTTONDBLCLK:
                case WM_RBUTTONDOWN:
                case WM_XBUTTONDBLCLK:
                case WM_XBUTTONDOWN:
                case WM_XBUTTONUP:
                case WM_LBUTTONUP:
                case WM_MBUTTONUP:
                case WM_RBUTTONUP:
                case WM_NCMOUSEMOVE:
                case WM_MOUSEMOVE:
                case WM_MOUSEWHEEL:
                case WM_TOUCH: {
                              // -------------------------------------------------------------------------7
                                DeferMessage( CurrentNativeEventWindowPtr, hwnd, msg, wParam, lParam );
                                // Handled
                                return 0;
                        }
                        break;
                // Mouse Movement
                case WM_INPUT: {
                ... // 可能有点参考价值?
                }
...
}
HWND是Windows的窗口句柄主要是对Windows输入进行分类处理,各个宏的意义都可以在网上查到,还是挺清晰的。
void FWindowsApplication::DeferMessage( TSharedPtr<FWindowsWindow>& NativeWindow, HWND InHWnd, uint32 InMessage, WPARAM InWParam, LPARAM InLParam, int32 MouseX, int32 MouseY, uint32 RawInputFlags ) {
        if( GPumpingMessagesOutsideOfMainLoop && bAllowedToDeferMessageProcessing ) {
                DeferredMessages.Add( FDeferredWindowsMessage( NativeWindow, InHWnd, InMessage, InWParam, InLParam, MouseX, MouseY, RawInputFlags ) );
        }
        else {
                // When not deferring messages, process them immediately
                // -------------------------------------------------------------------------------------------8
                ProcessDeferredMessage( FDeferredWindowsMessage( NativeWindow, InHWnd, InMessage, InWParam, InLParam, MouseX, MouseY, RawInputFlags ) );
        }
}
这个部分主要是判断下,我们是不是要立即处理这个DeferMessage,如果不延迟,那么就立即执行,如果延迟,那么就把他加入一个TArray<FDeferredWindowsMessage> DeferredMessages
下一个栈就是ProcessDeferredMessage,这个部分其实总体逻辑很清晰,也是我们发出各个输入信号的分水岭,可以看一看,不过不看也不是那么重要了。最后到CommonUI Client会获得一个FInputKeyEventArgs也就是虚幻打包好的一个输入事件,具体什么时候打包的,调用栈会告诉你答案。
2.2 Input处理

从源代码中可以看见CommonUI的Client继承了几个Input函数,并且专门设置了RouterHandle,我们专门从Key分析下他是怎么操作的。逻辑是InputKey是响应函数的最外层,首先从进去查看一下是不是比需要处理的UI按键更加高级的命令IsKeyPriorityAboveUI ,如果不是,那么首先将Result设置为Unhandled,先通过 RerouteInput委托执行,如果委托没有绑定函数,那么去自己定义的HandleRerouteInput执行,如果Result在这里面执行好了,并且设置成为了Handled,那么就返回True,不然就返回False。
最主要的处理过程就是ERouteUIInputResult InputResult = ActionRouter->ProcessInput(Key, EventType);
bool UCommonGameViewportClient::InputKey(const FInputKeyEventArgs& InEventArgs) {
        const FInputKeyEventArgs& EventArgs = InEventArgs;
        if (IsKeyPriorityAboveUI(EventArgs)) {
                return true;
        }
        // The input is fair game for handling - the UI gets first dibs
#if !UE_BUILD_SHIPPING
        if (ViewportConsole && !ViewportConsole->ConsoleState.IsEqual(NAME_Typing) && !ViewportConsole->ConsoleState.IsEqual(NAME_Open))
#endif
        {               
                FReply Result = FReply::Unhandled();
                if (!OnRerouteInput().ExecuteIfBound(EventArgs.ControllerId, EventArgs.Key, EventArgs.Event, Result)) {
                        HandleRerouteInput(EventArgs.ControllerId, EventArgs.Key, EventArgs.Event, Result);
                }
                if (Result.IsEventHandled()) {
                        return true;
                }
        }
        return Super::InputKey(EventArgs);
}
bool UCommonGameViewportClient::IsKeyPriorityAboveUI(const FInputKeyEventArgs& EventArgs) {
#if !UE_BUILD_SHIPPING
        // First priority goes to the viewport console regardless any state or setting
        if (ViewportConsole && ViewportConsole->InputKey(EventArgs.ControllerId, EventArgs.Key, EventArgs.Event, EventArgs.AmountDepressed, EventArgs.IsGamepad())) {
                return true;
        }
#endif
        // We'll also treat toggling fullscreen as a system-level sort of input that isn't affected by input filtering
        if (TryToggleFullscreenOnInputKey(EventArgs.Key, EventArgs.Event)) {
                return true;
        }
        return false;
}
void UCommonGameViewportClient::HandleRerouteInput(int32 ControllerId, FKey Key, EInputEvent EventType, FReply& Reply) {
        ULocalPlayer* LocalPlayer = GameInstance->FindLocalPlayerFromControllerId(ControllerId);
        Reply = FReply::Unhandled();

        if (LocalPlayer) {
                UCommonUIActionRouterBase* ActionRouter = LocalPlayer->GetSubsystem<UCommonUIActionRouterBase>();
                if (ensure(ActionRouter)) {
                        ERouteUIInputResult InputResult = ActionRouter->ProcessInput(Key, EventType);
                        if (InputResult == ERouteUIInputResult::BlockGameInput) {
                                // We need to set the reply as handled otherwise the input won't actually be blocked from reaching the viewport.
                                Reply = FReply::Handled();
                                // Notify interested parties that we blocked the input.
                                OnRerouteBlockedInput().ExecuteIfBound(ControllerId, Key, EventType, Reply);
                        }
                        else if (InputResult == ERouteUIInputResult::Handled) {
                                Reply = FReply::Handled();
                        }
                }
        }
}
// GameViewportClient.cpp
bool UGameViewportClient::TryToggleFullscreenOnInputKey(FKey Key, EInputEvent EventType) {
        if ((Key == EKeys::Enter && EventType == EInputEvent::IE_Pressed && FSlateApplication::Get().GetModifierKeys().IsAltDown() && GetDefault<UInputSettings>()->bAltEnterTogglesFullscreen)
                || (IsRunningGame() && Key == EKeys::F11 && EventType == EInputEvent::IE_Pressed && GetDefault<UInputSettings>()->bF11TogglesFullscreen && !FSlateApplication::Get().GetModifierKeys().AreModifersDown(EModifierKey::Control | EModifierKey::Alt)))         {
                HandleToggleFullscreenCommand();
                return true;
        }
        return false;
}
在上一个项目中,我很好奇CommonUI到底是以什么逻辑处理的Input,因为每次到这个部分的时候,其实几个委托都是Unbound的状态,并且怎么也搜不到这几个委托在哪里被绑定了,就很奇怪。如果这几个委托都没有绑定,那么这个类存在的意义是什么呢?然后我尝试将CommonUI Client换回原本的Client,结果发现之前设计的逻辑竟然都能够直接使用!那说明了,如果我们全部输入都Custom的话,那么其实这个大的窗口是不需要换的,不过我也找到了一些需要CommonUI Client触发的操作,其中一个就是Back操作,而要通过Client响应这个Back,我们还需要执行以下的步骤,也就是官方教程中新手完全看不懂的部分
首先右键内容浏览器,在Miscellaneous类别中找到Data Table创建,然后进去之后创建一行,名字任意取,然后按自己需要绑定键盘输入、手柄输入、触控输入(如果需要的话)



我把这一行名字设置为NativeBack,并且设置键盘B键和手柄Face Button Right(也就是XBox的B键)可以触发

然后创建一个CommonUIInputData为基类的蓝图,将我们设置好的按键放在默认Back Action中



测试下Back

然后在Project Setting->Game->Common Input Settings中,将InputData设置为我们刚刚创建的CommonUIInputData,这时候,我们配置已经完成了。打开上次的工程,将HowToPlay和HowToPlayDetail的两个CommonActivatableWidget的IsBackHandler勾选上,至此,我们就可以使用B键对其进行销毁(使用Stack模式),现在我们来查看Back的调用栈,断点打在UCommonActivatableWidget::HandleBackAction()



第一次进入ProcessNormalInput,注意bCanReceiveInput



第二次进入ProcessNormalInput,注意bCanReceiveInput

可以清楚地看见我们触发Back的最上层CommonUI入口的确是CommonGameViewportClient,并且处理函数也是和我们之前说过的是相应的ActionRouter->ProcessInput(Key, EventType);这样想想,似乎ActionRouter才是CommonUI的处理中心,Client只是为部分原生数据提供支持的地方,不过不论怎么样,先把这个部分看完再说。
第一张图可以看到,从Client进去之后,就是UCommonUIActionRouterBase的秀场了,我们的Back信号并没有被其Process函数内的各个特殊判断给吸收,最后被一个Lambda函数吸收了,并且调用到了
ActiveRootNode->ProcessNormalInput(ActiveMode, Key, Event);
...
        const auto ProcessNormalInputFunc = (EInputEvent Event) {
                        bool bHandled = PersistentActions->ProcessNormalInput(ActiveMode, Key, Event);
                        if (!bHandled && ActiveRootNode && bIsActivatableTreeEnabled) {
                                bHandled = ActiveRootNode->ProcessNormalInput(ActiveMode, Key, Event);
                        }
                        return bHandled;
                };
...
                // Even if no widget cares about this input, we don't want to let anything through to the actual game while we're in menu mode
                bHandledInput = ProcessNormalInputFunc(InputEvent);
...
也就到了我们之前截图的调用栈,可以从第一张图得知,我们目前的ActiveRootNode就是我们设置的Container,Container有三个子节点,分别就是三个菜单,这个时候为什么其他的菜单无法对这个进行响应,可以聚焦到各个TreeNode的bCanReceiveInput成员变量,这个成员变量控制了是否能够接收Input信息,这个大概就是压栈出栈会改变的状态(每个Container内部不应该只有一个是Active的吗,那不是直接取就行了?),然后在能够接收到Input的子节点,会进入函数FActionRouterBindingCollection::ProcessNormalInput这里面会将我们之前设置的Action遍历一遍,再把Action里面的键位Mapping遍历一遍,找到对应的Binding、Key、Mapping、Action之后执行Binding,也就是Widget的HandleBackAction(),蓝图可以Override BP_OnHandleBackAction方法,就可以实现自定义Back。
bool FActionRouterBindingCollection::ProcessNormalInput(ECommonInputMode ActiveInputMode, FKey Key, EInputEvent InputEvent) const {
        for (FUIActionBindingHandle BindingHandle : ActionBindings) {
                if (TSharedPtr<FUIActionBinding> Binding = FUIActionBinding::FindBinding(BindingHandle)) {
                        if (ActiveInputMode == ECommonInputMode::All || ActiveInputMode == Binding->InputMode) {
                                for (const FUIActionKeyMapping& KeyMapping : Binding->NormalMappings) {
                                        // A persistent displayed action skips the normal rules for reachability, since it'll always appear in a bound action bar
                                        const bool bIsDisplayedPersistentAction = Binding->bIsPersistent && Binding->bDisplayInActionBar;
                                        if (KeyMapping.Key == Key && Binding->InputEvent == InputEvent && (bIsDisplayedPersistentAction || IsWidgetReachableForInput(Binding->BoundWidget.Get())))
                                        {
                                                // Just in case this was in the middle of a hold process with a different key, reset now
                                                Binding->CancelHold();
                                                Binding->OnExecuteAction.ExecuteIfBound();
                                                if (Binding->bConsumesInput) {
                                                        return true;
                                                }
                                        }
                                }
                        }
                }
        }
        return false;
}
不过这功能,感觉上是需要一个System的视角去看可能理解能够更加深刻,我目前对System的理解还不够,所以目前会认为这个功能其实挺鸡肋的。
3. CommonActivatableWidget的初始化

上面的例子已经说明了CommonUI提供的ViewportClient并不是限制我们使用CommonUI一些路由功能的关键,那么具体是谁影响了CommonUI的路由,还可以通过Widget的初始化判断,同时Widget初始化之后可能还会用广播影响RouteTree的行为,这里也需要考虑到,因为上一个项目中,我们在非CommonUI Client模式下也可以获得正确的Gamepad路由。一个CommonActivatableWidget从零到能用到底触发了多少东西,这对整个架构的理解都很重要。
新人提示:在Log界面输入Log LogCommonUI Verbose可以让CommonUI的log显示出来,但发现没什么用。经过调试,我初步判断出了一个CommonActivatableWidget初始化需要经过的步骤,主要与Widget的生命周期有关,因为与Widget有关的创建都是在Lua中执行的,所以在调用栈中会看到很多与Lua相关的栈空间,属正常现象。由于这章是枢纽,所以混杂的类可能会比较多。
3.1 Create/NewObject

首先是创建,UE还是挺方便的,我们可以把断点打在UCommonActivatableWidget的GENERATED_BODY()处,这样就可以查看调用栈,这里主要是初始化类中存在默认定义的变量,这些变量大部分都可以在蓝图中调整这个类对应的初始化方式,其他就是正常的调用父类方法,这部分没有太多特别的。


3.2 AddToViewport/Construct

这部分比较关键,在CommonUIActionRouterBase.cpp中,可以看见构建树的时候需要用到RebuiltWidgetsPendingNodeAssignment,里面存储的是待构建成为树节点的Widget,搜索这个变量可以定位到修改他的地方,把这个函数打上断点,查看调用栈可以发现,这是符合生命周期的,并且我们是从AddToViewport触发的Rebuild时期将Widget加入到RouterBase的待处理中。



AddToViewport调用栈

这里通过调用栈我们可以像上面一样查看下各个时期Unreal Engine的行为,其中主要是在RouterBase初始化时期,注册好了接受OnRebuilding委托函数,并且UCommonActivatableWidget::OnRebuilding设计为静态成员变量,所以只需要在RouterBase这里初始化一次所有的Widget都可以使用。
void UUserWidget::AddToScreen(ULocalPlayer* Player, int32 ZOrder) {
        if ( !FullScreenWidget.IsValid() ) {
...
                TSharedRef<SWidget> UserSlateWidget = TakeWidget(); // -----------------------------------------1
...
      }
...
}

TSharedRef<SWidget> UWidget::TakeWidget() {
        LLM_SCOPE(ELLMTag::UI);
        return TakeWidget_Private( []( UUserWidget* Widget, TSharedRef<SWidget> Content ) -> TSharedPtr<SObjectWidget> {
                     return SNew( SObjectWidget, Widget )[ Content ];
                   } ); // --------------------------------------------------------------------------------------2
}

TSharedRef<SWidget> UWidget::TakeWidget_Private(ConstructMethodType ConstructMethod) {
...
      PublicWidget = RebuildWidget(); // ----------------------------------------------------------------------3
...
}

TSharedRef<SWidget> UCommonActivatableWidget::RebuildWidget() {
        // Note: the scoped builder guards against design-time so we don't need to here (as it'd make the scoped lifetime more awkward to leverage)
        //FScopedActivatableTreeBuilder ScopedBuilder(*this);
        if (!IsDesignTime()) {
                OnRebuilding.Broadcast(*this); // ---------------------------------------------------------------4
        }                                                                                          ↑
        return Super::RebuildWidget();                                                             ↑
}                                                                                                ↑
                                                                                                   ↑
void UCommonUIActionRouterBase::Initialize(FSubsystemCollectionBase& Collection) {               ↑
...                                                                                                ↑
        UCommonActivatableWidget::OnRebuilding.AddUObject(this, &UCommonUIActionRouterBase::HandleActivatableWidgetRebuilding);
...                                                                                             ↓
}                                                                                             ↓
                                                                                                ↓
void UCommonUIActionRouterBase::HandleActivatableWidgetRebuilding(UCommonActivatableWidget& RebuildingWidget){
        if (RebuildingWidget.GetOwningLocalPlayer() == GetLocalPlayerChecked()){
                RebuiltWidgetsPendingNodeAssignment.Add(&RebuildingWidget);
        }
}
最后AddToViewport这一段生命周期是以Construct构建为结束的,所以可以在Widget的NativeConstruct中打个断点查看一下,这里就可以找到设置了默认Back响应的地方DefaultBackActionHandle = RegisterUIActionBinding(BindArgs);,而AddToViewport到Construct的生命周期似乎都写在了TSharedRef<SWidget> UWidget::TakeWidget_Private 可以Mark一下。
3.3 Activate

按理来说Activate进来之后运行的应该是ActivateWidget-InternalProcessActivation-NativeOnActivated的运行逻辑,但是这里调用栈写的是直接到了NativeOnActivated



Activate调用栈

而加入进树这个步骤,并不是由Activate引起的,而是引擎的Tick启动了Router的Tick,在Tick中判断了我们现在是否存在需要进入树的Widget,如果有,那么就把他们加入到树中,这一段放在上一小节逻辑更合理,但是从运行顺序上来说,启动的Activate比Tick更早执行。
bool UCommonUIActionRouterBase::Tick(float DeltaTime) {
        QUICK_SCOPE_CYCLE_COUNTER(STAT_UCommonUIActionRouter_Tick);
        if (PendingWidgetRegistrations.Num() > 0 || RebuiltWidgetsPendingNodeAssignment.Num() > 0) {
                ProcessRebuiltWidgets();
        }
...
}

//这是Tick运行中最主要的函数
void UCommonUIActionRouterBase::ProcessRebuiltWidgets() {
// 把所有的预选节点走一个判断根的流程
// FindOwningActivatable里面是不断的While循环找父亲,直到成为null,消耗可能会比较大?那个地方官方也打了一个TODO哈哈//
// 如果找到了已经在树里面了的父亲,会标记父子关系WidgetsByDirectParent
        // Begin by organizing all of the widgets that need nodes according to their direct parent//

// 将所有的根收集起来,给他们建议棵树,所以可以明晰,整个Router维护的是一个森林,这点还蛮令我意外的
// 这应该也是路由的基础了,通过不同的按键可以在森林中Focus跳跃
// FActivatableTreeRoot::Create的时候就会触发Widget的函数绑定,并且把刚才说的执行逻辑立马给补上了
// void FActivatableTreeNode::Init() {
//        RepresentedWidget->OnActivated().AddSP(this, &FActivatableTreeNode::HandleWidgetActivated);
//        RepresentedWidget->OnDeactivated().AddSP(this, &FActivatableTreeNode::HandleWidgetDeactivated);
//        if (RepresentedWidget->IsActivated()) {
//                HandleWidgetActivated();
//        }
// }
// 同时对于Root还创建了InputSubsystem.OnInputMethodChangedNative.AddSP(this, &FActivatableTreeRoot::HandleInputMethodChanged);
// 从代码逻辑上可以知道,似乎官方并没有给Widget和Node打互通的Activate数据,还需要HandleRootNodeActivated一次
// Widget和TreeNode各玩各的,HandleRootNodeActivated完成了绘制!
        // Build a new tree for any new roots

// 处理树中父亲
        // Now process any remaining entries - these are widgets that were rebuilt but should be appended to an existing node//

// 树中父亲处理完他还是孤儿状态,就表示出错了
        if (WidgetsByDirectParent.Num() > 0)
        {
                //@todo DanH: Build a string to print all the remaining entries
                ensureAlwaysMsgf(false, TEXT("Somehow we rebuilt a widget that is owned by an activatable, but no node exists for that activatable. This *should* be completely impossible."));
        }

// 非根节点的其他Widget情况
        // Now, we account for all the widgets that would like their actions bound

        RebuiltWidgetsPendingNodeAssignment.Reset();
        PendingWidgetRegistrations.Reset();
}
4. CommonActivatableWidget的运行时

4.1 Activate/Deactivate

我们可以在运行时控制Widget是否激活,首先需要Check的是激活与否的影响,Widget组件有Deactivate函数给我们调用,可以先查看这个部分的调用栈,并且检查Delegate以便我们知道下一步应该怎么做,这个时候发现了有一个Delegate出去的,指向FActivatableTreeNode::HandleWidgetDeactivated()


跳转过来之后发现这里也是一个Delegate,在下图可以看见这个Delegate指向的处理函数,在Router中,接着按照指示跳转到UCommonUIActionRouterBase::HandleRootNodeDeactivated


可以看见Deactivate的作用主要是控制控件树的关系,以及一个可选开关,用Activate和Deactivate控制Visibility(上一篇这里的Activate Visibility和Deactivate Visibility在我最开始写的时候理解错了,其就是是否用Activate控制Visibility,如果控制,切换的时候用什么)。
4.2 Focus

为了知道CommonUI怎么把Active信息与Unreal Engine Focus系统结合起来,这个挺重要,首先从结果方面入手,用之前的命令打开CommonUI的Log之后,手柄的移动就可以打印出来了


在项目中搜索target changed to的位置,就可以找到这个Log从哪里输出的,找到了在CommonAnalogCursor中的Tick函数,其中获得Widget路径的方式如下
void FCommonAnalogCursor::Tick(const float DeltaTime, FSlateApplication& SlateApp, TSharedRef<ICursor> Cursor) {
      if (IsUsingGamepad() && IsGameViewportInFocusPathWithoutCapture()) {
                const TSharedRef<FSlateUser> SlateUser = SlateApp.GetUser(GetOwnerUserIndex()).ToSharedRef();
...
                        // By default the cursor target is the focused widget itself, unless we're working with a list view
                        TSharedPtr<SWidget> CursorTarget = SlateUser->GetFocusedWidget();
...
         }
...
}
// WeakFocusPath中,拿到了从Windows到FocusWidget这条路径上的所有组件,并且最后一级就是FocusWidget
TSharedPtr<SWidget> FSlateUser::GetFocusedWidget() const {
        return WeakFocusPath.IsValid() ? WeakFocusPath.GetLastWidget().Pin() : nullptr;
}
现在我们需要找到,CommonUI是怎么找到WeakFocusPath的,其实经过我的Debug发现,并不是CommonUI驱动的寻找这个步骤,而是引擎本身就驱动了这个寻找步骤,CommonUI更关注的可能是让多个可以接受同一个Input的组件在同一个时刻展示,并且用很简单的开发逻辑实现这个过程,我在这里也写一下Unreal Engine是怎么调用的
首先接收到手柄带来的信号,按照这个信号去寻找最符合条件的,并且是Hittable的Widget,这时候我们对于找到的Widget有两种表达方式,一种方式是给出一个屏幕坐标,另一个方式是给出一个Slate层次结构的链


然后,将找到的Widget,经过处理给到SlateUser,这里的WidgetPath就是以链为表示的Widget路径


虽然表达起来两个步骤很简单,但是要能精确的找到也花费了我不少功夫。
5. 总结

从上面的表现来说,CommonUI更加适合处理复杂的Input,他可以用一个树状结构将Input用很少的开发代码送到对的地方,上面的测试确实还没有体现出CommonUI真正的厉害之处,所以我可能后期考虑做一个比较适合表现CommonUI功能性的一个UMG Demo,至于在手机游戏上,这种多层次的结构其实是很讨巧的,问题就在于与之前的兼容,其实感觉要兼容只需要在外面套个皮就可以了。
页: [1]
查看完整版本: 游戏引擎应用——Unreal Engine的CommonUI插件分析