UE5.8 UMG MVVM 模式与 ViewModel:从一团乱麻的背包面板说起

📑 目录

UE5.8 UMG MVVM 模式与 ViewModel:从一团乱麻的背包面板说起

引子:背包面板把周末毁了

小周那周五本来打算准时下班。项目里新的背包和角色面板已经拖了整整两周,策划在 Jira 上留了十七张截图,每张都标红。核心问题很具体:打开背包后,角色面板里的攻击力和防御力不更新;把武器从背包拖到装备槽,武器图标消失了,但属性没变;网络对战时客户端 A 看见自己换上了新护甲,客户端 B 看他还是旧外观。更糟的是,这些现象没有固定复现步骤,时好时坏。

他到周日晚上才定位到根因。UMG Widget 里直接引用了背包组件和角色属性组件,十几个 Widget 各自拉线读取数据,装备变化时靠自定义事件广播,事件链像意大利面条。某次装备替换没有触发广播,某个 Widget 在 Construct 里读到的指针已经失效,某条网络复制路径比 UI 刷新晚了一帧,这些都可能导致界面显示和实际数据对不上。小周在群里发了句:「我们得上 MVVM。」

他不是没试过别的办法。之前他把所有数据更新都集中到 PlayerState 的一个事件总线,Widget 统一订阅。结果总线消息越来越杂,一个装备变化要广播十七个字段,订阅者里还混进了本不该关心的界面,排错时根本找不到消息来源。集中式事件总线把耦合从 Widget 之间搬到了总线本身,本质上还是换了一种乱麻。

MVVM 不是 UE 特有的概念,但 UE5.8 把它正式接进了 UMG。这篇文章记录小周把背包和角色面板由直接引用改为使用 UMG MVVM 的过程,按模块拆开讲。

MVVM 到底是什么

MVVM 把 UI 拆成三块:Model 管数据,View 管显示,ViewModel 夹在中间做转换和同步。玩家背包里有一把剑,这把剑的数据存在 GameInstance 或角色组件里,这是 Model。屏幕上显示剑的图标、名字、攻击力加成,这是 View。ViewModel 负责把 Model 里的原始数据变成 View 能直接绑定的属性,比如把 int32 DamageValue = 25 转成 FText DamageText = '攻击 +25',并在数值变化时通知 View。

和直接在 Widget 里读取角色属性相比,MVVM 的核心差别是:Widget 不再知道数据存在哪,它只知道 ViewModel 暴露了哪些可绑定字段。这样即使底层数据从角色组件迁移到 GAS Attribute Set,或者从本地存档改成服务器同步,View 层几乎不用改。把显示逻辑和数据逻辑分开,还能让策划和测试更容易介入:策划调显示格式不需要动 Model,测试造数据也不需要打开游戏跑完整流程。

下面这张图展示了三者的基本关系。

flowchart TD
    subgraph data ["数据层"]
        model["Model 背包/属性数据"]
        gas["GAS Attribute Set"]
    end
    subgraph vm ["ViewModel 层"]
        invvm["Inventory ViewModel"]
        charvm["Character ViewModel"]
    end
    subgraph view ["视图层"]
        bagwb["背包 Widget"]
        statwb["角色面板 Widget"]
    end
    model --> invvm
    gas --> charvm
    invvm --> bagwb
    charvm --> statwb

Model 只向上提供事件或回调,ViewModel 订阅这些变化并更新自己的暴露字段,View 再通过绑定自动刷新。这个方向感很重要,反过来看:View 不直接写 Model,所有用户操作都先交给 ViewModel,由 ViewModel 决定调用哪个 Model 接口。

Model-View-ViewModel 的边界

实际写代码时,最容易混淆的是 Model 和 ViewModel 的边界。小周最初的方案是把背包组件直接塞进一个 ViewModel 子类,里面同时包含数据存储和 UI 格式化逻辑。结果 ViewModel 越写越大,最后还是回到了老路的混乱。

比较清晰的分法是:

  • Model 只描述业务实体和规则。背包 Model 里存物品 ID、数量、耐久、是否绑定;角色 Model 里存等级、基础属性、装备槽位引用。Model 里不出现任何 UMG 类型。
  • ViewModel 面向 View 的显示需求。它保存当前选中物品、过滤后的物品列表、每个槽位对应的图标路径、格式化后的属性文本。ViewModel 可以引用 Model,但 Model 不能引用 ViewModel。
  • View 只负责布局和渲染。Widget 里不再有 Get Owning Player Pawn 然后 Cast 的逻辑,只有把 Text、Progress Bar、Image 绑定到 ViewModel 的字段。

