Skip to content

UE5 编辑器插件开发:定制菜单

发布于  at 11:19 PM

委托(Delegate)

编辑器扩展的核心并不是主动修改 Content Browser 的源码,而是在引擎预留的扩展点上注册自己的回调。Content Browser 准备构建右键菜单时广播事件,插件收到事件后返回菜单扩展;用户点击新增的菜单项时,另一个委托再调用插件中的处理函数。

UE 委托最常见的用途就是把「事件何时发生」与「事件发生后做什么」解耦。

普通的 C++ 函数调用需要直接知道调用对象和函数;委托则先保存一个可调用目标,等到合适的时机再执行。以按钮为例,按钮只需要知道点击时执行一个委托,不需要知道最终处理点击的是哪个类。

最重要的操作是绑定(Bind):将委托与函数联系起来。不同的绑定方式对应不同的对象生命周期,例如 CreateRawCreateSPCreateUObject

不同类型的委托

UE 的委托

常见的委托可以分为:

定义委托通常使用宏:

DECLARE_DELEGATE(FOnDeleteButtonClicked)

这个委托对应没有返回值、没有参数的函数:

void OnDeleteButtonClicked();

如果需要一个参数,可以使用:

DECLARE_DELEGATE_OneParam(FOnSelected, FString)

对应的函数签名为:

void OnSelected(FString SelectedPath);

如果既需要参数又需要返回值,则可以使用:

DECLARE_DELEGATE_RetVal_OneParam(bool, FCanDelete, FString)

UE 已经为不同参数数量提供了对应宏。参数较大的结构体应尽量通过 const Type& 传递,避免不必要的复制。

这次不需要自己声明委托。Content Browser 和 Slate 已经定义好了菜单扩展需要的委托类型:

三个委托组成了一条调用链:

Content Browser 构建路径右键菜单
    -> CustomCBMenuExtender(SelectedPaths)
    -> AddContentBrowserMenuEntry(MenuBuilder)
    -> 用户点击菜单项
    -> OnDeleteUnusedAssetButtonClicked()

添加 Content Browser 模块

菜单属于 Content Browser 模块,因此先在 EditorHelper.Build.cs 中添加私有依赖:

PrivateDependencyModuleNames.AddRange(
	new string[]
	{
		"CoreUObject",
		"Engine",
		"ContentBrowser",
		"UnrealEd",
		"Slate",
		"SlateCore",
	}
);

这里使用 PrivateDependencyModuleNames,是因为对 Content Browser 和 Slate 的使用只出现在模块自身的实现中,不需要把这些依赖继续暴露给依赖 EditorHelper 的其他模块。

CPP 中还需要引入:

#include "ContentBrowserModule.h"
#include "Framework/MultiBox/MultiBoxBuilder.h"

ContentBrowserModule.h 提供 FContentBrowserModule 以及路径菜单扩展委托;MultiBoxBuilder.h 提供 FExtenderFMenuBuilder 和菜单扩展相关类型。UE 编辑器中的菜单和工具栏大量建立在 MultiBox 框架之上。

在模块启动时注册菜单扩展

FEditorHelperModule 实现了 IModuleInterface。模块加载后会执行 StartupModule,因此可以把注册入口放在这里:

void FEditorHelperModule::StartupModule()
{
	InitContentBrowserMenuExtension();
}

在头文件中声明菜单扩展所需的函数,并保存注册委托的句柄:

private:

#pragma region ContentBrowserMenuExtension

	void InitContentBrowserMenuExtension();

	TSharedRef<FExtender> CustomCBMenuExtender(const TArray<FString>& SelectedPaths);

	void AddContentBrowserMenuEntry(FMenuBuilder& MenuBuilder);

	void OnDeleteUnusedAssetButtonClicked();

	FDelegateHandle ContentBrowserMenuExtenderHandle;
	TArray<FString> SelectedFolderPaths;

#pragma endregion

#pragma region 只是 IDE 中用于折叠代码的组织方式,不参与 UE 的反射,也不会改变编译结果。

接下来加载 Content Browser 模块,并取得路径视图右键菜单扩展器数组:

void FEditorHelperModule::InitContentBrowserMenuExtension()
{
	FContentBrowserModule& ContentBrowserModule =
		FModuleManager::LoadModuleChecked<FContentBrowserModule>(TEXT("ContentBrowser"));

	TArray<FContentBrowserMenuExtender_SelectedPaths>& ContentBrowserModuleMenuExtenders =
		ContentBrowserModule.GetAllPathViewContextMenuExtenders();

	FContentBrowserMenuExtender_SelectedPaths MenuExtenderDelegate =
		FContentBrowserMenuExtender_SelectedPaths::CreateRaw(
			this, &FEditorHelperModule::CustomCBMenuExtender);

	ContentBrowserMenuExtenderHandle = MenuExtenderDelegate.GetHandle();
	ContentBrowserModuleMenuExtenders.Add(MoveTemp(MenuExtenderDelegate));
}

