尧图网络科技YAOTU DIGITAL 获取报价
获取报价
首页 / 资讯中心 / 文章详情

UE5编辑器扩展:用ToolMenus注册自定义菜单栏完整指南

发布时间:2026/9/26 20:12:20

资讯中心
01
ARTICLE

UE5编辑器扩展:用ToolMenus注册自定义菜单栏完整指南

UE5编辑器扩展:用ToolMenus注册自定义菜单栏完整指南
做 UE5 编辑器扩展绕不开的第一个需求往往就是给编辑器的菜单栏加一个属于自己的菜单。之前有位同事问我想把自己的一批工具塞进编辑器第一反应是去改引擎的 Source听我说别碰源码之后又问我能不能从菜单栏开始。其实 UE5 的编辑器菜单早就不是早年那种写死结构了而是在启动时通过 ToolMenus 这套注册系统动态拼装。这篇文章我就从零开始带你把一个 C 的编辑器模块搭起来在顶级菜单栏里注册出一个“我的工具”菜单并且往里面挂上真正可点击的菜单项。内容适合刚接触 UE5 编辑器扩展的开发者也适合以前一直用旧式 FExtender、想切到 ToolMenus 体系的插件作者。关键技术点只有三个编辑器模块的创建、ToolMenus 的扩展接口、FUIAction 回调绑定。把这三点理清楚后面再加窗口、加工具栏就都是套路了。1. 动手之前搞懂 UE5 编辑器的菜单系统1.1 菜单栏其实是一张注册表不是一份写死的清单很多人第一次接触编辑器扩展都会盯上引擎源码里的 MainFrame觉得要在菜单栏加东西就得改这里。这个思路也不能说错但代价很大引擎升级一次你的补丁就没了同事拉代码时还要面对“为什么你的编辑器多一个菜单我的没有”的困惑。正确的姿势是把编辑器菜单看成一张注册表UE5 在启动时会把各个模块注册好的菜单项汇总出来放到菜单栏对应的位置。你的模块只需要在宿主菜单上“挂号”不用动别人的代码。这个机制在 UE5 里的名字就是 ToolMenus。菜单结构是一棵以名字为地址的树比如 LevelEditor.MainMenu 对应主菜单栏LevelEditor.LevelEditorToolBar 对应工具栏甚至连资产编辑器的右键菜单也有自己的地址。我们要做的事本质上就是在 LevelEditor.MainMenu 下面再挂一个一级节点。1.2 新老接口的取舍为什么优先 ToolMenus在 UE4 时代常见做法是拿到 FLevelEditorModule 的 MenuExtensibilityManager再注册一个 FExtender过程啰嗦不说还经常要跟 MultiBox 的细节打交道。UE5 里这套接口虽然还能用官方和绝大多数第三方插件的实现都已经转向 ToolMenus。ToolMenus 的好处在于统一、名字化。一个菜单地址可以通过字符串定位注册逻辑通常就写在模块的 StartupModule 里面代码量比老写法少一半。理解成本也低收到一个 UToolMenu 指针你就可以在上面加 Section、加 Entry注册逻辑完全可控。而且不管主菜单栏、下拉菜单还是右键菜单都是同一套 API不用像老接口那样为不同场景记不同的扩展器用法。1.3 编辑器扩展模块是怎么回事UE5 的引擎和编辑器功能是通过模块来组织的可以理解成一个个 dll。普通游戏逻辑用的模块是 Runtime 类型而编辑器扩展必须是一个 Editor 类型的模块它只在编辑器进程里加载不会被打包进最终的游戏程序。这就是为什么编辑器工具代码要独立放一个模块而不是塞进主游戏模块里面。一个最小的 Editor 模块由一个加载入口类加一个模块描述文件组成。加载入口类实现 IModuleInterface启动时注册菜单关闭时清理资源。编辑器启动时模块系统按描述文件里的配置把模块加载进来并调用 StartupModule。听起来很正式但真正写下来代码量不大下一章我们就先把工程骨架搭出来。2. 项目准备创建编辑器模块2.1 插件还是源码模块动手前先决定模块放在哪。最常见的两种项目根目录的 Plugins 下创建插件适合复用、分发从 UE5 的插件菜单可以一键创建 Blank 插件。项目 Source 下直接建模块适合跟项目强耦合的私有工具通常只在当前工程内部使用。我个人建议哪怕只是项目内部工具也优先用插件。原因很简单插件自带 .uplugin 描述文件里面可以直接声明模块属性和加载阶段比改 .uproject 更清晰想迁移到其他工程时复制一个目录就行。如果选择插件创建插件时可以直接选 Editor 模式或者先创建 Blank 插件再手动把 Type 改成 Editor。2.2 模块描述文件的关键配置如果走插件路线插件目录下的 MyTools.uplugin 内容大概长这样{ FileVersion: 3, Version: 1, VersionName: 1.0, FriendlyName: My Tools, Category: Editor, Modules: [ { Name: MyTools, Type: Editor, LoadingPhase: PostEngineInit } ] }Type 是 Editor这个决定模块只在编辑器进程加载。LoadingPhase 用 PostEngineInit确保引擎初始化完成、菜单系统准备就绪的时候我们的模块才启动。如果这里写错了可能会遇到 ToolMenus 还没初始化、拿到空指针或者模块压根不加载的问题。如果不想建插件也可以在 .uproject 的 Modules 数组里写同样的内容。但我见过太多人被热重载和模块加载问题搞到怀疑人生插件在这方面的隔离性要好不少出了问题直接禁用插件就行不影响整个项目启动。2.3 Build.cs 的依赖到底要加多少编辑器模块最烦的就是链接依赖。很多新人编译报错最后发现就是 Build.cs 少了某个模块。以我的经验最小可运行菜单需要加这些依赖依赖模块用途Core / CoreUObject / Engine基本运行时UnrealEd编辑器基础功能Slate / SlateCoreUE 的 UI 框架ToolMenus菜单注册系统WorkspaceMenuStructure提供 Window 菜单的标准分组结构LevelEditor主编辑器框架菜单栏所在地其中最容易漏的是 ToolMenus 和 WorkspaceMenuStructure。很多教程只让你加 UnrealEd编译时看着没问题一用到特定接口就报错找半天才发现是依赖缺失。最稳妥的办法是先把这六个模块都加上后面编译报错再按提示调整。一份最小 Build.cs 示例using UnrealBuildTool; public class MyTools : ModuleRules { public MyTools(ReadOnlyTargetRules Target) : base(Target) { PCHUsage ModuleRules.PCHUsageMode.UseExplicitOrSharedPCHs; PublicDependencyModuleNames.AddRange( new string[] { Core, CoreUObject, Engine, InputCore }); PrivateDependencyModuleNames.AddRange( new string[] { UnrealEd, Slate, SlateCore, ToolMenus, WorkspaceMenuStructure, LevelEditor }); } }这一章的内容纯粹是工程配置不需要太多调试先把工程能编译出来、模块能加载后面才有得玩。3. 核心实现用 ToolMenus 注册菜单栏3.1 拿到主菜单栏的引用在 StartupModule 里第一步永远是先拿 UToolMenus 实例。UToolMenus 是编辑器菜单的全局单例它维护了所有已注册的菜单地址我们的所有注册操作都要通过它来完成所以这一步没有任何商量余地。UToolMenus* ToolMenus UToolMenus::Get(); if (ToolMenus nullptr) { return; }然后通过 ExtendMenu 获取主菜单栏的 UToolMenu 对象。这里有个细节ExtendMenu 这个接口名字容易让人误解它不只是“在原有菜单上追加”如果目标菜单不存在它还会顺手创建。所以直接用 ExtendMenu(LevelEditor.MainMenu) 是最省事的。UToolMenu* MainMenuBar ToolMenus-ExtendMenu(LevelEditor.MainMenu); if (MainMenuBar nullptr) { return; }拿到对象之后建议先判断一次空指针因为极端情况下主菜单栏可能还没准备好。编辑器模块的加载顺序偶尔会受插件影响空指针检查不是防御性编程而是必要保护。3.2 在菜单栏上添加自定义一级菜单主菜单栏本身是一个容器它里面是 File、Edit、Window 这些一级菜单项。我们要加的“我的工具”在实现上其实是一个 AddSubMenu向主菜单栏容器添加一个子菜单项但它在用户界面上会显示成菜单栏最顶层的一级菜单。FToolMenuSection Section MainMenuBar-AddSection( FName(MyToolsSection), LOCTEXT(MyToolsSectionLabel, MyToolsSection)); Section.AddSubMenu( FName(MyToolsMenu), LOCTEXT(MyToolsMenuLabel, 我的工具), LOCTEXT(MyToolsMenuTooltip, 打开自定义工具), FNewToolMenuDelegate::CreateLambda([](UToolMenu* SubMenu) { // 在这里构建下拉菜单项 }), false, FToolMenuInsert(FName(Window), EToolMenuInsertType::After));最后那个参数是插入位置在 Window 菜单后面插入。如果不关心顺序可以直接省略。这里解释一下 AddSubMenu 的函数签名参数分别是名字、显示文本、悬停提示、子菜单构建委托、悬停展开还是点击展开、插入规则。第五个参数设 false表示点击之后展开菜单而不是鼠标悬停就展开否则一级菜单经常莫名其妙弹出来影响使用体验。3.3 构造下拉菜单的具体菜单项AddSubMenu 里那个 lambda在用户点击“我的工具”、准备展开菜单时才执行所以每次打开都会重建内容这反而让你的菜单天然支持动态项。在 lambda 内部先用 SubMenu-AddSection 加一个分区然后往这个 Section 里 AddMenuEntry 添加菜单项。一个最少配方的菜单项长这样FNewToolMenuDelegate::CreateLambda([](UToolMenu* SubMenu) { FToolMenuSection SubSection SubMenu-AddSection( FName(MyToolsSubSection), LOCTEXT(MyToolsSubSectionLabel, 常用工具)); SubSection.AddMenuEntry( FName(PrintHello), LOCTEXT(PrintHelloLabel, 打印 Hello World), LOCTEXT(PrintHelloTooltip, 在输出日志打印一条信息), FSlateIcon(), FUIAction(FExecuteAction::CreateLambda([]() { UE_LOG(LogTemp, Log, TEXT(Hello from MyTools Menu)); }))); })这里最重要的概念是 FUIAction。它不是一个简单的回调而是一组状态执行回调、可用性判断回调、勾选状态回调。我们平时写的按钮、菜单项、工具栏按钮最终都会被包装成这种结构。菜单项点击后的动作就通过 FExecuteAction 触发。我在做这种菜单时习惯先把回调封成普通成员函数而不是一直写 lambda否则菜单项一多StartupModule 就会变成一坨又长又难维护的代码。示意的做法是 FExecuteAction::CreateRaw(this, FMyToolsModule::ExecutePrintHello)或者绑定一个稳定的管理类对象这样代码结构会清晰很多。3.4 一个可运行的完整示例把上面三小步拼起来再加上一个带勾选的菜单项完整代码大概这样。这里假设模块类命名为 FMyToolsModule你可以在自己的模块文件里按这个结构去填#include MyToolsModule.h #include ToolMenus.h #include Framework/Commands/UIAction.h #define LOCTEXT_NAMESPACE FMyToolsModule static bool bTestToggle false; void FMyToolsModule::StartupModule() { UToolMenus* ToolMenus UToolMenus::Get(); if (ToolMenus nullptr) { return; } UToolMenu* MainMenuBar ToolMenus-ExtendMenu(LevelEditor.MainMenu); if (MainMenuBar nullptr) { return; } FToolMenuSection Section MainMenuBar-AddSection( FName(MyToolsSection), LOCTEXT(MyToolsSectionLabel, MyToolsSection)); Section.AddSubMenu( FName(MyToolsMenu), LOCTEXT(MyToolsMenuLabel, 我的工具), LOCTEXT(MyToolsMenuTooltip, 打开自定义工具), FNewToolMenuDelegate::CreateLambda([](UToolMenu* SubMenu) { FToolMenuSection SubSection SubMenu-AddSection( FName(MyToolsSubSection), LOCTEXT(MyToolsSubSectionLabel, 常用工具)); SubSection.AddMenuEntry( FName(PrintHello), LOCTEXT(PrintHelloLabel, 打印 Hello World), LOCTEXT(PrintHelloTooltip, 在输出日志打印一条信息), FSlateIcon(), FUIAction(FExecuteAction::CreateLambda([]() { UE_LOG(LogTemp, Log, TEXT(Hello from MyTools Menu)); }))); SubSection.AddMenuEntry( FName(ToggleFeature), LOCTEXT(ToggleFeatureLabel, 启用测试开关), LOCTEXT(ToggleFeatureTooltip, 切换一个测试布尔值), FSlateIcon(), FUIAction( FExecuteAction::CreateLambda([]() { bTestToggle !bTestToggle; UE_LOG(LogTemp, Log, TEXT(ToggleValue %d), bTestToggle); }), FCanExecuteAction(), FIsActionChecked::CreateLambda([]() { return bTestToggle; }) ), EUserInterfaceActionType::ToggleButton ); }), false, FToolMenuInsert(FName(Window), EToolMenuInsertType::After)); } void FMyToolsModule::ShutdownModule() { // 菜单对象由 ToolMenus 统一管理一般不需要手动销毁 } #undef LOCTEXT_NAMESPACE IMPLEMENT_MODULE(FMyToolsModule, MyTools)留意 ToggleButton 类型的菜单项它会在左侧显示勾选状态适合做开启/关闭性质的开关。真正常用的功能不外乎两种执行任务、切换状态这两个示例足够给你后面铺路了。编译跑起来以后你会在面板左上角看到“我的工具”这个新菜单点开后就有两个能用的项。4. 常见问题排查与调试技巧4.1 菜单死活不显示先查模块有没有加载菜单不显示这种事90% 的根因都不在注册代码本身而是第一步就断掉了。打开编辑器的 Window Modules 面板搜一下你的模块名看状态是不是 Loaded。如果没有 Loaded大概率是 .uplugin 或 .uproject 的 Type 写成了 Runtime导致模块只在打包时才加载。排查顺序我一般是这样模块状态 → StartupModule 里打个断点 → 看看 UToolMenus 实例是否为空 → 看看 ExtendMenu 是否返回空。只要断点能踩到菜单栏还看不到再考虑是不是注册代码漏了 AddSubMenu 那一层。另外开发过程中快速迭代编辑器经常缓存着旧模块状态遇到神秘问题先完全关掉编辑器再重新打开比在代码里猜半天管用。热重载虽然方便但 ToolMenus 注册这种全局状态热重载后偶尔会给你留下重复菜单或者干脆消失。4.2 编译报错的各种大头最常见的报错是找不到 UToolMenus或者无法解析 FUIAction。遇到这种问题第一反应不是去头文件里手动 include 一堆东西而是回到 Build.cs 看依赖找不到 UToolMenus 相关符号确认有 ToolMenus 依赖。找不到 FExecuteAction / FUIAction确认有 Slate 依赖同时 include Framework/Commands/UIAction.h。找不到 FNewToolMenuDelegateinclude ToolMenus.h 基本都能解决。如果编译信息指向某个宏或常量比如 LOCTEXT_NAMESPACE 未定义那就是忘了写 define 和 undef。这类问题是纯工程配置问题和我当初第一次写插件时踩的坑一模一样。遇到编译错误不要慌先看是源码问题还是链接问题链接问题大多数都能在 Build.cs 里找到答案。4.3 回调不执行可能是生命周期问题菜单项点击后没有反应看起来像回调没绑上其实是对象短命。用 CreateLambda 绑定 this 时如果 this 是模块单例一般安全但如果你绑的是一个临时对象菜单打开时对象已经被销毁回调自然没法触发。我在团队里常用的原则能绑模块对象就绑模块对象能绑稳定的 UObject 就绑 UObject避免 CreateRaw 绑定一个生命周期不确定的原生指针。凡是涉及窗口、勾选状态、异步操作的优先用 TWeakObjectPtr 判断有效性再执行不然编辑器崩溃会来得非常随机。4.4 验证菜单项是否真的注册成功注册代码跑通了但菜单项点开会闪退这时候可以通过日志验证。在 AddMenuEntry 的 lambda 开头加一行 UE_LOG每次展开菜单都会输出。因为这个委托就是打开菜单时才执行的日志能告诉你两件事菜单是否构建了、构建过程在哪里卡住。逻辑简单的话直接在 Executor 回调里一点一点 UE_LOG可以确认绑定链是否完整。等于是给代码加临时探针排查完删掉即可。5. 实操心得与后续扩展5.1 我的菜单注册代码组织习惯菜单项一多把所有逻辑堆在 StartupModule 里确实不行。我后来的标准做法是把每一个工具的入口定义成一个独立类菜单注册只负责把命令文本、回调指针塞给 ToolMenus具体执行归业务类。菜单这层保持薄命令层保持瘦两者各干各的活。还有一点菜单的名字、Section 名字属于全局标识尽量起得具体一点比如用插件名做前缀否则版本多了之后一不小心就和别人的菜单撞名。某些第三方插件互相干扰多半就是全局菜单注册名太随便。5.2 下一步可以做的三个方向菜单栏通常只是编辑器工具的开胃菜。菜单挂通之后最自然的下一步是这三个把菜单项改成打开一个自定义 Tab 窗口之后资产批量处理、调试面板都可以挂进去。给同一个菜单项加快捷键FExecuteAction 不变只是把命令和输入映射绑定起来。在资产编辑器的右键菜单里加项比如对选中的一堆资产执行批量操作这是实际工作中需求量很大的功能。我给自己定的节奏是先把菜单这一层做扎实后面才能放心往里面填内容。菜单只是个入口真正的工具逻辑要花的心思多得多。下一篇我打算接着讲这个系列的第二个场景把菜单项接上真正的工具窗口到时候你会看到菜单和 Slate 窗口怎么串成一个完整的工作流。
02
RELATED NEWS

相关资讯

更多网站建设与数字化升级内容

03
WHY YAOTU

想打造同款高转化官网?

懂行业、懂生意,从建站到增长一站式陪跑

◈

场景化定制

不做模板站,围绕你的业务场景量身设计,小众不撞款。

◐

营销型架构

以转化目标组织内容与路径,让官网真正带来询盘。

▲

全周期服务

设计、开发、运营、运维一体,上线只是开始。

免费获取你的建站方案

留下需求,专属顾问 24 小时内为你输出方案建议。