FlowGraph 添加一个不依赖引擎的搜索功能

发布于 2024-12-05  16 次阅读


FlowGraph 添加一个不依赖引擎的搜索功能

1. 背景

FlowGraph 插件是 Github 上的一个开源 UE 插件,插件主要提供了较为简洁的节点拓展、逻辑功能执行,对于一些任务管理、游戏流程控制等很有帮助,具体的使用本文不再赘述,网络上也有一些文章,读者可以自行搜索,也可以直接查看:FlowGraph - Github

FlowGraph 提供了一个搜索功能,但其依赖于两处引擎的修正:

/**
 * Documentation: https://github.com/MothCocoon/FlowGraph/wiki/Asset-Search
 * Set macro value to 1, if you made these changes to the engine: https://github.com/EpicGames/UnrealEngine/pull/9882
 */
#define ENABLE_JUMP_TO_INNER_OBJECT 0

/**
 * Documentation: https://github.com/MothCocoon/FlowGraph/wiki/Asset-Search
 * Set macro value to 1, if you made these changes to the engine: https://github.com/EpicGames/UnrealEngine/pull/9943
 */
#define ENABLE_SEARCH_IN_ASSET_EDITOR 0

作者已经提交了 pull request,但等待 UE 官方合入遥遥无期,项目使用又会带来额外的维护成本,因此考虑尝试不依赖引擎改动来添加一个搜索功能。

最终效果大体如下:

2. 方案

添加一个搜索功能其实很简单,说白了就是一个搜索框、一系列带有节点信息的列表,列表元素上定义一些操作并和原始节点关联起来,就完成了搜索的功能。
这里搜索框使用 UE 的 SSearchBox 完成,节点列表使用 TreeView 即可(TreeView 之前写过一篇 《自定义树形结构控件》,可以阅读相关拓展逻辑,本文也会梳理一遍涉及到的内容)。

UE 中的搜索框大多都是这一逻辑,例如 SFindInBlueprints、SFindInBT 等。
笔者这里也直接拷贝了 SFindInBT,修正相关逻辑来完成这一功能。

提交已经合并到主干,代码可以在这里看到:Pull request

笔者这里参考了 UE BehaviorTree 的搜索功能,先将 FindInBT 拷贝到了插件中,然后更名为 FindInFlow。
以下是详细修正内容及逻辑:

1. 注册入口

为了减少原有逻辑的修改,考虑便于持续更新,笔者这里没有删除旧的搜索逻辑,使用了原有的 ENABLE_SEARCH_IN_ASSET_EDITOR 宏隔离,默认状态使用 FindInFlow。

故原始的注册逻辑基本没有修正,还是注册 Tab 的流程,具体不再赘述,这里在创建界面内容时使用了新创建的 SFindInFlow:

// FlowAssetEditor.h
#if ENABLE_SEARCH_IN_ASSET_EDITOR
    TSharedPtr<class SSearchBrowser> SearchBrowser;
#else
    TSharedPtr<class SFindInFlow> SearchBrowser;
#endif

// FlowAssetEditor.cpp
#if ENABLE_SEARCH_IN_ASSET_EDITOR
    SearchBrowser = SNew(SSearchBrowser, GetFlowAsset());
#else
    SearchBrowser = SNew(SFindInFlow, SharedThis(this));
#endif

这里延用 FindInBT 的规则,传入了资产编辑器即 FlowAssetEditor 的 TShared 指针,这里相较于资产信息是更全面的,因为这里持有着当前打开的编辑器的相关信息,如:

/** The Flow Asset being edited */
TObjectPtr<UFlowAsset> FlowAsset;

TSharedPtr<class FFlowAssetToolbar> AssetToolbar;
TSharedPtr<SFlowGraphEditor> GraphEditor;

2. 主体 UI

Construct 这里删除了一些无用的逻辑,创建了搜索框、TreeView,并绑定了相关回调。
大体内容可以查看注释,后文也会详细描述拓展的逻辑:

void SFindInFlow::Construct( const FArguments& InArgs, TSharedPtr<FFlowAssetEditor> InFlowAssetEditor)
{
    FlowAssetEditorPtr = InFlowAssetEditor;

    this->ChildSlot
        [
            SNew(SVerticalBox)
            +SVerticalBox::Slot()
            .AutoHeight()
            [
                SNew(SHorizontalBox)
                +SHorizontalBox::Slot()
                .FillWidth(1)
                [
                    SAssignNew(SearchTextField, SSearchBox) // 创建搜索框
                    .HintText(LOCTEXT("FlowEditorSearchHint", "Enter text to find nodes..."))
                    .OnTextChanged(this, &SFindInFlow::OnSearchTextChanged) // 文字变动时的回调,键入内容即会回调
                    .OnTextCommitted(this, &SFindInFlow::OnSearchTextCommitted) // 文字提交时的回调,即回车时或焦点移开搜索框时
                ]
                +SHorizontalBox::Slot()
                .Padding(10,0,5,0)
                .AutoWidth()
                .VAlign(VAlign_Center)
                [
                    SNew(STextBlock) // 笔者这里在搜索框的后方添加了一个子图搜索功能的控制入口
                    .Text(LOCTEXT("FlowEditorSubGraphSearchText", "Find In SubGraph "))
                ]
                +SHorizontalBox::Slot()
                .AutoWidth()
                [
                    SNew(SCheckBox) // 可以通过这个勾选框来控制是否打开子图搜索
                    .OnCheckStateChanged(this, &SFindInFlow::OnFindInSubGraphStateChanged)
                    .ToolTipText(LOCTEXT("FlowEditorSubGraphSearchHint", "Checkin means search also in sub graph."))
                ]
            ]
            +SVerticalBox::Slot()
            .FillHeight(1.0f)
            .Padding(0.f, 4.f, 0.f, 0.f)
            [
                SNew(SBorder)
                .BorderImage(FAppStyle::GetBrush("Menu.Background"))
                [
                    SAssignNew(TreeView, STreeViewType) // 基于 TreeView 创建搜索结果列表
                    .ItemHeight(24)
                    .TreeItemsSource(&ItemsFound) // 搜索出的内容
                    .OnGenerateRow(this, &SFindInFlow::OnGenerateRow) // 创建一行的 UI
                    .OnGetChildren(this, &SFindInFlow::OnGetChildren) // 获取元素的子元素,笔者这里即子图中的匹配节点
                    .OnSelectionChanged(this, &SFindInFlow::OnTreeSelectionChanged) // 当选中项更改时的回调
                    .OnMouseButtonDoubleClick(this, &SFindInFlow::OnTreeSelectionDoubleClicked) // 当双击元素时的回调
                    .SelectionMode(ESelectionMode::Multi) // 选中格式,因为也没拓展右键菜单之类的功能,这里不太重要
                ]
            ]
        ];
}

3. TreeView

SAssignNew(TreeView, STreeViewType) // 基于 TreeView 创建搜索结果列表
.ItemHeight(24)
.TreeItemsSource(&ItemsFound) // 搜索出的内容
.OnGenerateRow(this, &SFindInFlow::OnGenerateRow) // 创建一行的 UI
.OnGetChildren(this, &SFindInFlow::OnGetChildren) // 获取元素的子元素,笔者这里即子图中的匹配节点
.OnSelectionChanged(this, &SFindInFlow::OnTreeSelectionChanged) // 当选中项更改时的回调
.OnMouseButtonDoubleClick(this, &SFindInFlow::OnTreeSelectionDoubleClicked) // 当双击元素时的回调
.SelectionMode(ESelectionMode::Multi) // 选中格式,因为也没拓展右键菜单之类的功能,这里不太重要

这里的 STreeViewType 是头文件中定义的类型别名,定义如下:

    typedef TSharedPtr<FFindInFlowResult> FSearchResult;
    typedef STreeView<FSearchResult> STreeViewType;

FSearchResult 是一个指向 FFindInBTResult 对象的共享指针,因此 STreeViewType 也就是一个使用 FFindInBTResult 对象的共享指针作为节点类型的 TreeView 控件。
系统中的大部分 TreeView 控件往往都会定义这两个别名,来方便使用。

FFindInBTResult 是我们定义的每一项的元素,这里有一些小的改动:

/** Item that matched the search results */
class FFindInFlowResult
{
...
    /** Create a flow node result */
    FFindInFlowResult(const FString& InValue, TSharedPtr<FFindInFlowResult>& InParent, UEdGraphNode* InNode, bool bInIsSubGraphNode = false); // 添加 bInIsSubGraphNode 参数
...
    FReply OnDoubleClick(TSharedPtr<FFindInFlowResult> Root); // 提供了一个双击的方法,可以跳转打开子图资产并聚焦到节点
...