这里涉及几个关键 API:

CreateRaw 不会追踪对象生命周期。也就是说,委托不会因为对象销毁而自动失效。因此模块卸载时必须主动注销,避免 Content Browser 之后调用已经不存在的模块对象。这里能够使用 CreateRaw,正是因为模块同时管理了注册和注销。

决定菜单插入位置

编辑器设置中可以启用 Display UI Extension Points,这样菜单会显示可用的扩展点名称。

启用 Display UI Extension Points

显示效果如下:

扩展点名称

当前代码选择 Delete,并使用 EExtensionHook::After,表示把自定义内容插入到 Delete 扩展点之后:

TSharedRef<FExtender> FEditorHelperModule::CustomCBMenuExtender(
	const TArray<FString>& SelectedPaths)
{
	TSharedRef<FExtender> MenuExtender(new FExtender());

	if (SelectedPaths.Num() > 0)
	{
		SelectedFolderPaths = SelectedPaths;

		MenuExtender->AddMenuExtension(
			FName("Delete"),
			EExtensionHook::After,
			TSharedPtr<FUICommandList>(),
			FMenuExtensionDelegate::CreateRaw(
				this,
				&FEditorHelperModule::AddContentBrowserMenuEntry)
		);
	}

	return MenuExtender;
}

SelectedPaths 是当前选中的 Content Browser 路径。只有至少选择了一个路径时,代码才调用 AddMenuExtension;没有选择路径时仍然返回一个空的 FExtender。这样,菜单是否出现由当前上下文决定,而不是永久显示一个无法使用的入口。

FExtender 用来描述「向已有 UI 添加什么」,并不直接拥有整个菜单。TSharedRef 表示返回值不能为空,符合框架要求:即使没有添加任何扩展,也要返回一个有效的空扩展器。

AddMenuExtension 的参数分别表示:

  1. 插入点名称 Delete
  2. 插入到该扩展点之后;
  3. 可选的 FUICommandList,当前没有使用,所以传入空的 TSharedPtr
  4. 实际构建菜单内容的 FMenuExtensionDelegate

添加菜单项和点击回调

到这里还只是确定「插在哪里」,菜单项的标题、提示和点击行为由 FMenuBuilder::AddMenuEntry 定义:

void FEditorHelperModule::AddContentBrowserMenuEntry(FMenuBuilder& MenuBuilder)
{
	MenuBuilder.AddMenuEntry(
		FText::FromString(TEXT("Delete Unused Assets")),
		FText::FromString(TEXT("Safety delete all unused assets under folder")),
		FSlateIcon(),
		FExecuteAction::CreateRaw(
			this,
			&FEditorHelperModule::OnDeleteUnusedAssetButtonClicked)
	);
}

四个参数依次是:

菜单显示文字使用 FText,而不是 FStringFText 是 UE 面向用户界面和本地化的文本类型;FString 更适合普通字符串处理,FName 更适合名称、标识符和高频比较。当前代码使用 FText::FromString 可以正常显示,不过如果后续需要本地化,通常会使用 LOCTEXT

点击事件最终绑定到 OnDeleteUnusedAssetButtonClicked,具体实现放在后面的删除逻辑中。

新增的菜单功能选项

在模块卸载时注销委托

注册全局扩展的同时,也要考虑模块热重载、插件禁用以及编辑器退出时的清理:

void FEditorHelperModule::ShutdownModule()
{
	if (ContentBrowserMenuExtenderHandle.IsValid() &&
		FModuleManager::Get().IsModuleLoaded(TEXT("ContentBrowser")))
	{
		FContentBrowserModule& ContentBrowserModule =
			FModuleManager::GetModuleChecked<FContentBrowserModule>(
				TEXT("ContentBrowser"));

		ContentBrowserModule.GetAllPathViewContextMenuExtenders().RemoveAll(
			[this](const FContentBrowserMenuExtender_SelectedPaths& Extender)
			{
				return Extender.GetHandle() ==
					ContentBrowserMenuExtenderHandle;
			});
	}

	ContentBrowserMenuExtenderHandle.Reset();
}