这样迁移后,小周发现测试变得简单。他可以在不打开游戏的情况下,写一个 Mock ViewModel 给 View 喂假数据,验证背包在不同分辨率下的布局;也可以单独测 Model 的装备规则,不用担心 UI 状态干扰。

边界定下后,数据流动的方向可以用下面这张流程图表示。

flowchart TD
    A["玩家拾取物品"] --> B["Model 添加物品并触发事件"]
    B --> C["ViewModel 监听事件并更新字段"]
    C --> D{绑定类型}
    D -->|OneWay| E["View 自动刷新显示"]
    D -->|TwoWay| F["View 输入反向同步"]
    F --> G["ViewModel 调用 Model 接口"]
    G --> H["Model 校验并修改数据"]
    H --> I["事件再次驱动 ViewModel 更新"]

整个循环的起点和终点都在 Model。View 不会自己改数据,它只是把玩家操作翻译给 ViewModel。

创建一个 UMG ViewModel

UE5.8 里的 ViewModel 是一个继承自 UMVVMViewModelBase 的类。这个基类提供了属性变更通知、字段暴露和蓝图/C++ 互操作的支持。创建方式有两种:在 Content Browser 里右键选 Blueprint Class,父类选 MVVM ViewModel Base;或者在 C++ 里继承 UMVVMViewModelBase,再派生蓝图子类。

小周给背包做了一个 UInventoryViewModel,给角色面板做了一个 UCharacterStatsViewModel。C++ 里声明可绑定字段需要用到 UFUNCTION 和自定义的 Getter/Setter,并在 Setter 里调用 UE_MVVM_SET_PROPERTY_VALUE 宏。这个宏会在值真正改变时触发 BroadcastFieldValueChanged

下面是一个简化版的背包 ViewModel 片段:

UCLASS(BlueprintType)
class MYGAME_API UInventoryViewModel : public UMVVMViewModelBase
{
    GENERATED_BODY()

public:
    UFUNCTION(BlueprintPure, FieldNotify)
    int32 GetSelectedSlotIndex() const { return SelectedSlotIndex; }

    UFUNCTION(BlueprintCallable)
    void SetSelectedSlotIndex(int32 NewIndex);

    UFUNCTION(BlueprintPure, FieldNotify)
    const TArray<FInventoryItemData>& GetFilteredItems() const { return FilteredItems; }

private:
    UPROPERTY(EditInstanceOnly, BlueprintReadWrite, FieldNotify, meta = (AllowPrivateAccess = true))
    int32 SelectedSlotIndex = 0;

    UPROPERTY(EditInstanceOnly, BlueprintReadWrite, FieldNotify, meta = (AllowPrivateAccess = true))
    TArray<FInventoryItemData> FilteredItems;
};

Setter 里需要写:

void UInventoryViewModel::SetSelectedSlotIndex(int32 NewIndex)
{
    UE_MVVM_SET_PROPERTY_VALUE(SelectedSlotIndex, NewIndex);
}

FieldNotify 标记是让 UMG 的绑定系统知道这个字段会主动推送变化。BlueprintPure 让蓝图可以读取,BlueprintCallable 让蓝图可以调用 Setter。FilteredItems 是一个数组,后面会讲到 ViewModel Collection 的处理。

创建完 ViewModel 类,还需要在 Widget 里声明 ViewModel 引用。打开 Widget Blueprint,在 Designer 面板右侧找到 View Models 标签,添加 UInventoryViewModel 并命名。命名之后,Widget 内的任意控件都可以绑定到这个实例的字段。

数据绑定:OneWay 与 TwoWay

UMG MVVM 的绑定类型分为 OneWay 和 TwoWay。OneWay 是 ViewModel 到 View 的单向同步,适合绝大多数显示场景:血量、攻击力、物品数量、冷却时间。TwoWay 是双向同步,View 的输入也会写回 ViewModel,常用于 Slider、Editable Text、Check Box、Combo Box 这些可交互控件。