    /** Gets the description on flow node if any */
    FString GetDescriptionText() const; // FlowGraph 中有一些关键信息往往会提供到 Description 中,这里也考虑提供该信息供搜索

    /** Gets the node tool tip */
    FText GetToolTipText() const; // 因为双击子图节点与非子图节点时行为不一致,所以考虑提供此方法以悬浮提示
...
    /** Search result parent */
    TWeakPtr<FFindInFlowResult> Parent; // 类型修正

    /** Whether this item is a subgraph node */
    bool bIsSubGraphNode = false; // 来控制当前是否为子图节点,以提供双击、悬浮时不同的行为
};

接下来再看 TreeView 的几个回调和参数。

(1) TreeItemsSource

    /** This buffer stores the currently displayed results */
    TArray<FSearchResult> ItemsFound;

TreeItemsSource 就是树形列表中要显示的内容,也就是组织好的我们定义好的的元素类型的数组,因为我们要搜索 FlowGraph 的节点,一般无键入内容时也不会显示内容,所以我们会在搜索框的相关回调中来组织这一数据。

OnGenerateRow 就是创建元素行时的回调,可以看到返回值是 TSharedRef 类型的变量,我们在这里创建每一行元素的 Widget 即可:

TSharedRef<ITableRow> SFindInFlow::OnGenerateRow( FSearchResult InItem, const TSharedRef<STableViewBase>& OwnerTable )
{
    return SNew(STableRow< TSharedPtr<FFindInFlowResult> >, OwnerTable)
        .ToolTip(SNew(SToolTip).Text(InItem->GetToolTipText()))
        [
            SNew(SHorizontalBox)
            +SHorizontalBox::Slot()
            .VAlign(VAlign_Center)
            .AutoWidth()
            [
                SNew(SBox)
                .MinDesiredWidth(300)
                [
                    SNew(SHorizontalBox)
                    +SHorizontalBox::Slot()
                    .AutoWidth()
                    [
                        InItem->CreateIcon()
                    ]
                    +SHorizontalBox::Slot()
                    .VAlign(VAlign_Center)
                    .AutoWidth()
                    .Padding(2, 0)
                    [
                        SNew(STextBlock)
                        .Text(FText::FromString(InItem->Value))
                        .HighlightText(HighlightText)
                    ]
                ]
            ]
            +SHorizontalBox::Slot()
            .VAlign(VAlign_Center)
            [
                SNew(STextBlock)
                .Text(FText::FromString(InItem->GetDescriptionText()))
                .HighlightText(HighlightText)
            ]
            +SHorizontalBox::Slot()
            .AutoWidth()
            .VAlign(VAlign_Center)
            [
                SNew(STextBlock)
                .Text(FText::FromString(InItem->GetNodeTypeText()))
                .HighlightText(HighlightText)
            ]
            +SHorizontalBox::Slot()
            .HAlign(HAlign_Right)
            .VAlign(VAlign_Center)
            [
                SNew(STextBlock)
                .Text(FText::FromString(InItem->GetCommentText()))
                .ColorAndOpacity(FLinearColor::Yellow)
                .HighlightText(HighlightText)
            ]
        ];
}

逻辑很简单,就是创建了四个 STextBlock,第一个额外加了一个 Icon,最后一个改了一下颜色,其他逻辑都一致。

四个文本框的文字内容,分别调用了封装的不同方法,方法内部就是基于构造 FFindInFlowResult 时传入的 UEdGraphNode* InNode,来调用 FlowGraphNode 上的相关方法,搜集的相关文字内容,具体可以查看 Pull Request 中的代码。

这里的 HighlightText 会在搜索框提交的时候填入其值,在这里传递给文本框后,就可以完成搜索到的内容的高亮逻辑。

file

(2) OnGetChildren

void SFindInFlow::OnGetChildren(FSearchResult InItem, TArray< FSearchResult >& OutChildren)
{
    OutChildren += InItem->Children;
}

OnGetChildren 用来获取当前元素的子元素,因为 TreeView 树型控件本身就是以分层列表形式来显示内容的,元素下可以有子元素。这里我们在构建 FFindInFlowResult 时填充了其成员变量 Children,当节点类型为 SubGraph 时,则搜集并填充其子类节点,具体逻辑在搜索框文字提交时的后续代码中执行。

(3) OnSelectionChanged

void SFindInFlow::OnTreeSelectionChanged(FSearchResult Item , ESelectInfo::Type)
{
    if (Item.IsValid())
    {
        Item->OnClick(FlowAssetEditorPtr, RootSearchResult);
    }
}