先检查句柄有效,再确认 Content Browser 仍然处于加载状态。卸载阶段的模块顺序不一定与注册阶段相同,因此这里不能使用 LoadModuleChecked:清理代码不应该为了注销委托而重新加载一个已经卸载的模块。

RemoveAll 遍历扩展器数组,通过 FDelegateHandle 只移除当前模块注册的委托。最后调用 Reset 清空本地句柄。

这也是编辑器扩展中很重要的开发习惯:模块负责注册的全局回调,也应该由同一个模块负责注销。这样既避免悬空的 Raw 委托,也能防止热重载后同一个菜单项被重复添加。

删除没有用到的资产

其实,对于这篇博客的主题,这大概可能是最不重要的部分了。不过它正好可以把前面注册的菜单入口、选中的路径和点击委托串起来,完成一个可以实际使用的编辑器工具。

当前功能的目标是:右键选中一个或多个 Content Browser 文件夹,找出其中没有被其他资产引用的资产,确认后将其删除。

添加 Editor Scripting Utilities

资产查询和删除使用 UEditorAssetLibrary。这个库属于 EditorScriptingUtilities 插件,因此需要在 EditorHelper.uplugin 中声明插件依赖:

"Plugins": [
	{
		"Name": "EditorScriptingUtilities",
		"Enabled": true
	}
]

这表示启用 EditorHelper 时也需要启用 EditorScriptingUtilities。仅仅在代码中引入头文件还不够,构建模块也要添加对应依赖:

PrivateDependencyModuleNames.AddRange(
	new string[]
	{
		"CoreUObject",
		"Engine",
		"ContentBrowser",
		"UnrealEd",
		"Slate",
		"SlateCore",
		"UMG",
		"EditorScriptingUtilities"
	}
);

.uplugin 中的 Plugins 描述插件之间的启用关系,Build.cs 中的依赖则告诉 Unreal Build Tool 当前 C++ 模块需要链接哪些模块。两者解决的不是同一层问题,所以这里都需要配置。

CPP 中加入资产操作和消息辅助函数的头文件:

#include "DebugHelper.h"
#include "EditorAssetLibrary.h"

UEditorAssetLibrary 是编辑器脚本 API,只应该用在编辑器模块中,不能作为运行时游戏逻辑使用。当前插件模块的 Type 已经是 Editor,正适合放置这类工具。

保存选中的文件夹

菜单扩展委托会收到当前选中的路径,但点击菜单项使用的 FExecuteAction 是一个没有参数的委托,不能在点击时直接接收 SelectedPaths

因此,在模块中增加一个成员变量保存菜单构建时的选区:

FDelegateHandle ContentBrowserMenuExtenderHandle;
TArray<FString> SelectedFolderPaths;

然后在 CustomCBMenuExtender 中记录路径:

if (SelectedPaths.Num() > 0)
{
	SelectedFolderPaths = SelectedPaths;

	MenuExtender->AddMenuExtension(
		FName("Delete"),
		EExtensionHook::After,
		TSharedPtr<FUICommandList>(),
		FMenuExtensionDelegate::CreateRaw(
			this,
			&FEditorHelperModule::AddContentBrowserMenuEntry)
	);
}

这样形成了一次上下文传递:

构建右键菜单时取得 SelectedPaths
    -> 保存到 SelectedFolderPaths
    -> 用户点击菜单
    -> 点击回调读取 SelectedFolderPaths

这里保存的是路径字符串副本,而不是 Content Browser 控件或临时对象的引用,因此不会产生引用悬空的问题。

删除前进行检查和确认

删除是不可轻易撤销的编辑器操作。回调首先检查是否保存了有效选区:

if (SelectedFolderPaths.IsEmpty())
{
	DebugHelper::ShowMsgDialog(
		EAppMsgType::Ok,
		TEXT("No folder selected."));
	return;
}

虽然菜单只会在有选中路径时出现,但点击回调仍然进行防御性检查。UI 状态和实际执行之间可能存在时间差,执行函数不应该完全依赖界面层保证输入有效。

接着弹出 Yes/No 对话框:

if (DebugHelper::ShowMsgDialog(
		EAppMsgType::YesNo,
		TEXT("Delete all unused assets in the selected folder(s)?"))
	!= EAppReturnType::Yes)
{
	return;
}

EAppMsgType::YesNo 决定对话框提供的按钮,ShowMsgDialog 返回 EAppReturnType::Type。只有用户明确选择 Yes 才继续,其余结果统一提前返回。

这种写法也叫 Guard Clause:先处理无效状态和取消操作,让后面的核心逻辑保持较浅的缩进。

收集文件夹中的资产

用户可以同时选中多个文件夹。依次调用 ListAssets,把结果加入一个集合:

TSet<FString> AssetPaths;

for (const FString& FolderPath : SelectedFolderPaths)
{
	AssetPaths.Append(
		UEditorAssetLibrary::ListAssets(FolderPath, true, false));
}

UEditorAssetLibrary::ListAssets 的三个参数分别是:

  1. 要查询的目录路径;
  2. bRecursive = true,递归查询所有子文件夹;
  3. bIncludeFolder = false,结果中只包含资产,不包含文件夹路径。

这里使用 TSet<FString>,而不是 TArray<FString>。如果同时选中了父文件夹和它的子文件夹,递归查询会得到重复的资产路径;集合可以自动去重,确保同一个资产最多处理一次。

ListAssets 返回的是资产路径,而不是立即加载完成的 UObject。这类基于路径和 Asset Registry 信息的编辑器 API 很适合批量工具,可以避免为了检查资产而主动把所有资产加载到内存。

查询引用并删除

接下来遍历资产路径,查询每个资产的包引用者:

int32 DeletedCount = 0;

for (const FString& AssetPath : AssetPaths)
{
	const TArray<FString> Referencers =
		UEditorAssetLibrary::FindPackageReferencersForAsset(
			AssetPath,
			true);

	if (Referencers.IsEmpty() &&
		UEditorAssetLibrary::DeleteAsset(AssetPath))
	{
		++DeletedCount;
	}
}

FindPackageReferencersForAsset 返回引用该资产的包路径。第二个参数为 true,表示在查询之前确认 Asset Registry 中的引用数据是最新的。更新引用信息会增加一些开销,但删除工具更应该优先保证判断依据可靠。

只有同时满足两个条件才增加计数:

把两个条件放在 && 两侧还利用了 C++ 的短路求值:如果引用数组不为空,就不会调用后面的删除函数。

DeleteAsset 的返回值不能忽略。没有引用并不代表一定能成功删除,文件只读、源代码管理状态或编辑器当前状态等因素都可能使操作失败。

DeletedCount 统计的是实际成功删除的数量,而不是准备删除的数量。

需要注意,这里的「未使用」被定义为「没有其他资产包引用」。这只是基于包引用关系的判断,并不等同于它在任何场景下都绝对无用。例如,通过配置字符串、运行时约定路径或外部系统间接使用的资产,未必会出现在普通包引用关系中。因此批量删除前保留确认对话框仍然非常重要。

显示删除结果

循环结束后,使用之前封装的 Slate 通知显示结果:

DebugHelper::ShowNotifyInfo(FString::Printf(
	TEXT("Deleted %d unused asset(s)."),
	DeletedCount));

FString::Printf 负责格式化计数,ShowNotifyInfo 则通过 FSlateNotificationManager 在编辑器中显示非阻塞通知。

完整的点击回调如下:

void FEditorHelperModule::OnDeleteUnusedAssetButtonClicked()
{
	if (SelectedFolderPaths.IsEmpty())
	{
		DebugHelper::ShowMsgDialog(
			EAppMsgType::Ok,
			TEXT("No folder selected."));
		return;
	}

	if (DebugHelper::ShowMsgDialog(
			EAppMsgType::YesNo,
			TEXT("Delete all unused assets in the selected folder(s)?"))
		!= EAppReturnType::Yes)
	{
		return;
	}

	TSet<FString> AssetPaths;
	for (const FString& FolderPath : SelectedFolderPaths)
	{
		AssetPaths.Append(
			UEditorAssetLibrary::ListAssets(
				FolderPath,
				true,
				false));
	}

	int32 DeletedCount = 0;
	for (const FString& AssetPath : AssetPaths)
	{
		const TArray<FString> Referencers =
			UEditorAssetLibrary::FindPackageReferencersForAsset(
				AssetPath,
				true);

		if (Referencers.IsEmpty() &&
			UEditorAssetLibrary::DeleteAsset(AssetPath))
		{
			++DeletedCount;
		}
	}

	DebugHelper::ShowNotifyInfo(FString::Printf(
		TEXT("Deleted %d unused asset(s)."),
		DeletedCount));
}

小结

到这里,当前代码实现的功能已经全部串联起来:模块启动时注册 Content Browser 文件夹菜单,菜单构建时保存所选路径,点击后检查引用并删除资产,模块卸载时再移除菜单委托。

本博客所有文章除特别声明外,均采用 CC BY-NC-SA 4.0 许可协议。转载请注明来自小谷的随笔

上一篇
优雅的 Haskell 函数与类型
下一篇
UE5 编辑器插件开发:操作 Assets