委托(Delegate)
编辑器扩展的核心并不是主动修改 Content Browser 的源码,而是在引擎预留的扩展点上注册自己的回调。Content Browser 准备构建右键菜单时广播事件,插件收到事件后返回菜单扩展;用户点击新增的菜单项时,另一个委托再调用插件中的处理函数。
UE 委托最常见的用途就是把「事件何时发生」与「事件发生后做什么」解耦。
普通的 C++ 函数调用需要直接知道调用对象和函数;委托则先保存一个可调用目标,等到合适的时机再执行。以按钮为例,按钮只需要知道点击时执行一个委托,不需要知道最终处理点击的是哪个类。
最重要的操作是绑定(Bind):将委托与函数联系起来。不同的绑定方式对应不同的对象生命周期,例如 CreateRaw、CreateSP 和 CreateUObject。
不同类型的委托
常见的委托可以分为:
- 单播委托(Single-cast Delegate):只绑定一个可调用目标,可以有返回值;
- 多播委托(Multicast Delegate):可以绑定多个目标,广播时依次执行,通常没有返回值;
- 动态委托(Dynamic Delegate):支持 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 已经定义好了菜单扩展需要的委托类型:
FContentBrowserMenuExtender_SelectedPaths:接收当前选中的路径,返回一个FExtender;FMenuExtensionDelegate:在构建菜单时接收FMenuBuilder,向其中添加菜单项;FExecuteAction:用户点击菜单项时执行的无参数操作。
三个委托组成了一条调用链:
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 提供 FExtender、FMenuBuilder 和菜单扩展相关类型。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:
LoadModuleChecked<T>:如果模块尚未加载则加载它,并返回指定类型;加载失败时会触发检查,适合当前这种必须依赖 Content Browser 才能工作的场景;GetAllPathViewContextMenuExtenders():取得 Content Browser 左侧路径视图的右键菜单扩展器。它针对的是文件夹路径,不是右侧资产列表中的资产;CreateRaw(this, ...):把当前模块对象的普通 C++ 成员函数绑定到委托;FDelegateHandle:委托注册后的唯一句柄,后续可以用它准确找到并移除这次注册;MoveTemp:把局部委托移动进数组,避免一次不必要的复制,并明确转移其内容。
CreateRaw 不会追踪对象生命周期。也就是说,委托不会因为对象销毁而自动失效。因此模块卸载时必须主动注销,避免 Content Browser 之后调用已经不存在的模块对象。这里能够使用 CreateRaw,正是因为模块同时管理了注册和注销。
决定菜单插入位置
编辑器设置中可以启用 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 的参数分别表示:
- 插入点名称
Delete; - 插入到该扩展点之后;
- 可选的
FUICommandList,当前没有使用,所以传入空的TSharedPtr; - 实际构建菜单内容的
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)
);
}
四个参数依次是:
- 菜单项显示文本;
- 鼠标悬停时的提示文本;
- 菜单图标,当前使用空的
FSlateIcon,所以不会显示自定义图标; - 点击后执行的
FExecuteAction。
菜单显示文字使用 FText,而不是 FString。FText 是 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 的三个参数分别是:
- 要查询的目录路径;
bRecursive = true,递归查询所有子文件夹;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 中的引用数据是最新的。更新引用信息会增加一些开销,但删除工具更应该优先保证判断依据可靠。
只有同时满足两个条件才增加计数:
Referencers.IsEmpty():没有查到引用该资产的包;DeleteAsset(AssetPath):资产确实删除成功。
把两个条件放在 && 两侧还利用了 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 文件夹菜单,菜单构建时保存所选路径,点击后检查引用并删除资产,模块卸载时再移除菜单委托。