这里转调了 Item 的 OnClick 方法:

FReply FFindInFlowResult::OnClick(TWeakPtr<class FFlowAssetEditor> FlowAssetEditorPtr, TSharedPtr<FFindInFlowResult> Root)
{
    if (FlowAssetEditorPtr.IsValid() && GraphNode.IsValid())
    {
        if (Parent.IsValid() && !bIsSubGraphNode)
        {
            FlowAssetEditorPtr.Pin()->JumpToNode(GraphNode.Get());
        }
        else
        {
            FlowAssetEditorPtr.Pin()->JumpToNode(Parent.Pin()->GraphNode.Get());
        }
    }

    return FReply::Handled();
}

单击节点时会聚焦到对应节点,如果是 SubGraph 的子节点,会聚焦到 Parent 节点,也就是持有其的 SubGraph 节点。

JumpToNode 方法就是调用了 SGraphEditor 的 JumpToNode 方法:

void FFlowAssetEditor::JumpToNode(const UEdGraphNode* Node) const
{
    if (GetFlowGraph().IsValid())
    {
        GetFlowGraph()->JumpToNode(Node, false);
    }
}

(4) OnMouseButtonDoubleClicked

void SFindInFlow::OnTreeSelectionDoubleClicked(FSearchResult Item)
{
    if (Item.IsValid())
    {
        Item->OnDoubleClick(RootSearchResult);
    }
}

这里转调了 Item 的 OnDoubleClick 方法:

FReply FFindInFlowResult::OnDoubleClick(TSharedPtr<FFindInFlowResult> Root)
{
    if (!Parent.IsValid() || !bIsSubGraphNode)
    {
        return FReply::Handled();
    }
    const UFlowGraphNode* ParentGraphNode = Cast<UFlowGraphNode>(Parent.Pin()->GraphNode);
    if (!ParentGraphNode || !ParentGraphNode->GetFlowNode())
    {
        return FReply::Handled();
    }

    if (UObject* AssetToEdit = ParentGraphNode->GetFlowNode()->GetAssetToEdit())
    {
        UAssetEditorSubsystem* AssetEditorSubsystem = GEditor->GetEditorSubsystem<UAssetEditorSubsystem>();
        if (AssetEditorSubsystem->OpenEditorForAsset(AssetToEdit))
        {
            if (const TSharedPtr<FFlowAssetEditor> FlowAssetEditor = FFlowGraphUtils::GetFlowAssetEditor(GraphNode->GetGraph()))
            {
                FlowAssetEditor->JumpToNode(GraphNode.Get());
            }
        }
    }

    return FReply::Handled();
}

主体逻辑就是当选中双击的 Item 为 SubGraph 的子节点时,尝试打开 SubGraph 资产的编辑器,然后聚焦到对应资产中的对应节点。

4. 搜索框

ItemsFound 的组织就是在搜索框的相关回调中完成的,OnSearchTextChanged 和 OnSearchTextCommitted 两个回调都会调到同一个方法,方法逻辑是一致的,所以键入文字时就会刷新搜索列表。当然也可以改造成类似蓝图搜索的功能,只在 Committed 时执行列表元素的重建(蓝图中还判断了 CommitType 的类型,只在 Enter 时重建列表)。

void SFindInFlow::OnSearchTextChanged(const FText& Text)
{
    SearchValue = Text.ToString();

    InitiateSearch();
}

void SFindInFlow::OnSearchTextCommitted(const FText& Text, ETextCommit::Type CommitType)
{
    OnSearchTextChanged(Text);
}

回调后,首先缓存了键入的字符到 SearchValue 中,然后在 InitiateSearch 方法中,又将文本填充到了 HighlightText 中。

void SFindInFlow::InitiateSearch()
{
    TArray<FString> Tokens;
    SearchValue.ParseIntoArray(Tokens, TEXT(" "), true);

    for (auto It(ItemsFound.CreateIterator()); It; ++It)
    {
        TreeView->SetItemExpansion(*It, false);
    }
    ItemsFound.Empty();
    if (Tokens.Num() > 0)
    {
        HighlightText = FText::FromString(SearchValue);
        MatchTokens(Tokens);
    }

    // Insert a fake result to inform user if none found
    if (ItemsFound.Num() == 0)
    {
        ItemsFound.Add(MakeShared<FFindInFlowResult>(LOCTEXT("FlowEditorSearchNoResults", "No Results found").ToString()));
    }

    TreeView->RequestTreeRefresh();

    for (auto It(ItemsFound.CreateIterator()); It; ++It)
    {
        TreeView->SetItemExpansion(*It, true);
    }
}