小周的角色面板里有一个负重上限滑块,玩家可以拖动调整自动拾取阈值。这个滑块用 TwoWay 绑定到 ViewModel 的 AutoLootWeightThreshold 字段,拖动时 ViewModel 收到新值,再写回本地设置 Model。如果只绑定 OneWay,玩家拖动滑块后数值会被 ViewModel 下一次刷新覆盖,界面会弹回旧值。

下面这张序列图展示了 TwoWay 滑块的完整交互。

sequenceDiagram
    participant Player as "玩家"
    participant Slider as "负重滑块"
    participant VM as CharacterStatsViewModel
    participant Model as "设置 Model"

    Player->>Slider: 拖动到 80
    Slider->>VM: TwoWay 写入 AutoLootWeightThreshold
    VM->>Model: SetThreshold(80)
    Model->>Model: 校验并保存
    Model->>VM: OnThresholdChanged
    VM->>Slider: OneWay 刷新显示 80

OneWay 绑定在 Widget 的 Details 面板里选择字段,Bind 到 ViewModel 的 Getter。TwoWay 绑定则需要字段同时有 Getter 和 Setter,并在绑定配置里勾选 TwoWay。UMG 在内部维护一个 FMVVMViewModelFieldVariant,当 ViewModel 调用 BroadcastFieldValueChanged 时,Slate 会把新值推送到所有订阅者。

数据绑定不是免费的。假设背包界面有 NN 个物品槽,每个槽有图标、名字、数量三个 OneWay 绑定,那么一次完整刷新会产生 3N3N 次属性通知。当 N=120N=120 时,就是 360360 次通知。背包通常不会全部可见,配合 List View 或 Tile View 的虚拟化,实际同步数量会降到可见项数 MNM \ll N。总通知数可以从:

Ctotal=3NC_{total} = 3N

降到:

Cvisible=3MC_{visible} = 3M

这个差距在打开大背包时非常明显。小周第一次测试时把所有格子都做成独立 Widget,打开背包的瞬间 Slate 耗时从 0.6 毫秒涨到 4.5 毫秒;改用 UMVVMViewModelCollectionObject 配合 List View 后,稳定在 1.0 毫秒以下。

绑定建立的时机与初始值

Widget 里的绑定不是在 Construct 时立刻生效,而是在 Native Construct 之后、第一次 Tick 之前由 MVVM 框架统一建立。如果 ViewModel 在 Widget 创建后才赋值,绑定会晚一帧建立。对于需要立即显示的数据,可以在 Widget 的 ViewModel 设置回调里手动调用一次刷新,或者让 ViewModel 在初始化时就持有默认值。

绑定路径的写法也需要注意。UMG 的绑定路径用点号分隔,例如 InventoryViewModel.SelectedSlotIndex。如果路径中间某个字段为空,绑定会静默失败,Text 显示为空而不是报错。小周遇到过 Collection 切换时 Entry Widget 的 ViewModel 还没赋值,导致图标全部空白。解决办法是在 NativeOnListItemObjectSet 里先判断传入对象是否有效,再调用 SetViewModel

ViewModel Collection

背包和商店界面通常要展示一组同类数据。给每个物品单独做一个 ViewModel 字段不现实,UMG MVVM 提供了 Collection 机制:FMVVMViewModelCollectionUMVVMViewModelCollectionObject

Collection 里存放的是同类型 ViewModel 实例。每个列表项对应一个 UItemSlotViewModel,Collection 负责跟踪增删改,View 层用 List View 或 Tile View 显示。Collection 内部维护了一个数组和一组变更事件,新增一项时只触发新增事件,不需要整个数组重新绑定。

小周的背包里有普通物品、任务物品、材料三类。他先按分类做了三个 Collection,再用一个顶层 UInventoryViewModel 暴露它们。Widget 里放三个 Tab,切换时只改变当前显示的 Collection,数据源不重建。

下面这段伪代码展示了 Collection 的基本用法:

void UInventoryViewModel::RefreshItems(const TArray<FInventoryItemData>& Items)
{
    ItemCollection->Reset();

    for (const FInventoryItemData& Item : Items)
    {
        UItemSlotViewModel* SlotVM = NewObject<UItemSlotViewModel>(this);
        SlotVM->Initialize(Item);
        ItemCollection->AddViewModel(SlotVM);
    }

    BroadcastFieldValueChanged(ThisClass::GetItemCollectionProperty());
}

