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框架会自动管理这些状态之间的转换,并触发相应的回调事件(如OnActivated,OnDeactivated),让你可以在正确的时机执行初始化或清理逻辑。
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(TSubclassOf<UCommonActivatableWidget> WidgetClass, UObject* InitializationData) { if (WidgetClass) { // 1. 创建Widget实例 UCommonActivatableWidget* NewWidget = CreateWidget<UCommonActivatableWidget>(GetOwningPlayer(), WidgetClass); // 2. 如果有初始化数据,可以在这里设置(需要Widget实现特定接口) if (InitializationData && NewWidget->Implements<UMyWidgetInitializationInterface>()) { 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的
OnActivated,OnDeactivated,OnHandleBackAction等关键事件中插入打印节点,可以清晰看到生命周期的触发顺序。 - 检查输入模式:在玩家控制器中,可以使用
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到“能管理”的UI,Common UI和Activatable Widgets提供了一套工业级的解决方案。初学时会觉得概念繁多,但一旦理解其状态机和栈管理的核心思想,并成功搭建起第一个可流畅导航的菜单系统,你就会发现之前那些令人头疼的UI Bug都烟消云散了。这套框架强制你进行清晰的架构设计,从长远看,对于维护和扩展大型项目的UI系统至关重要。我的建议是,从一个简单的项目开始,严格按照Push/Pop和状态回调来操作,彻底告别手动管理Visibility的时代。当你习惯了这种模式,开发复杂UI将变得事半功倍。