这里的 SetItemExpansion 是控制元素子元素的折叠展开状态。

核心方法在 MatchTokens 中,主体逻辑就是遍历当前资产中的节点,将其要显示的文字组合起来,查看是否在 Tokens 中存在匹配项,存在时,则构建对应的 FSearchResult 并填充到 ItemsFound 数组中。

void SFindInFlow::MatchTokens(const TArray<FString>& Tokens)
{
...
    for (auto It(Graph->Nodes.CreateConstIterator()); It; ++It)
    {
        UEdGraphNode* Node = *It;

        const FString NodeName = Node->GetNodeTitle(ENodeTitleType::ListView).ToString();
        FSearchResult NodeResult(new FFindInFlowResult(NodeName, RootSearchResult, Node));
        FString NodeSearchString = NodeName + Node->GetClass()->GetName() + Node->NodeComment;

        if (const UFlowGraphNode* FlowGraphNode = Cast<UFlowGraphNode>(Node))
        {
            FString NodeDescription = FlowGraphNode->GetNodeDescription();
            NodeSearchString += NodeDescription;

            UFlowNode_SubGraph* SubGraphNode = Cast<UFlowNode_SubGraph>(FlowGraphNode->GetFlowNode());
            if (bFindInSubGraph && SubGraphNode)
            {
                if (const UFlowAsset* FlowAsset = Cast<UFlowAsset>(SubGraphNode->GetAssetToEdit()); FlowAsset && FlowAsset->GetGraph())
                {
                    for (auto ChildIt(FlowAsset->GetGraph()->Nodes.CreateConstIterator()); ChildIt; ++ChildIt)
                    {
                        MatchTokensInChild(Tokens, *ChildIt, NodeResult);
                    }
                }
            }
        }

        NodeSearchString = NodeSearchString.Replace(TEXT(" "), TEXT(""));
        const bool bNodeMatchesSearch = StringMatchesSearchTokens(Tokens, NodeSearchString);

        if ((NodeResult->Children.Num() > 0) || bNodeMatchesSearch)
        {
            ItemsFound.Add(NodeResult);
        }
    }
}

中间额外对 SubGraph 进行了处理,收集了匹配的子节点。这里没有考虑递归的创建,因为笔者这里只做了一层的搜索,只能搜索当前图内的子图节点,子图内的子图则没有提供。

void SFindInFlow::MatchTokensInChild(const TArray<FString>& Tokens, UEdGraphNode* Child, FSearchResult ParentNode)
{
    if (Child == nullptr)
    {
        return;
    }

    const FString ChildName = Child->GetNodeTitle(ENodeTitleType::ListView).ToString();
    FString ChildSearchString = ChildName + Child->GetClass()->GetName() + Child->NodeComment;
    if (const UFlowGraphNode* FlowGraphNode = Cast<UFlowGraphNode>(Child))
    {
        FString NodeDescription = FlowGraphNode->GetNodeDescription();
        ChildSearchString += NodeDescription;
    }
    ChildSearchString = ChildSearchString.Replace(TEXT(" "), TEXT(""));
    if (StringMatchesSearchTokens(Tokens, ChildSearchString))
    {
        const FSearchResult DecoratorResult(new FFindInFlowResult(ChildName, ParentNode, Child, true));
        ParentNode->Children.Add(DecoratorResult);
    }
}

StringMatchesSearchTokens 方法就是检测组合的字符串中是否 Contains 搜索框输入的 Tokens:

bool SFindInFlow::StringMatchesSearchTokens(const TArray<FString>& Tokens, const FString& ComparisonString)
{
    bool bFoundAllTokens = true;

    //search the entry for each token, it must have all of them to pass
    for (auto TokItr(Tokens.CreateConstIterator()); TokItr; ++TokItr)
    {
        const FString& Token = *TokItr;
        if (!ComparisonString.Contains(Token))
        {
            bFoundAllTokens = false;
            break;
        }
    }
    return bFoundAllTokens;
}

总结

通过如上拓展,我们就添加了一个自定义的搜索功能,目前已经合并到了主干。

Pull request 链接:Add a search functionality that does not rely on engine modifications.

TreeView 的使用之前写过一篇:《自定义树形结构控件》。