2093 字
10 分钟
UE UI 系统——从 Slate、UMG 到高性能 Widget

一、UMG 不是 UI 的全部#

在 UE 里写 UI,最常见的入口是 UMG Designer:拖一个 TextBlock、一个 Button,然后在蓝图里连事件。但 UMG 更像是面向 Gameplay 的 UI 封装层,底层真正负责布局和绘制的是 Slate。

UMG(UUserWidget / UButton / UTextBlock)
↓ 包装与暴露给 Gameplay 的 UObject 接口
Slate(SWidget / SButton / STextBlock)
↓ 布局、输入路由、绘制命令
渲染线程 / RHI
屏幕上的像素

两套体系解决的问题不同:

主要职责典型使用场景
Slate高性能、底层、编辑器级 UI编辑器、复杂自定义控件、基础绘制
UMGUObject 生命周期、蓝图、Designer游戏 HUD、菜单、背包、提示框

UUserWidget 里通常会生成一个对应的 Slate Widget。你在 UMG 里看到的是易用的 Gameplay 接口,Slate 才是每帧参与布局和绘制的对象树。

理解这一层关系很重要: 当 UI 出现卡顿时,问题可能来自 Widget 的 UObject 逻辑,也可能来自 Slate 树的布局重算,不能只盯着蓝图节点找。


二、Widget Tree:UI 也是一棵场景树#

一个简单的合作副本 HUD 可能长这样:

CoopHUD
└── CanvasPanel
├── TopBar
│ ├── RoundStateText
│ └── TimerText
├── ItemPanel
│ ├── ItemImage
│ └── EstimateText
└── ActionPanel
├── ActionHintText
├── AbilityButton
└── HintText

父节点会影响子节点的布局、可见性、命中测试和绘制顺序。于是有一个很实际的原则:

UI 层级不是免费的。每多一层 Panel,就多一层布局和遍历关系。

这不代表要把所有控件都堆到一个 Canvas 上。层级是表达布局约束的工具,问题在于不要无意识地嵌套几十层 OverlayBorderSizeBox

1 CanvasPanel 不是什么万能布局#

CanvasPanel 适合 HUD 上固定锚点的元素,但不适合所有列表:

需求更合适的容器
自适应横向排列HorizontalBox
自适应纵向排列VerticalBox
大量可滚动条目ListView / TileView
覆盖层叠加Overlay
需要统一边距Border / Padding

列表如果手动创建几百个 Widget 再全部放进 VerticalBox,滚动时布局和 Tick 都会变重。ListView 的虚拟化会只创建当前可见范围附近的条目,更适合背包、排行榜和任务历史。


三、Widget 生命周期:不要在 Construct 里做所有事#

常用的生命周期大致是:

CreateWidget
→ NativeOnInitialized / OnInitialized
→ NativeConstruct / Construct
→ AddToViewport
→ Tick / 输入 / 属性更新
→ RemoveFromParent
→ Destruct

1 初始化和构造的边界#

void UCoopHUDWidget::NativeOnInitialized() {
Super::NativeOnInitialized();
AbilityButton->OnClicked.AddDynamic(
this, &UCoopHUDWidget::OnAbilityClicked);
}
void UCoopHUDWidget::NativeConstruct() {
Super::NativeConstruct();
RefreshAll();
}
void UCoopHUDWidget::NativeDestruct() {
UnbindFromRoundState();
Super::NativeDestruct();
}

如果 Widget 会反复打开和关闭,绑定事件时尤其要小心。每次 ConstructAddDynamic,却没有在销毁时解绑,最终可能一次点击触发多次回调。

2 RemoveFromParent 不等于 Destroy#

RemoveFromParent() 只是把 Widget 从当前的父节点移除,Widget 对象可能仍然被其他引用持有。这个特性适合缓存常用菜单,但也带来两个坑:

  • 以为 Widget 已经销毁,结果旧 Widget 还在监听 Gameplay 事件
  • 缓存了大量永远不会再次打开的页面,导致内存和资源一直被占用

缓存是选择,不是默认行为。 只有创建成本高、打开频率高的 Widget 才值得长期复用。


四、Binding 很方便,但可能每帧运行#

在 UMG 里给 Text 绑定一个函数很容易:

FText UCoopHUDWidget::GetObjectiveText() const {
return FText::AsNumber(SessionState->GetObjectiveProgress());
}

问题在于很多绑定系统会在 Tick 或 Prepass 期间反复求值。一个绑定本身很便宜,几百个绑定叠在一起就会变成持续 CPU 开销,特别是函数里又查找 Actor、格式化字符串或创建临时对象时。

更稳的方式是状态变化时主动刷新

void UCoopHUDWidget::OnObjectiveProgressChanged(int32 NewProgress) {
ObjectiveProgressText->SetText(FText::AsNumber(NewProgress));
UpdateAbilityButtonState();
}
错误:每帧询问“当前任务进度是多少?”
→ GetWorld → 找 SessionState → 转 FText → 设置文本
更好:任务进度变化时通知一次
→ OnRep / Gameplay Message
→ 更新对应文本

这也是前面复制文章里“状态和表现分离”的延伸:网络状态变化才驱动 UI 更新,UI 不需要反过来轮询网络状态。


五、Tick、可见性和 Invalidation#

1 UI 不应该默认 Tick#