Reset 会清空集合并通知 View 全部移除,AddViewModel 逐个加入。实际项目中,更好的做法是比较前后差异,只增删变化的项,这样 Collection 的动画和焦点状态不会被打断。UE5.8 的 FMVVMViewModelCollection 已经提供了基于索引的增删接口,Diff 逻辑需要自己按项目需求实现。

Collection 和 List View 的 Entry Widget 绑定时,List View 的 UserWidget Entry 里声明一个 UItemSlotViewModel 类型的 ViewModel。在 NativeOnListItemObjectSet 里把传入的对象转成 ViewModel 并赋值给 Entry 的 ViewModel 字段,后续显示就自动走 MVVM 绑定。

生命周期管理

ViewModel 的生命周期比 Widget 长,也比 Widget 短,取决于它归谁管。局部 ViewModel 通常由某个 Widget 创建,Widget 销毁时一起释放;全局 ViewModel 由 GameInstance、PlayerState 或某个 Subsystem 持有,跨界面存活。

小周最早把背包 ViewModel 放在 Widget 的 View Models 列表里由 UMG 自动创建。打开背包时实例化,关闭背包时销毁,这种方式最简单,也最容易控制内存。问题出现在角色面板上:玩家可能同时打开背包和角色面板,两个面板都需要装备数据。如果各管各的,就会出现两个 ViewModel 监听同一个 Model,一个更新不及时,另一个已经刷新,界面之间再次不一致。

他后来的方案是:角色属性相关 ViewModel 由 PlayerState 持有,背包 ViewModel 由背包 Widget 持有,但两者共享同一个装备 Model 引用。Model 变化时同时通知两个 ViewModel,它们各自更新自己暴露的字段。这样角色面板和背包看到的永远是同一份装备状态。

下面这张图展示了 ViewModel 的典型生命周期。

graph LR
    A["Player 打开背包"] --> B["Widget 创建局部 InventoryViewModel"]
    B --> C["ViewModel 监听 Model 事件"]
    C --> D["Widget Add To Viewport"]
    D --> E["玩家操作触发 TwoWay 绑定"]
    E --> F["ViewModel 调用 Model"]
    F --> G["Model 变更通知 ViewModel"]
    G --> H["View 自动刷新"]
    H --> I["玩家关闭背包"]
    I --> J["Widget Remove From Parent"]
    J --> K["ViewModel 解除监听并等待 GC"]

关键点是解绑时机。ViewModel 订阅 Model 事件时,要在析构或显式清理函数里移除委托。如果 Model 是全局单例,ViewModel 被 GC 后委托还在,下一次事件触发会访问野指针。UE5.8 的 UMVVMViewModelBase 析构时会自动清理一些内部绑定,但对 Model 的自定义委托仍需要手动处理。

小周在 UInventoryViewModel 里加了 Shutdown 函数:

void UInventoryViewModel::Shutdown()
{
    if (InventoryModel)
    {
        InventoryModel->OnItemChanged.RemoveDynamic(this, &UInventoryViewModel::HandleItemChanged);
        InventoryModel = nullptr;
    }
}

Widget 的 NativeDestruct 里调用 Shutdown,保证成对释放。

全局 ViewModel 与局部 ViewModel

全局 ViewModel 适合跨界面、跨关卡都需要的数据。HUD 上的玩家血量、任务追踪、聊天消息、好友状态,这些通常放在全局 ViewModel 里,由 GameInstance 或某个自定义 Subsystem 创建,Widget 打开时直接引用。局部 ViewModel 适合一次性界面,比如背包、设置面板、商店弹窗,数据和界面同生同灭。

UE5.8 提供了 MVVM Game Subsystem 的扩展思路,可以在 UGameInstanceSubsystem 里创建全局 ViewModel。Widget 通过 GetGameInstance()->GetSubsystem<UMyMVVMSubsystem>()->GetGlobalViewModel() 拿到实例,再在 Designer 的 View Models 里绑定。这样多个 Widget 可以共享同一个 ViewModel 实例,避免重复监听和状态拷贝。

小周的全局 ViewModel 只放两类数据:一类是玩家基础信息,如等级、名字、金币;一类是游戏全局状态,如当前网络延迟、匹配状态。他刻意不把背包数据放进去,因为背包只在特定界面出现,全局持有会一直占内存,还容易在关闭界面后残留旧数据。

