资讯动态

UE5 Common UI与Activatable Widgets:构建可管理、可交互的健壮菜单系统

发布时间:2026/8/10 1:40:11 来源:尧图企业网站定制
1. 项目概述从“能显示”到“能交互”的UI思维跃迁刚接触UE5的开发者尤其是从蓝图或者传统UMG虚幻运动图形UI系统转过来的朋友常常会陷入一个误区UI不就是拖几个按钮、图片然后绑定几个点击事件吗直到你开始尝试构建一个稍微复杂点的菜单系统比如带有多级子菜单、暂停菜单、设置面板并且希望它们能流畅地叠加、切换、响应手柄输入时才会发现传统的UMG Widget在架构上有点力不从心。这时你大概率会听到一个词Common UI以及它的核心——Activatable Widgets。我第一次被这个概念“教育”是在尝试复刻一个3A游戏的主菜单时。我的蓝图里堆满了“Set Visibility”和“Add to Viewport”/“Remove from Viewport”的节点逻辑很快变成了一团乱麻。哪个菜单在最上层手柄的“返回”键应该关掉哪个面板为什么我的UI有时吃不到输入这些问题让我意识到UI不仅仅是视觉布局更是一套需要精心管理的状态机和输入栈。Common UI提供的Activatable Widgets就是虚幻引擎官方给出的、用于解决这些架构级问题的标准化方案。简单来说Activatable Widgets是一种“可激活”的UI控件。它不再是简单的显示/隐藏而是拥有“激活”、“停用”、“暂停”等明确的生命周期状态。Common UI则是一套建立在它之上的框架帮你自动管理这些Widget的堆叠、输入路由、音效和视觉效果。这就像从手动管理每个窗口的“记事本”升级到了拥有窗口管理器的“操作系统”。对于新手理解并上手这套机制是脱离UI“玩具”阶段迈向构建健壮、可维护的商用级UI系统的关键一步。本文就将带你从零开始拆解官方示例最终亲手打造你的第一个可交互菜单系统。2. Common UI与Activatable Widgets核心机制拆解在深入实操前我们必须先建立正确的认知模型。Common UI不是一个具体的控件而是一个插件和一套设计范式。它的核心目标是为游戏UI提供一致的行为和输入处理尤其擅长处理基于控制器的导航和复杂的菜单流。2.1 为什么需要Common UI传统UMG的三大痛点传统UMG Widget在简单场景下工作良好但在复杂交互中会暴露出以下问题输入路由混乱当多个UI面板叠加时如主菜单上弹出确认对话框你希望手柄或键盘输入只被最顶层的对话框接收。传统方法需要手动编写逻辑来判断哪个Widget应该响应输入极易出错。状态管理复杂一个菜单可能有打开、关闭、半透明暂停如打开子菜单时主菜单变暗等多种状态。用Visibility和布尔变量来管理随着菜单数量增加状态组合会呈指数级增长难以维护。缺乏统一生命周期Widget被添加到视口和从视口移除时虽然有Construct和Destruct事件但对于“暂时失去焦点但依然可见”如被另一个菜单覆盖这种状态没有标准的处理钩子。这导致资源如动画、音效管理不便。Common UI通过引入“激活”的概念将UI控件视为具有明确状态的实体从而系统性地解决了这些问题。2.2 Activatable Widgets的四种核心状态这是理解整个框架的基石。一个Activatable Widget在其生命周期内会在以下几种状态间转换未激活 (Deactivated)Widget已加载但未显示不接收输入不执行Tick。相当于传统UMG中尚未Add to Viewport的状态。已激活 (Activated)Widget已显示并处于活动状态接收输入执行Tick。这是Widget的“前台工作”状态。已停用 (Deactivated)Widget已从激活状态退出不再显示不接收输入。注意这与“未激活”的英文相同但发生在激活之后是生命周期的一个阶段通常会触发关闭动画。暂停 (Paused)这是一个关键状态。Widget仍然可见但不接收输入且Tick可能被暂停取决于配置。典型场景是打开一个设置面板时背后的主菜单变暗但依然可见。这避免了重复的隐藏/显示操作并能保持视觉连续性。Common UI框架会自动管理这些状态之间的转换并触发相应的回调事件如OnActivatedOnDeactivated让你可以在正确的时机执行初始化或清理逻辑。2.3 输入路由与“UI栈”管理Common UI的核心管理组件是CommonActivatableWidgetStack。你可以把它想象成一个专门存放UI的“栈”后进先出。推入 (Push)将一个Activatable Widget推入栈顶。它会进入“已激活”状态并开始接收输入。如果栈里之前有Widget那个Widget会根据规则进入“暂停”或“停用”状态。弹出 (Pop)将栈顶的Widget移除。它会进入“已停用”状态。如果下面还有Widget那个Widget会被重新“激活”并重新获得输入焦点。输入阻断栈顶的Widget会阻断输入向栈下方传递。这意味着你只需要关心当前激活的Widget不用担心底下的菜单误触发。这种栈式管理自动解决了“哪个UI该响应输入”的问题并且让“返回”按钮的实现变得异常简单通常就是执行一次Pop操作。实操心得刚开始我试图自己用数组模拟这个栈很快就遇到了输入冲突和状态同步的坑。直接使用CommonActivatableWidgetStack组件是避免重复造轮子的最佳实践。它经过大量测试行为符合玩家预期。3. 环境准备与第一个Activatable Widget创建理论铺垫完毕我们开始动手。确保你使用的是UE5.0或更高版本。3.1 启用Common UI插件打开你的UE5项目点击菜单栏的编辑 (Edit) - 插件 (Plugins)。在插件窗口的搜索框中输入“Common UI”。找到“Common UI”插件勾选其右侧的复选框。编辑器会提示需要重启。点击“立即重启”。重启后再次进入插件设置搜索“Common Game”插件并启用它。这个插件提供了与Common UI配合的基础游戏框架比如玩家控制器和游戏状态能让我们的UI集成更顺畅。3.2 创建你的第一个Activatable Widget蓝图在内容浏览器中右键选择用户界面 - Widget蓝图。给它起个名字比如WBP_MainMenu。双击打开这个Widget蓝图。在细节面板中找到“父类”选项。点击下拉菜单你会发现多出了很多以CommonActivatableWidget开头的类。选择CommonActivatableWidget作为父类。这是所有可激活控件的基类提供了最基本的状态回调。小提示如果你需要全屏菜单如主菜单可以选择CommonActivatableWidget。如果需要弹出式对话框可以选择CommonPopupMenu它自带了一些对话框的通用行为。现在你的Widget蓝图已经具备了Activatable的能力。观察它的图表你会看到事件列表里多了几个新的事件On Activated当Widget被推入栈顶并激活时触发。On Deactivated当Widget从栈顶被移除弹出时触发。On Handle Back Action这是一个非常有用的函数。当玩家按下通用的“返回”键如手柄的B键、键盘的Esc时框架会尝试调用栈顶Widget的这个函数。你可以在这里实现返回逻辑比如播放一个关闭动画然后返回true表示已处理或者返回false让框架自动执行Pop。3.3 设计一个简单的主菜单界面让我们先设计一个最简单的界面包含一个标题和两个按钮。在Widget蓝图的设计器视图中从左侧面板拖入一个Canvas Panel作为根容器。拖入一个Text Block设置其文本为“我的游戏主菜单”调整字体和位置作为标题。拖入一个Button控件将其锚点置于画布中心文本设为“开始游戏”。再拖入一个Button放在第一个按钮下方文本设为“设置”。为了美观可以拖入一个Image控件作为背景图。你的第一个Activatable Widget的视觉部分就完成了。接下来是关键的一步让它“活”起来。4. 构建UI栈与实现菜单流逻辑单独的Widget没有意义我们需要一个容器来管理它们这就是UI栈。4.1 创建并配置UI栈通常我们会将UI栈放在一个独立的、始终存在的“根”Widget中或者放在玩家控制器里。这里我们采用更模块化的方式创建一个专门的HUD Widget来承载所有菜单栈。创建一个新的Widget蓝图父类选择CommonActivatableWidgetContainer。命名为WBP_RootHUD。打开WBP_RootHUD在设计器中你可以看到一个预置的CommonActivatableWidgetStack控件。它通常已经放在画布里了。这个控件就是我们的“栈容器”。选中这个Stack控件在细节面板中找到“栈类型”属性。它有几种选项Global: 全局栈通常用于主菜单、暂停菜单等。GameMenu: 游戏内菜单栈。HUD: 用于平视显示器元素。我们选择Global。这意味着这个栈将用于管理我们全局的菜单流。4.2 初始化并推入主菜单现在我们需要在游戏开始时自动将主菜单推入这个栈。打开你的游戏模式蓝图或你用来初始化游戏逻辑的蓝图。在游戏开始的事件中如Event BeginPlay。我们需要创建并显示WBP_RootHUD。由于Common UI框架有推荐的方式我们通常使用CommonUI子系统来创建Widget。在蓝图图表中获取CommonUI子系统上下文对象可以是Get Game Instance。调用Create Widget节点注意不是普通的Create Widget这里为了演示通用流程我们先按常规。实际上更佳实践是在你的玩家控制器继承自CommonPlayerController的PostInitializeComponents事件中创建WBP_RootHUD并调用AddViewportWidgetForPlayer确保它一直存在。为了简化我们假设WBP_RootHUD已经被创建并添加到视口。打开WBP_RootHUD的图表。我们需要在它被激活后自动将主菜单推入栈。在事件图表中拖出On Activated事件。从On Activated节点拉出引线搜索Get Stack Widget节点你需要先选中设计器中的Stack控件然后右键在图表中“创建对Stack控件的引用”。调用Stack控件的Push Widget函数。在Widget Class参数上选择我们之前创建的WBP_MainMenu。关键设置将Input Mode参数设置为Menu。这会将游戏输入模式切换为“仅UI”确保玩家角色不会在菜单打开时移动。Layer参数可以先保持默认。现在运行游戏你应该能看到WBP_RootHUD被创建然后WBP_MainMenu被自动推入并显示。4.3 实现菜单导航从主菜单到设置菜单让我们实现点击“设置”按钮打开一个设置菜单。首先创建第二个Activatable Widget作为设置菜单。步骤同上命名为WBP_SettingsMenu父类为CommonActivatableWidget。在里面设计一些简单的设置选项比如一个滑动条调节音量和一个“返回”按钮。打开WBP_MainMenu蓝图进入图表。找到“设置”按钮的On Clicked事件。在这个事件的处理逻辑中我们需要获取到管理它的UI栈然后推入设置菜单。调用Get Owning Local Player节点。调用Get Player Controller节点。从Player Controller调用Get HUD节点并转换为你的WBP_RootHUD类假设HUD就是它。成功转换后获取到WBP_RootHUD的引用然后调用其上的一个自定义函数我们需要先创建它来执行推入操作。这样设计更清晰。在WBP_RootHUD中创建一个新的自定义函数命名为PushSettingsMenu。在这个函数里获取到它的CommonActivatableWidgetStack控件引用。调用Stack的Push Widget函数Widget Class选择WBP_SettingsMenu。回到WBP_MainMenu在按钮点击事件中调用WBP_RootHUD的PushSettingsMenu函数。现在运行游戏点击主菜单的“设置”按钮你会发现设置菜单平滑地覆盖在了主菜单之上。注意观察主菜单依然可见但变暗了这是Common UI默认的视觉样式这就是它进入了“暂停”状态。4.4 实现返回逻辑有两种方式实现返回方式一利用On Handle Back Action事件推荐打开WBP_SettingsMenu蓝图。在图表中右键搜索On Handle Back Action事件并添加。在这个事件的处理逻辑中你可以先播放一个关闭动画如果有然后必须返回一个布尔值。返回true表示你已经处理了这个返回动作框架不会做其他事。但通常我们希望框架自动弹出这个Widget。所以更常见的做法是直接调用Deactivate Widget节点这个节点在CommonActivatableWidget蓝图库中然后返回true。Deactivate Widget会请求框架弹出当前Widget。同时你也可以为“返回”按钮的点击事件绑定同样的逻辑调用Deactivate Widget。方式二手动从栈中弹出在WBP_SettingsMenu的“返回”按钮点击事件中。同样需要先获取到WBP_RootHUD的引用。调用WBP_RootHUD上的另一个自定义函数例如PopTopWidget。在WBP_RootHUD的PopTopWidget函数中调用Stack控件的Pop Widget函数。注意事项强烈推荐使用方式一。因为它与框架的输入系统深度集成。当玩家按下手柄的B键或键盘的Esc时框架会自动调用栈顶Widget的On Handle Back Action事件。你只需要在这个事件里实现弹出逻辑就能免费获得跨平台的“返回”键支持无需手动绑定输入按键。5. 深入官方示例Lyra Game的UI架构解析学习新系统研究官方示例是最快的方式。UE5的示例项目Lyra在启动器的“学习”选项卡中可下载是Common UI的最佳实践宝库。虽然项目庞大但我们可以聚焦其UI部分。5.1 Lyra的UI层次结构Lyra的UI管理非常清晰ALyraHUD游戏的HUD类负责创建和管理最底层的UI容器——ULyraUIController。ULyraUIController这是UI系统的“大脑”。它是一个UObject组件挂载在玩家控制器上。它创建并持有了多个UCommonActivatableWidgetStack的实例分别对应不同的层级Layer例如GameplayStack用于游戏内的HUD元素血条、弹药。MenuStack用于游戏内菜单如背包、地图。ModalStack用于模态对话框如确认框、提示。SystemModalStack用于系统级模态对话框如网络错误。分层管理这种分层是关键。ModalStack里的Widget会阻断MenuStack和GameplayStack的输入MenuStack又会阻断GameplayStack。这完美实现了UI的优先级管理。你的HUD元素Gameplay层永远在最底层菜单覆盖其上对话框又覆盖菜单。5.2 如何借鉴到自己的项目你不需要完全照搬Lyra的复杂架构但可以吸收其核心思想使用多个栈至少区分Gameplay和Menu两个栈。这能避免你的血条UI和菜单系统互相干扰。创建UI管理器仿照ULyraUIController创建一个自己的UMyUIController组件或子系统。它的职责是在游戏初始化时创建各个UI栈的实例。提供简洁的API给其他蓝图或C调用例如ShowMainMenu(),ShowPauseMenu(),ShowConfirmationDialog()。在这些API内部处理具体的Push/Pop逻辑。管理全局的UI输入模式是仅UI、仅游戏、还是两者兼顾。Widget数据初始化Lyra中在Push Widget时经常通过一个FDataTable或自定义结构体将数据“注入”到Widget中。例如打开一个物品详情菜单会把物品ID传进去。你可以在自定义的Push函数中增加一个Initialization Data参数来实现类似功能。5.3 从Lyra示例中提炼的实用代码片段虽然我们主要用蓝图但了解其C思路对设计有帮助。例如Lyra中推入一个Widget的典型流程封装得很好// 这是在UILayer里封装的函数 UCommonActivatableWidget* UMyUILayer::PushWidget(TSubclassOfUCommonActivatableWidget WidgetClass, UObject* InitializationData) { if (WidgetClass) { // 1. 创建Widget实例 UCommonActivatableWidget* NewWidget CreateWidgetUCommonActivatableWidget(GetOwningPlayer(), WidgetClass); // 2. 如果有初始化数据可以在这里设置需要Widget实现特定接口 if (InitializationData NewWidget-ImplementsUMyWidgetInitializationInterface()) { IMyWidgetInitializationInterface::Execute_InitializeWidget(NewWidget, InitializationData); } // 3. 推入栈 ActivatableStack-PushWidget(*NewWidget); return NewWidget; } return nullptr; }在蓝图中你可以模仿这个模式创建一个“UI管理器”Actor或组件它持有对各个Stack的引用并暴露一系列蓝图函数库让其他蓝图可以方便地调用Push Menu Widget等操作。6. 常见问题、调试技巧与性能优化在实际使用中你肯定会遇到各种问题。以下是我踩过坑后总结的清单。6.1 常见问题速查表问题现象可能原因解决方案点击按钮无反应1. Widget未激活。2. 输入被其他Widget或游戏模式阻断。3. 按钮本身被其他控件覆盖Hit Test不可见。1. 检查Widget是否被成功Push到栈顶并处于Activated状态可在OnActivated事件中打印日志。2. 检查UI栈的输入模式。确保顶层Widget的输入模式正确如Menu。检查游戏模式是否设置了SetInputMode冲突。3. 在设计器检查控件层级确保按钮的Is Enabled和Visibility正确。“返回”键Esc/B键无效1. Widget未正确处理On Handle Back Action事件。2. 输入未绑定到Common UI子系统。1. 在顶层Activatable Widget的图表中添加On Handle Back Action事件并确保返回True或调用Deactivate Widget。2. 在项目设置中检查Common UI部分确保DefaultClickHoldAction和DefaultBackAction已绑定到正确的输入操作如UI_Back。多个UI同时接收输入UI栈管理混乱可能有多个Widget处于激活状态或未使用栈管理。确保所有需要独占输入的菜单Widget都通过同一个CommonActivatableWidgetStack的Push/Pop来管理。避免混合使用Add to Viewport和Push。打开新菜单时旧菜单消失而不是暂停在Push新Widget时旧Widget的Desired Visibility或栈的配置问题。检查Stack控件的属性bAutoDeactivateOnPush。如果希望旧Widget暂停保持可见但不交互通常需要配合Widget自身的Activation Policy激活策略使用。最常见的策略是On Managed Stack让栈来决定其状态。UI动画在状态切换时播放异常动画可能在错误的生命周期事件中触发。将打开动画绑定在On Activated事件或NativeOnActivated事件中。将关闭动画绑定在On Deactivated事件中。避免在Construct或PreConstruct中播放一次性动画。6.2 调试技巧使用“Common UI Debug”工具在编辑器运行时打开“输出日志”窗口输入命令CommonUI.Debug.ToggleWidgetInspector。这会在屏幕上显示当前所有UI栈的状态、层级以及每个Activatable Widget的当前状态激活/暂停/停用。这是排查UI栈问题的最强利器。善用打印字符串在Widget的OnActivatedOnDeactivatedOnHandleBackAction等关键事件中插入打印节点可以清晰看到生命周期的触发顺序。检查输入模式在玩家控制器中可以使用Get Input Mode节点来检查当前的输入模式确认是否与你的UI预期相符。6.3 性能优化与最佳实践懒加载与池化对于频繁打开关闭的菜单如物品栏不要每次都创建新的Widget实例。可以在UI管理器中预先创建Pre-construct一个实例池需要时从池中取出并Push关闭时Pop并放回池中仅重置其状态。这能有效减少GC垃圾回收压力。减少Tick依赖Activatable Widget在非激活状态默认不Tick这很好。但在激活状态也要检查其内部是否有不必要的Tick逻辑。UI的刷新应尽量由事件驱动如数据更新时刷新而非每帧Tick。合理使用异步加载如果菜单内容复杂如大量图标、3D模型考虑在菜单激活前进行异步加载并在加载期间显示一个加载动画。可以在On Activated事件中开始加载在加载完成的回调里再显示实际内容。声音和视觉反馈Common UI框架内置了对按钮点击等通用操作的声音和视觉反馈如按下效果的支持。确保你的按钮使用了CommonButtonBase或其子类并在项目设置中配置好通用的音效和样式数据资产这样可以获得一致且高效的反馈体验无需为每个按钮单独设置。从“能显示”的UI到“能管理”的UICommon UI和Activatable Widgets提供了一套工业级的解决方案。初学时会觉得概念繁多但一旦理解其状态机和栈管理的核心思想并成功搭建起第一个可流畅导航的菜单系统你就会发现之前那些令人头疼的UI Bug都烟消云散了。这套框架强制你进行清晰的架构设计从长远看对于维护和扩展大型项目的UI系统至关重要。我的建议是从一个简单的项目开始严格按照Push/Pop和状态回调来操作彻底告别手动管理Visibility的时代。当你习惯了这种模式开发复杂UI将变得事半功倍。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价