UCoopHUDWidget::UCoopHUDWidget(
const FObjectInitializer& ObjectInitializer)
: Super(ObjectInitializer) {
bCanEverTick = false;
}

如果倒计时只需要每秒变化一次,不要每帧 Tick:

void UCoopHUDWidget::StartCountdown() {
GetWorld()->GetTimerManager().SetTimer(
CountdownTimer,
this,
&UCoopHUDWidget::RefreshCountdown,
0.1f,
true);
}

实际项目中也可以由服务器同步结束时间,客户端根据本地时间计算显示值;关键是不要让每一个小 Widget 都拥有自己的 Tick。

2 Visibility 有不同含义#

Visibility绘制布局命中测试
Visible
HitTestInvisible
SelfHitTestInvisible自身否、子项可
Collapsed
Hidden通常仍参与布局

想彻底移除一个面板的布局成本,通常使用 Collapsed,而不是把它设成透明或 Hidden。透明只是不画,Widget 仍可能参与布局和遍历。

3 Invalidation Panel#

Slate 为 UI 提供了缓存布局/绘制结果的机制,UMG 中常见入口是 InvalidationBox。一个静态面板可以在没有变化时复用缓存:

静态背景、装饰、标题
→ 缓存
倒计时、任务进度、技能冷却条
→ 单独放在会变化的区域

如果把整棵 HUD 放进一个 Invalidation Panel,却每帧修改其中一个文本,整个缓存可能频繁失效,收益会被抵消。把稳定的 UI 和高频变化的 UI 分区,比盲目添加缓存控件更重要。


六、数据层和 UI 层不要互相拥有#

一个常见的坏结构是:

// Widget 直接操作 GameMode 和服务器逻辑
void UAbilityWidget::OnAbilityClicked() {
GetWorld()->GetAuthGameMode()->UseAbility(AbilityTag);
}

客户端没有自己的权威 GameMode,这段代码在多人环境里本身就不成立。更合理的边界是:

按钮点击
→ PlayerController / 本地 ViewModel 发出请求
→ Server RPC
→ 服务器修改 RoundState
→ Replicated 属性或消息到达客户端
→ Widget 只负责表现
void UAbilityWidget::OnAbilityClicked() {
if (ACoopPlayerController* PC = GetOwningPlayer<ACoopPlayerController>()) {
PC->RequestAbility(SelectedAbility);
}
}

UI 需要知道的是“按钮是否可用、显示什么文本、是否正在等待服务器”,而不是自己决定“这次技能是否合法”。


七、MVVM:把绑定关系显式化#

当 UI 数量增长后,Widget 既查 Gameplay 对象、又格式化数据、又监听网络事件,会越来越像一个新的 God Class。MVVM 的思路是加一个中间层:

Model:RoundState / PlayerState / Inventory
↓ 状态转换
ViewModel:ObjectiveProgressText、CanUseAbility、RemainingTime
↓ 单向绑定
View:TextBlock、Button、ProgressBar

ViewModel 不应该拥有服务器权威数据,它只是把 Gameplay 状态转换成 UI 容易消费的形式:

UCLASS()
class UCoopViewModel : public UObject {
GENERATED_BODY()
public:
UPROPERTY(BlueprintReadOnly)
FText ObjectiveProgressText;
UPROPERTY(BlueprintReadOnly)
bool bCanUseAbility = false;
void SetSessionState(const ACoopSessionState* InState);
};

如果项目没有启用完整的 MVVM 插件,也可以先用普通 UObject ViewModel + 显式刷新函数实现同样的边界。重要的是不要让 View 反向修改 Model


八、合作副本 HUD 的拆分练习#

CoopHUD
├── UCoopObjectiveWidget // 目标、倒计时、公开进度
├── UCoopAbilityWidget // 选择技能、发送请求、等待反馈
├── UCoopQuestWidget // 只给本地玩家的任务提示
└── UCoopResultWidget // 副本结束结果

每个子 Widget 只订阅自己关心的消息:

  • ObjectiveWidget 订阅副本状态和公开进度变化
  • AbilityWidget 订阅本地法力、技能冷却和技能请求结果
  • QuestWidget 只处理 OwnerOnly 数据
  • ResultWidget 订阅副本结束事件,并从 Replicated 状态读取结果

这样做的结果是:增加一个“任务历史”面板,不需要修改整个 HUD;换成手柄输入,也不需要在 UI 里重写网络逻辑。


九、总结#

概念一句话
SlateUE 底层的 UI 布局、输入和绘制框架
UMG面向 Gameplay 的 UObject/蓝图 UI 封装
Widget TreeUI 的层级结构,影响布局、绘制和遍历成本
Binding方便但可能高频求值;重要 UI 更适合事件驱动刷新
Invalidation缓存不变的 UI 结果,减少重复布局和绘制
MVVM把 Gameplay 状态转换成 UI 状态,避免 Widget 变成 God Class

高性能 UI 的核心不是少放几个控件,而是让变化只影响必要的区域。 UI 只表现状态,网络和 Gameplay 决定状态,三者边界清楚以后,性能和可维护性通常会一起变好。

UE UI 系统——从 Slate、UMG 到高性能 Widget
https://www.m4doka.xyz/posts/ue/ue-10-ui-system/
作者
m4doka
发布于
2026-08-20
许可协议
CC BY-NC-SA 4.0