局部 ViewModel 的创建可以通过 Widget 的 View Models 面板自动完成,也可以在 C++ 里手动 NewObject<UMyViewModel>(this) 后调用 SetViewModel。手动创建的好处是可以在构造 ViewModel 时传入 Model 指针,而自动创建默认走无参构造,需要后续再初始化。

性能与调试

MVVM 解决了代码组织问题,但本身也引入开销。最主要的开销来自三方面:属性通知数量、Collection 更新粒度、ViewModel 查找与绑定建立时间。

属性通知过多的典型场景是批量更新。小周有一次在服务器同步了 120 件物品后,本地 Model 逐个修改并触发事件,导致 ViewModel 收到 120 次通知,每次通知又触发 View 刷新,打开背包时卡了半秒。解决办法是在批量修改前关闭通知,修改完成后再统一广播。可以在 Model 里加一个 BeginBatchUpdate / EndBatchUpdate

void UInventoryModel::EndBatchUpdate()
{
    bBatching = false;
    OnBatchUpdated.Broadcast();
}

ViewModel 监听 OnBatchUpdated 而不是每一个单条事件。

Collection 更新粒度过粗也会导致问题。Reset 整个集合并重建,会让 List View 丢失滚动位置和选中状态。尽量用增量更新,只改变化的那几项。如果数据顺序变化,可以用 MoveViewModel 接口调整位置。

调试 MVVM 时,UE5.8 的 Widget Reflector 里可以看到每个控件的绑定关系和当前 ViewModel 字段值。打开 Widget Reflector,选中某个 Text,Details 面板会显示它绑定的 ViewModel、字段名、当前值和绑定方向。这比在蓝图里顺着 Bind 节点找来源快很多。

控制台命令 mvvm.PrintBindings 可以打印当前 View 的所有绑定。小周常用它来检查某个字段是否被重复绑定,或者 TwoWay 绑定是否方向正确。

性能监控方面,可以用 Stat Slate 看 UI 渲染耗时,用 Stat MVVM 看绑定通知和 ViewModel 查找耗时。UI 帧时间预算通常按 60 FPS 算,留给 UI 的时间大约 1 到 2 毫秒。假设一帧总时间是 TframeT_{frame},UI 占比为 ruir_{ui},则:

Tui=Tframe×ruiT_{ui} = T_{frame} \times r_{ui}

Tframe=16.67T_{frame} = 16.67 毫秒、rui=0.1r_{ui} = 0.1 时,Tui1.67T_{ui} \approx 1.67 毫秒。如果 MVVM 通知和 Slate 重绘把这个时间推到 3 毫秒以上,复杂场景下就会掉帧。

内存与绑定清理

除了帧时间,内存也要盯。每个 TwoWay 绑定都会在 View 和 ViewModel 之间建立一条 Slate 委托链路。界面打开得越多,链路就越多。如果关闭界面时只把 Widget 设成 Hidden,而不 Remove From Parent,委托不会被释放,ViewModel 也可能因为被 Slate 引用而无法 GC。小周在设置面板里踩过这个坑:面板关闭后仍占用十几兆内存,且每次重新打开都新增一份绑定。后来改成真正的创建/销毁流程,内存曲线立刻平稳。

清理绑定的另一个要点是不要对同一个字段反复 Bind 和 Unbind。某些动态生成的 Widget 如果每帧都重新设置 ViewModel,会不断创建新的委托实例,旧委托不会自动去重。可以在设置新 ViewModel 前先把旧的设为 nullptr,或者保证一个 Widget 生命周期内只赋值一次。

结尾

小周把背包和角色面板迁到 MVVM 后,周末加班的次数明显减少。界面和数据之间的引用关系被拆开了,新增装备类型时不需要改十几个 Widget,调整显示格式时也不会触发网络同步 bug。角色面板和背包同时打开,两边看到的属性始终一致;网络对战时装备变化由 Model 统一广播,客户端之间的差异也消失了。

MVVM 并不能替代好的代码习惯。边界不清、通知滥用、生命周期忘记解绑,都会让好处打折扣。但对需要频繁更新、多个界面共享数据的 UI 系统来说,UE5.8 的 UMG MVVM 提供了一条清晰的组织路径。小周那个周末踩的坑,希望能让你少踩几个。