导语:「远港 Online」立项第三个月,UI 组做过一次盘点:规划中的界面 47 个,背包 200 格,团队框架要同时挂 40 个人的血条和 Buff 图标,拍卖行一个窗口 6 个分页。而卷一卷二的「回响峡谷」,全部 UI 只有 6 个界面。复杂度差了近一个数量级,UI 又是玩家唯一摸得到的系统——架构选错,后期的麻烦是优化?不,是整组重写。我经手过一次这样的重写,三个月,两个客户端全职。本章把这层地基一次打对。
本章地图
mindmap
root((UI/UX 工程))
UMG 的本质
retained 模式
Bind 轮询的代价
UX 补课
信息层级
Fitts 定律
CommonUI
输入路由栈
模态与挂起
手柄导航
MVVM
单向数据流
FieldNotify
直绑游戏对象=灾难
背包组件化
服务器确认完成回路
组件接口拆分
性能
无效化面板
200 格重绘风暴单机那套 UI 写法,在 MMO 里活不过三个月
先把两本书的账本摆在一起。
| 回响峡谷(单机) | 远港 Online(MMO) | |
|---|---|---|
| 界面总数 | 6 | 47 |
| 同屏控件峰值 | 约 50 | 约 1500(背包+团队框架+聊天同开) |
| 数据来源 | 本地对象,随取随用 | 服务器复制,离散到达 |
| 输入设备 | 键鼠 | 键鼠、手柄混合,随时切换 |
| 并发界面 | 暂停菜单独占 | 背包+聊天+地图同时活着 |
| 写错的代价 | 重开存档 | 玩家流失,全组返工 |
这张表决定了后面所有讨论的前提:MMO 的 UI 是一个常驻的、高并发的小型应用——"几个界面"这种说法连热身都算不上。它要和游戏世界抢帧时间,要和网络层抢数据时序,还要在键鼠和手柄之间随时切换交互模型。
UMG(Unreal Motion Graphics)是这个世界的地基,而 UMG 只是 Slate 的可视化封装。Slate 是 retained mode(保留模式)UI:控件树创建一次就驻留在内存里,引擎每帧遍历这棵树,做布局(Prepass,计算每个控件的 DesiredSize)、排列(Arrange)、绘制(Paint)。作为对照,Dear ImGui 这类 immediate mode 是每帧用代码把界面重新描述一遍,状态由调用方自己保管。
retained 模式的好处是状态天然保留、动画和样式好做,这也是它成为 3A UI 主流的原因。代价是成本隐蔽:一个界面"什么都不变",控件树的遍历和绘制照样每帧发生。不更新不等于没开销。单机时代控件树小,这笔账可以忽略;MMO 时代一棵树挂着上千个控件,每一笔都要算。
更要命的是数据绑定(Property Binding)。UMG 设计器里每个属性旁边的 Bind 下拉,写个函数或选个变量就能让控件"自动跟着数据走",听起来很美。它的真相是:引擎为每个绑定生成一个每帧轮询的 getter,穿过反射调用,拿到值再和旧值比较,变了才刷新控件。三个绑定无所谓,三百个绑定就是三百次每帧反射调用——而且无论数据变没变,轮询都跑。
我带过的项目里,UI 帧时间失控的案例,八成的根因都能在 Bind 面板上找到。规矩只有三条:
- 静态内容(标题、图标底框)一次性设置,此后不碰。
- 动态内容用事件驱动:数据变了,主动推给 UI。
- Bind 轮询只允许出现在原型阶段,进主干前必须清掉。
三种策略的取舍画出来更直观:
quadrantChart
title "UI 数据策略取舍(原型区陷阱最深)"
x-axis "运行时开销低" --> "运行时开销高"
y-axis "维护成本低" --> "维护成本高"
quadrant-1 "烧钱又难维护"
quadrant-2 "维护贵但可控"
quadrant-3 "双低"
quadrant-4 "原型陷阱区"
"Bind 轮询": [0.85, 0.25]
"事件驱动委托": [0.25, 0.5]
"MVVM FieldNotify": [0.35, 0.65]Bind 轮询落在右下:写起来最省事,运行时最贵,是原型区最深的陷阱。MVVM 多写一层代码,维护成本略高,但它把"推数据"这件事做成了框架自带的机制——第四节细讲。
还没被说服的话,可以翻翻 MMO 界面的全家福,每一张脸背后都有一个工程坑。拍卖行:六个分页共用一个列表组件,分页切换时数据源的替换与搜索条件的保留,状态管理稍有不慎就是"切了分页丢了筛选"的祖传 bug。聊天框:多频道消息流、敏感词过滤、@提醒、物品链接的富文本解析,它是个伪装成 UI 的消息中间件。团队框架:40 个成员 ×(血条+护盾+8 个 Buff 位+驱散高亮),400 多个控件每帧都可能变化,是性能一节的常客。邮件、公会、好友、成就、声望面板,每一个都够单机游戏当主界面用。这些界面还要两两通信——背包里的物品右键要能发送到聊天链接,拍卖行的搜索结果要能比价到背包——界面间的耦合图谱本身就是一张网络。这就是为什么架构必须先于界面。
补课 · UX:信息层级与 Fitts 定律
补课:本节补交互设计基础,写给程序员。有 UX 背景的读者可以直接跳到 CommonUI 一节。
先立一个数字:战斗中,玩家每秒钟给 HUD 的"扫视预算"大约只有 2-3 次,每次 200-300ms。UI 设计的全部问题,就是把有限的信息塞进这点预算里。
信息层级的做法是给每条信息定级。层级 1 是"死了就输"的信息:自己的血量、关键资源、当前目标的血量。层级 2 是战斗中有用的信息:技能冷却、Buff、队友状态。层级 3 是低频信息:任务文本、背包、邮件、社交。层级决定位置,位置有铁律——离视线焦点越近,层级越高。
这就回答了"为什么血条躺在屏幕下方中央"。战斗时玩家的视线焦点是自己的角色,角色通常在屏幕中央偏下。血条放这里,余光就能读到,不用动眼球。把它放左上角(很多老游戏这么干),每次确认血量都是一次 300ms 的转头加扫视,团本里这 300ms 就是一次倒地。
第二条定律管的是"点目标"的成本。Fitts 定律(Fitts’s Law)给出指点动作时间的模型:
其中 MT 是移动耗时,D 是起点到目标的距离,W 是目标宽度,a、b 是设备相关常数(鼠标上 b 的经验值约 100-150ms/bit)。它说的事很朴素:目标越远越小,就越难点。算一笔账:鼠标从屏幕中央移到技能图标,D 取 400px;图标 32px 时难度 ID ≈ log2(13.5) ≈ 3.75 bit,图标放大到 48px 时 ID ≈ log2(9.3) ≈ 3.22 bit,差 0.53 bit,换算下来每次点击省 60-80ms。一场战斗点几十次技能,这就是生与死的差距。
从这条定律推出三条工程规矩:
- 高频目标必须大。技能图标做到 44-64px,别为了"精致"缩到 28px。
- 高频目标必须近。技能栏贴着鼠标常驻区(屏幕下方中央),拍卖行确认按钮贴着列表。
- 贴边的目标 W 视为无穷大。屏幕边缘是"免费"的命中区,所以主菜单按钮贴边、技能栏贴底边——鼠标甩过头也出不了界。
还有一条 MMO 特有的约束:玩家的左手焊死在键盘 1-9 技能键和 WASD 上,右手焊死在鼠标上。凡是需要"腾出一只手"的操作——拖拽整理背包、双击换装——都必须设计成低频操作,并且要能在移动中完成。没人会在跑尸的路上停下来开背包。
把这些层级落到「远港 Online」的 HUD 上,就是下面这张分区图:
分区是一份注意力预算的分配表,跟美术审美不在同一个维度讨论。后面每一节的工程决策,都建立在这张表上。
CommonUI 的核心问题:按键先给谁
设想一个场景:背包开着,玩家按下 B 键。这个 B 该干嘛?关闭背包?触发绑在 B 上的回城石?还是先在背包搜索框里打出一个字母"b"?
单机时代这个问题不存在——暂停菜单一开,游戏世界就停了,输入只有一个接收者。MMO 不行:背包开着,角色还在野外跑着,聊天框还半掩着,组队邀请的弹窗随时可能砸下来。任何一个按键到来,系统必须回答"先给谁,他不要再给谁"。这就是输入路由,也是 CommonUI 插件存在的意义。
CommonUI 出身 Epic 内部,在 Fortnite 上实战验证多年,5.x 起作为官方插件随引擎发布,Lyra 示例项目整套 UI 都建立在它上面。它的核心概念三件半:
- CommonActivatableWidget:可激活界面的基类。每个界面声明自己的激活(Activate)、挂起(Deactivate)行为,以及自己的输入配置。
- CommonActivatableWidgetStack:模态栈容器。界面一层压一层,只有栈顶接收输入;新界面压栈,旧的自动挂起;弹出,旧的恢复。背包压住 HUD,确认框压住背包,天然有序。
- RootLayout 分层:栈再往上,整个屏幕按用途分成几层——Game 层(HUD)、GameMenu 层(背包、技能书)、Menu 层(设置)、Modal 层(确认框、断线提示)。每层一个栈,高层永远优先。
- 输入配置表:每个界面配一张数据表,声明"激活时吃掉哪些输入、放行哪些输入",以及绑定动作(Bound Action,如"手柄按 Y 整理背包"),交给 CommonBoundActionBar 这类控件自动显示成底部提示条。
一次按键的完整旅程:
flowchart TD
A[硬件输入 键鼠/手柄] --> B[Enhanced Input 解析]
B --> C{CommonUI 输入路由}
C --> D[Modal 层栈顶
确认框/断线提示]
D -->|消费| D1[执行绑定动作
如 Enter=确认]
D -->|不消费| E[Menu/GameMenu 层栈顶
背包/技能书]
E -->|消费| E1[界面内导航与快捷键]
E -->|不消费| F[Game 层 HUD]
F -->|不消费| G[角色控制 移动/技能]界面的生命周期也跟着栈走,一共四种状态:
stateDiagram-v2
[*] --> 关闭
关闭 --> 激活中: Push(资产异步加载完成)
激活中 --> 挂起: 新界面压栈
挂起 --> 激活中: 上层弹出
激活中 --> 关闭: Pop
激活中 --> 输入挂起: 演出/过场
输入挂起 --> 激活中: 挂起令牌归零
挂起 --> 关闭: 强制清空栈这套机制顺带解决了两个 MMO 的顽固问题。
第一个是输入模式切换。UE 提供三种输入模式:GameOnly(输入全给游戏)、UIOnly(全给界面,角色定住)、GameAndUI(双方都能收到,按路由分)。FPS 开菜单用 UIOnly 没问题,MMO 用 UIOnly 是事故——玩家开着背包照样要躲技能、要跑位。「远港 Online」的规矩是:默认 GameAndUI,只有捏脸、键位设置这类真正独占注意力的界面才允许 UIOnly。切到 GameAndUI 时鼠标光标显示出来,切回 GameOnly 时隐藏,这套光标与焦点的联动 CommonUI 已经管了。
第二个是手柄与键鼠混用。主机版和用手柄的 PC 玩家,焦点导航是刚需:CommonUI 按控件的几何位置自动推导十字键/摇杆的焦点跳转,也允许在界面里指定自定义跳转链。切换设备时,绑定动作的提示图标自动换成对应平台的按键图——这件事在 MMO 里天天发生:玩家用手柄做日常,抢拍卖行时切回鼠标。还有一点容易被漏掉:手柄没有 hover,所有靠悬停提示(Tooltip)传达的信息,必须为焦点态补一条等价路径。
手柄党还有一个出现率极高的实战坑:焦点丢失。场景是背包列表刷新——卖掉一件物品,列表重建,玩家刚才聚焦的那个格子控件被销毁了,焦点无处安放,十字键按烂界面都没反应,只能开关背包重进。规矩是:任何会重建控件的列表,刷新前记录焦点所在的数据索引(第几号物品,不是哪个控件指针),重建完成后按索引恢复焦点;索引对应的物品没了,就落到最近的有效项上。控件是临时的,数据索引是稳定的,焦点必须绑在稳定的那一头——这和 React/Vue 里列表 key 的教训是同一回事。
输入挂起(Input Suspend)是最后一块砖:剧情演出、过场动画期间,临时挂起一切 UI 输入,但界面本身保持可见。实现上是令牌计数——申请一个挂起拿一个 token,归还时减一,归零才恢复,多个系统同时挂起互不干扰。Lyra 项目里的 UCommonUIExtensions 提供了对应的封装(SuspendInputForPlayer),5.8 中具体 API 以 Lyra 源码与官方文档 Common UI 词条为准。
MVVM:界面只能看 ViewModel,看游戏对象就是灾难
讲架构之前,先看一段每个 UE 项目早期都会出现的写法:背包格子控件直接 Bind 一个函数,函数里 GetOwningPlayerPawn 拿到角色,再 GetComponentByClass 拿到背包组件,读出第 i 格的物品图标返回。能跑,而且只写了五行。
这五行是灾难的种子,四条根因:
生命周期失控。 UI 持有游戏对象的引用,角色死亡、换图、断线重连,任何一个时刻引用都可能失效。于是代码里长出成片的双判空加 TWeakObjectPtr,或者更糟——UI 的引用反过来让该回收的对象回收不掉,GC(见第 2 章)被 UI 卡着脖子。
每帧轮询穿过反射。 Bind 的代价第一节算过账。游戏对象的每一点状态变化都被迫接受全量 UI 的逐帧盘问,而 MMO 里 99% 的帧,背包数据根本没变。
网络时序错位。 这是 MMO 独有的:背包数据靠复制从服务器推过来,到达是离散的、不定时的。UI 需要的是"变了告诉我",游戏对象提供的是"每帧来问我"。语义拧着,补丁就会越打越多。
没法测试,没法换皮。 UI 逻辑缠在游戏世界上,想脱离世界单独看一眼界面长什么样——做本地化截屏、做 UI 走查工具——都得先登录进游戏。
MVVM(Model-View-ViewModel)把数据流扳成单向:
classDiagram
direction LR
class Model {
<<游戏世界层>>
UInventoryComponent
APlayerState
+GetSlot(i) FItemStack
+OnSlotChanged: 委托
}
class ViewModel {
<>
UInventorySlotViewModel
+ItemName: FText
+StackCount: int32
+SetItemData(stack)
}
class View {
<<表现层 umg>>
WBP_InventorySlot
图标 / 数量 / 品质框
}
Model --> ViewModel : 订阅事件,拉取数据
ViewModel --> View : FieldNotify 单向通知
View ..> Model : 编译期就禁止的引用
note for View "View 不知道 Model 存在\nViewModel 不知道 View 存在\n箭头方向=数据流方向=唯一允许的方向"表现层> 游戏世界层>三层各管一件事。Model 是游戏世界:InventoryComponent、PlayerState,活在服务器权威的世界里,怎么复制怎么存盘它不管 UI。View 是 UMG 控件,只管把数据画出来、把点击转成请求发出去,里面不写一行游戏逻辑。ViewModel 夹在中间:订阅 Model 的事件,把世界数据翻译成"UI 视角的扁平数据"——图标、文本、颜色、冷却进度——View 要什么形状,它就提供什么形状。
箭头方向就是纪律:Model 不知道 ViewModel,ViewModel 不知道 View,View 绝对不知道 Model。点击事件走的是另一条路——View 发出请求("我要移动 3 号格到 7 号格"),直接交给游戏层处理,处理结果绕服务器一圈回来,再从 Model 流到 View。读和写是两条单向道,这是 MVVM 和"双向绑定"最本质的分歧。
UE 5.x 的官方实现是 MVVM 插件(插件名 UMGViewModel,5.8 中已是正式功能,官方文档词条:Unreal Engine → UMG UI Designer → MVVM)。它给你三样东西:ViewModel 基类 UMVVMViewModelBase;属性标记 FieldNotify——值变化时自动广播;以及 UMG 设计器里的 Viewmodel 绑定面板,把控件属性挂到 ViewModel 的 FieldNotify 属性上,引擎生成的是"变化才推送"的事件绑定——每帧轮询从此退场。
ViewModel 侧的最小实现长这样:
UCLASS(BlueprintType)
class UInventorySlotViewModel : public UMVVMViewModelBase
{
GENERATED_BODY()
public:
// 由游戏侧在收到 OnSlotChanged 后调用,是唯一的数据入口
void SetItemData(const FItemStack& InStack)
{
UE_MVVM_SET_PROPERTY_VALUE(ItemName, InStack.DisplayName);
UE_MVVM_SET_PROPERTY_VALUE(StackCount, InStack.Count);
UE_MVVM_SET_PROPERTY_VALUE(QualityColor, InStack.GetQualityColor());
UE_MVVM_SET_PROPERTY_VALUE(bUsable, InStack.Count > 0);
}
void Clear() { SetItemData(FItemStack::Empty); }
protected:
UPROPERTY(FieldNotify, BlueprintReadOnly)
FText ItemName;
UPROPERTY(FieldNotify, BlueprintReadOnly)
int32 StackCount = 0;
UPROPERTY(FieldNotify, BlueprintReadOnly)
FLinearColor QualityColor;
UPROPERTY(FieldNotify, BlueprintReadOnly)
bool bUsable = false;
};三个要点:UE_MVVM_SET_PROPERTY_VALUE 宏内部先比较新旧值,变了才写内存、才广播 FieldNotify,没变化的开销只有一次比较——这就是它比 Bind 轮询便宜的原因;ViewModel 是纯 UObject,不继承任何 UMG 类,编译依赖上它根本看不见 View;View 侧绑定在设计器里完成,格子控件拿到哪个 ViewModel 实例,由外层背包界面在创建时注入,也可以在 C++ 里用 NewObject 创建后交给绑定系统。
诚实的代价也要说:MVVM 每层数据都要多写一份映射代码,界面数量翻倍时这层代码量很可观。三个界面的小项目、两周要出的原型,直接事件驱动(数据变了发委托,UI 监听)就够了,不必上全套框架。判断标准是界面数量和数据来源数——「远港 Online」这种 47 个界面、数据全靠网络推送的项目,MVVM 就是生存问题。选择题?那是三个界面的小项目的特权。
还有一类数据是跨界面的:金币数量、玩家等级、背包容量水印,HUD 顶栏要显示,背包界面要显示,拍卖行结算时还要显示。这种数据别在每个界面各建一份 ViewModel——三份副本总会有一份忘记更新。MVVM 插件提供了全局 ViewModel 机制(MVVM Game Subsystem 管理的命名实例,编辑器里可配置),全项目唯一的 CurrencyViewModel 挂在那里,所有界面绑同一个实例,货币变化时四十几个界面一起刷新,数据源只有一处。这和前端状态管理里全局 store 的思路完全一致:共享状态集中,界面状态分散。
背包组件化:一次拾取要绕服务器一圈,格子才敢亮
单机背包的链路很短:拾取判定 → 数组加一个元素 → UI 刷新,全在同一帧内完成。MMO 里这条链必须绕服务器一圈,因为背包里的每一件物品都是服务器账本上的条目——客户端本地加一件装备,等于自己给自己印钞票。
「远港 Online」一次拾取的完整数据流:
sequenceDiagram
participant P as 玩家(按 E)
participant PC as 客户端 PlayerController
participant Srv as 服务器 InventoryComponent
participant Cli as 客户端 InventoryComponent
participant VM as InventoryViewModel
participant V as WBP 背包格子
P->>PC: 本地拾取检测(距离/视线)
PC->>Srv: ServerTryPickup(ItemID) RPC 请求
Srv->>Srv: 权威校验:距离、归属、背包容量
alt 校验通过
Srv->>Cli: 复制槽位变更(增量)
Cli->>Cli: 广播 OnSlotChanged 委托
Cli->>VM: 读取新槽位,翻译数据
VM->>V: FieldNotify 推送
V->>V: 刷新图标、数量、品质框
else 校验失败
Srv->>PC: ClientRejectPickup(原因)
PC->>V: 弹提示:背包已满
end注意这张图里客户端 UI 的角色:它从头到尾只做了两件事——发请求、等通知。它从来没有自己把物品加进格子。这就是服务器权威模型在 UI 层的投影:View 是账本副本的显示器,不是账本本身。复制的细节(增量怎么压、RepNotify 和 FastArray 怎么选)是第 21 章的主菜,这里只要记住一个结论:UI 刷新只认服务器复制回来的事实,RTT 100ms 的环境下,格子亮起比按键晚 100ms 左右是正常的物理现象。
100ms 的延迟在拖拽整理时会有体感,业内有两派做法。悲观 UI 等确认再动,实现简单,手感钝;乐观 UI 先在本地把格子换了,等服务器确认,失败就回滚弹回。拾取、使用、交易这类低频高价值操作,一律悲观——失败代价是装备丢失,谁也不敢乐观。拖拽整理这种高频低价值操作可以乐观,但必须把回滚路径写进第一天,否则服务端一次拒绝就会让客户端背包显示和真相永久错位。WOW 玩家偶尔遇到的"物品卡灰点不动",就是乐观路径回滚时的样子。
组件拆分上,背包是一个 Widget?不——它是四层:
# 背包组件接口(伪代码):服务器权威的唯一事实源
class InventoryComponent:
MAX_SLOTS = 200
slots: list[ItemStack] # 复制给 owning client 的账本
on_slot_changed = Event[int] # 客户端委托:ViewModel 订阅它
# ---- 以下三个入口全部在服务器执行,客户端只负责调用 ----
rpc_server request_move(src: int, dst: int) # 拖拽整理
rpc_server request_use(slot: int) # 右键使用
rpc_server request_drop(slot: int) # 丢弃
def try_pickup(self, item_id) -> bool:
slot = self.find_space(item_id)
if slot == -1:
rpc_client("reject_pickup", reason="full")
return False
self.slots[slot].add(item_id) # 改账本
mark_dirty(slot) # 触发增量复制,推回客户端
return True四层各自的职责:InventoryComponent 管账本与校验(服务器权威);InventoryViewModel 把账本翻译成 UI 数据(订阅 on_slot_changed);WBP_InventoryGrid 管 200 个格子的排布与滚动;WBP_InventorySlot 管单格的表现,拖拽用 UMG 原生的 DragDropOperation 实现,落点校验只做表现层的预判断(能不能放由服务器裁决)。每层只和相邻层说话,换皮时美术只动 WBP 层,改规则时策划只动 Component 层——这就是组件化值钱的时刻。
数据建模上还有一个岔路口要提前选:slot-based 还是 item-based。slot-based 的账本以格子为主键:"3 号格里有什么",拖拽就是交换两个格子的内容,WOW 背包是这个模型,UI 实现直白,格子索引直接当数组下标。item-based 以物品实例为主键:"这把剑在哪",格位只是物品的一个属性,暗黑破坏神式的占格背包必须走这条路。选型影响复制的最小单元、存档的结构、拖拽的协议,第 26 章持久化还会再见到它。「远港 Online」选 slot-based:200 个定长槽位,复制和回存的账都好算,代价是放弃了占格玩法——这个代价在立项时就要和策划谈清楚,做到一半换模型等于推倒背包重做。
性能:200 格背包全开,为什么帧率掉了 30
先给每帧 UI 成本建个账:
三项分别是:绑定轮询成本——N_bind 个 Bind × 每次穿过反射的轮询开销(量级 0.3-1μs);无效化重建成本——N_dirty 个本帧被标记"脏了"的控件 × 重新计算布局(DesiredSize)与重排的开销(每控件数 μs,且会传染父链);绘制成本——可见控件逐个生成绘制指令。背包 200 格,每格绑 5 个属性(图标、数量、品质框、冷却遮罩、可用态),就是 1000 次轮询,0.5-1ms 直接没了,而这只是第一项。
「远港 Online」真实发生过的案例。现象:打包版、1080p、中端 PC 上,关闭背包时帧率 120,打开背包瞬间掉到 85-90,stat unit 显示多出来的时间全在 GameThread 的 Slate 项上——开背包后 Slate 从 0.4ms 涨到 2.8ms。
排查用两件工具。stat slate 给总量;Slate Debugger(编辑器菜单 Tools → Debug → Slate Debugger)能把每帧被无效化的控件高亮成红框,开背包一看,整个格子区域每帧全红——200 格全部在重建。
根因挖出来三条,正好对应公式的三项:
| 根因 | 对应项 | 修法 |
|---|---|---|
| 每格 5 个 Bind 轮询 | 轮询 | 全改 FieldNotify,数据不变零开销 |
| 数量文本每帧重建,传染 ScrollBox 重排 200 格 | 重建 | 格子套 Invalidation Box,隔离无效化 |
| 200 格控件全部真实存在,滚出屏幕也参与布局 | 绘制 | 换 ListView,只实例化可视行 |
Invalidation Box(UInvalidationBox)是这个案例的主角:它把子树的渲染结果缓存起来,内容没变就直接用缓存,断了"一个控件脏、全树重排"的传染链。代价是占内存,且缓存内做频繁动画会适得其反——冷却扫光这种每帧必变的遮罩要放在 Invalidation Box 外面。ListView 虚拟化是第二刀:200 格只实例化约 40 个可视格控件,滚到哪建到哪。三刀下去,Slate 时间从 2.8ms 回到 0.6-0.7ms,开背包帧率回到 115 以上(以上为数量级,实测随机器浮动)。
这个案例真正值得背下来的,是排查顺序——三个修法只是排查这条路上的路标:先 stat 定位到 Slate,再 Slate Debugger 看无效化红区,最后对着三项公式分账。UI 性能问题九成都出在这三笔账里,区别只是哪笔爆了。
治本靠预算制度。16.6ms 的帧时间里,「远港 Online」给 UI 的配额是 1.5ms(GameThread Slate Tick 加 Slate Draw 合计),写进性能基线文档,和第 18 章的预算表同等待遇。CI 里挂一条自动化用例:机器人开满背包+团队框架+聊天,采 30 秒 stat slate 均值,超过 1.5ms 就报警。预算数字的价值在于它把"UI 变慢"从事后的玩家投诉,变成了提交当天的一条红字。MMO 的界面只会越加越多,没有预算闸门,1.5ms 涨到 3ms 不需要任何人的许可,只需要半年的沉默。
「远港 Online」实操:HUD 分层与背包落地
把前面所有零件装起来。RootLayout 分四层,各层准入名单:
| 层 | 住什么 | 输入模式 |
|---|---|---|
| Game | HUD:血条、技能栏、小地图、聊天 | GameOnly |
| GameMenu | 背包、技能书、任务、拍卖行 | GameAndUI |
| Menu | 设置、键位绑定、角色选择 | UIOnly(仅此层允许) |
| Modal | 确认框、断线重连、系统公告 | 挂起下层输入 |
界面资产不能全部塞进启动加载。47 个界面、几百张图标,启动时全加载等于让玩家盯着 logo 看半分钟。背包这类大界面用软引用加异步加载,开了才载:
void UHarborHUD::OpenInventory()
{
if (InventoryWidgetClass.IsNull()) return;
FStreamableManager& SM = UAssetManager::GetStreamableManager();
SM.RequestAsyncLoad(InventoryWidgetClass.ToSoftObjectPath(),
FStreamableDelegate::CreateLambda([this]()
{
TSubclassOf<UCommonActivatableWidget> Cls = InventoryWidgetClass.Get();
if (!Cls) return;
// 示意:Lyra 中对应 UCommonUIExtensions::PushStreamedContentToLayer_ForPlayer
RootLayout->PushWidgetToStack(GameMenuLayerTag, Cls);
}));
}InventoryWidgetClass 是 TSoftClassPtr,平时只占一个路径字符串;RequestAsyncLoad 完成后才实例化。Lyra 项目里这套"异步加载后压入指定层"的流程有现成封装,5.8 中以 Lyra 源码 UCommonUIExtensions 为准。第一次打开背包会有几十毫秒的加载延迟,预热策略是进战斗前或主城空闲时后台预载——拿内存换响应,这笔交易在 MMO 里几乎总是赚的。
HUD 侧另有两块硬骨头,做法直接从前面章节抄答案。团队框架就是 40 份"小号血条":每格一个 ViewModel,挂在全局 TeamViewModel 下按成员索引分发,控件层套 Invalidation Box——治疗职业的团战体验全看它能不能守住 1.5ms 预算。聊天框走另一条路:消息模型是追加只增的环形缓冲(固定 500 条上限),View 层用 ListView 虚拟化,新消息到达只脏一行。两个界面的架构没有一行新发明,这正是架构统一的回报:第二个界面开始,全是复制粘贴级别的工程量。
小结
MMO 的 UI 是一个常驻的高并发小应用,"几个界面"这种认知连门槛都够不着。复杂度翻十倍时先选架构再画界面:Bind 轮询只配活在原型里,事件驱动是起步价,MVVM 是 MMO 的终点。数据流只许单向:Model → ViewModel → View;View 看见游戏对象的那一刻,生命周期、网络时序、测试性三个债主就同时上了门。界面的每一次数据变化都要绕服务器一圈,UI 擅自改数据等于自己印钞票——低频高价值操作走悲观确认,高频低价值操作走乐观预测,但回滚路径必须第一天就写好。每帧 UI 成本就三笔账:轮询、无效化重建、绘制;200 格背包全开时笔笔都能爆,FieldNotify、Invalidation Box、ListView 虚拟化各管一笔。血条在屏幕下方中央、技能图标做到 48px——这两条写在信息层级和 Fitts 定律里,"审美"只是执行得好之后的外溢副产品。
上手任务
- FieldNotify 改造:搭一个 8×25 格的背包界面,用 MVVM 插件驱动所有格子。验收:打包版 stat slate 下,打开背包引起的 Slate Tick 增幅 < 0.3ms;Slate Debugger 中静置时格子区域不出现红色无效化高亮。
- 三层路由:用 CommonActivatableWidgetStack 实现 Game / GameMenu / Modal 三层,把背包和确认框分别压入 GameMenu 和 Modal。验收:背包打开时 WASD 移动依然生效;确认框弹出时背包快捷键 B 被屏蔽,关闭确认框后恢复。
- Fitts 实验:把技能栏图标从 32px 加到 48px 并下移到屏幕下缘贴边。验收:找一名没接触过本项目的同事,掐表完成"喝药 → 切技能 → 关背包"三连操作,比旧布局快 20% 以上,且主观报告"没怎么瞄准"。
下一章
背包那次刷新为什么要绕服务器一整圈?因为客户端本地的一切数据都可能是假的——延迟、丢包和作弊者各怀鬼胎。第 20 章把网络这层黑魔法拆开:RTT 的物理账本、状态同步为什么是 MMO 的唯一解,以及服务器权威模型到底权威在哪。
延伸阅读
- 官方文档词条:Unreal Engine → UMG UI Designer(UMG 基础与 Bind 机制);Unreal Engine → Common UI(输入路由与激活栈);Unreal Engine → UMG Viewmodel(MVVM 插件与 FieldNotify)
- 引擎源码:Engine/Plugins/Runtime/CommonUI(激活栈与输入路由实现);Engine/Plugins/Runtime/UMGViewModel(FieldNotify 广播机制);Engine/Source/Runtime/SlateCore(无效化与绘制管线)
- 示例项目:Lyra Starter Game 的 UCommonUIExtensions 与 PrimaryGameLayout——分层、异步压栈、输入挂起的完整参考实现
- GDC 演讲:Epic 关于 Fortnite UI 框架演进(CommonUI 前身)的分享,检索关键词 "Fortnite UI Unreal GDC";交互设计经典读物 Fitts 原始论文之外,可看《The Design of Everyday Things》的示能(Affordance)章节