UE5.8 UMG 复杂列表虚拟化:从背包爆仓到好友列表不卡顿

📑 目录

某天下班前,策划在群里丢了一张截图:背包打开后画面定格了将近两秒,帧率从 60 掉到 28。我打开工程看了下,背包界面直接用 VerticalBox 放了 300 个格子,每个格子又是一套 Icon、耐久条、堆叠数、品质框、Tip 触发器。打开背包的瞬间,引擎要创建近 1500 个 Slate 控件。

我拉了一下 stat 面板,创建阶段耗时 420 毫秒,其中大部分时间花在 Slate 控件构造和材质实例化上。主线程被阻塞,输入响应延迟,玩家看到的定格画面就是这段时间。策划说这个问题不解决,下次测试不敢开背包。

同一套问题也出现在好友列表。列表里有 200 位好友,每个人物条目带头像、等级、在线状态、最近上线时间。商店商品列表同样不乐观,80 件商品每件都带图标、价格、折扣标签。商店界面是玩家每次回城都会打开的,卡顿会直接打断购买节奏。好友列表更不用说,社交类 UI 一旦打开慢,玩家会反复点击,加重主线程负担。这些界面都是玩家高频打开的,卡顿直接变成体验事故。优化这类问题不能靠拆东墙补西墙,必须从列表控件本身入手。

UE5.8 自带的 ListView、TileView、TreeView 就是用来解决这类场景的。它们的核心机制是虚拟化:只实例化当前视口内看得见的条目,其余数据只保留在数组里。这篇文章从实际项目出发,介绍复杂列表虚拟化的完整做法。

三种列表控件该选谁

虚拟化不是某个控件的专利,UMG 里提供了三种原生列表控件。

ListView 适合纵向单列列表,条目从上到下排布。背包里的道具列表、好友列表、任务日志,都是它的典型场景。

TileView 把条目铺成网格,类似背包的大格子视图或者商店商品橱窗。它内部依然走虚拟化,只是排列方式从线性变成了行列。

TreeView 在 ListView 基础上增加了层级展开能力,适合技能树、文件浏览器、成就分类这类有父子关系的数据。

它们共享同一套 Entry Widget 与对象池逻辑,差别主要在于排布算法和交互语义。选对控件能少写很多自定义布局代码。

ListView 和 TileView 经常让人犹豫。背包是线性排列还是网格排列,直接影响玩家找东西的效率。如果道具数量多但分类少,TileView 更适合一眼扫过去;如果分类多、名称重要,ListView 更省纵向空间。TreeView 则不要乱用,层级超过两层时展开折叠的体验成本会明显高于直接平铺。

flowchart TD
    subgraph umg ["UMG"]
        listview["ListView / TileView / TreeView"]
        scrollbox["ScrollBox"]
        widgetpool["Widget Pool"]
        entrywidget["Entry Widget"]
    end
    subgraph data ["Data"]
        itemdata["UObject Data"]
    end

上图里 ListView 并不直接持有几百个 Widget。它只维护一个数据源数组和一个 Widget 对象池。ScrollBox 负责滚动,Widget Pool 负责复用少量 Entry Widget 实例。

Entry Widget 与 UserObjectListEntry

虚拟化能工作的前提是条目 Widget 必须能够根据数据动态更新外观。UMG 通过 IUserObjectListEntry 接口提供了一套标准回调。

在 C++ 里让 Entry Widget 继承这个接口:

UCLASS()
class MYGAME_API UInventoryEntryWidget : public UUserWidget, public IUserObjectListEntry
{
    GENERATED_BODY()

public:
    virtual void NativeOnListItemObjectSet(UObject* ListItemObject) override;
    virtual void NativeOnItemSelectionChanged(bool bIsSelected) override;
    virtual void NativeOnItemExpansionChanged(bool bIsExpanded) override;
    virtual void NativeOnEntryReleased() override;

    UPROPERTY(meta = (BindWidget))
    TObjectPtr<class UImage> IconImage;

    UPROPERTY(meta = (BindWidget))
    TObjectPtr<class UTextBlock> CountText;

    UPROPERTY(meta = (BindWidget))
    TObjectPtr<class UBorder> SelectionBorder;
};

实现里只做数据绑定和视觉反馈:

void UInventoryEntryWidget::NativeOnListItemObjectSet(UObject* ListItemObject)
{
    if (UInventoryItemData* Data = Cast<UInventoryItemData>(ListItemObject))
    {
        IconImage->SetBrushFromTexture(Data->Icon);
        CountText->SetText(FText::AsNumber(Data->StackCount));
        SetIsEnabled(Data->StackCount > 0);
    }
}

void UInventoryEntryWidget::NativeOnItemSelectionChanged(bool bIsSelected)
{
    SelectionBorder->SetVisibility(
        bIsSelected ? ESlateVisibility::Visible : ESlateVisibility::Collapsed);
}

NativeOnListItemObjectSet 是数据进来的入口。列表滚动时,同一个 Entry Widget 会被反复拉回对象池再拿出来,每次拿出来都会调这个函数。所以这里不能假设 Widget 是全新的,要把旧状态清干净。

NativeOnEntryReleased 在 Widget 被回收到池里时调用,适合取消未完成的异步加载或者清理代理绑定,避免滚动过程中注册监听器越来越多。

NativeOnItemExpansionChanged 在 TreeView 里才会触发,箭头展开或收起时调用。这里不要立刻加载子节点数据,因为用户可能快速连点。可以先让 UI 响应,再在动画结束或下一帧异步补齐数据。NativeOnListItemObjectSet 里还要注意 Slate 事件绑定,如果每次都重新绑定 Button OnClicked,旧的代理不清理,点一下会触发多次。

选择与事件

列表控件内置了几种选择模式。背包格子一般用单选,商店列表可能允许多选,任务日志可以关闭选择只响应点击。

在蓝图中,OnItemClickedOnItemHoveredOnItemSelected 这些事件可以直接使用。C++ 侧则建议覆写 NativeOnItemSelectionChanged 或者绑定 OnItemSelectionChanged 代理。

事件里最常见的坑是循环通知。代码里调用 SetSelectedItem 会触发选择变更事件,如果事件回调里又改数据源再调用 SetSelectedItem,就可能死循环。处理方式是把数据修改和视觉反馈分开:数据改完后统一调一次 RequestListRefresh,不要让选择事件直接驱动数据写入。

蓝图里设置 SelectionMode 为 Single 时,列表最多保留一个选中项,切换时会自动取消前一个。Multiple 模式需要配合 Ctrl 或 Shift,移动端还要考虑长按多选的手势冲突。如果只想要点击反馈而不需要选中状态,可以把 SelectionMode 设为 None,自己用 OnItemClicked 处理高亮。

另一个坑是悬停状态。条目滑出视口后 Widget 会被回收,悬停效果必须能在 NativeOnListItemObjectSet 里根据数据恢复,不能依赖上一次 hover 时设置的临时状态。

虚拟化原理

没有虚拟化时,ScrollBox 里真实存在 300 个 Widget。虚拟化后,ScrollBox 里只保留视口高度能容纳的条目再加少量缓冲。

假设视口高度是 HviewportH_{viewport},单个条目高度是 hentryh_{entry},额外缓冲行数为 NpaddingN_{padding},那么同时存在的 Widget 实例数大约是:

Npool=Hviewporthentry+NpaddingN_{pool} = \left\lceil \frac{H_{viewport}}{h_{entry}} \right\rceil + N_{padding}

对于一个 12 行可见、缓冲 4 行的背包,实际创建的格子 Widget 只有 16 个左右。内存占用和构造开销都从 O(N)O(N) 降到了 O(1)O(1)

flowchart TD
    A["玩家滚动列表"] --> B{顶部条目是否移出视口}
    B -->|"是"| C["将其 Widget 归还对象池"]
    B -->|"否"| D["计算新的可见索引范围"]
    C --> D
    D --> E["从对象池取出空闲 Widget"]
    E --> F["调用 NativeOnListItemObjectSet 绑定新数据"]
    F --> G["把 Widget 插入 ScrollBox 底部"]
    G --> A

这个循环的核心是对象池。对象池里的 Widget 不会被销毁,只是脱离可视区域等待复用。因为 Widget 创建和垃圾回收在 Slate 里都不便宜,复用能显著降低 GC 压力和卡顿。

缓冲行数 NpaddingN_{padding} 不能设太大。缓冲越多,滚动时越不容易看到空白,但内存占用和构造开销会同步上升。一般留出 1 到 4 行即可,超过这个范围收益递减。视口外的条目虽然不占 Widget 实例,但数据对象还在内存里,所以 UObject 数组本身的大小也要控制,必要时做分页或懒加载。

TileView 的算法类似,只是按二维网格计算可见行列。TreeView 还要额外维护展开状态,决定哪些子节点参与可见范围计算。

内存优化

虚拟化把实例数量压下来了,但每个 Entry Widget 本身的设计依然决定内存上限。一个常见的错误是把所有可能的状态都做进一个 Widget:空槽、有道具、可合成、有红点、选中、禁用,每个状态展开一堆子控件。结果一个 Entry Widget 就嵌了几十层,十几个对象池实例加在一起依然吃内存。

优化思路是拆细而不是堆大。可选元素用 Collapsed 隐藏比 Hidden 更好,因为 Collapsed 不参与布局计算。但即使是 Collapsed,子控件对象依然存在。对于不常用的状态,可以考虑动态创建,或者把它们放到 WidgetSwitcher 里按需切换。

素材复用也能省内存。品质框用 9 格切图,而不是每个品质各一张完整图。耐久条用 ProgressBar 加材质参数,避免每张道具单独做进度条样式。头像用 SoftObjectPath 异步加载,不要一次性把所有头像 Texture 都硬引用进内存。

数据对象本身也要控制体积。好友列表的条目数据如果直接引用玩家头像 Texture,200 条数据即使不显示也会把 200 张图钉在内存。更好的做法是在 Entry Widget 里根据 URL 或 Path 异步加载,数据对象只存标识。

字体也是容易被忽略的内存大户。每个 TextBlock 都会生成字形缓存,条目里字号种类越多,缓存越分散。把同层级的 TextBlock 统一字号和字体资源,能显著降低渲染开销。再者,少用动态材质实例做颜色变化,直接改 Image 的 ColorAndOpacity 更轻量。

图片异步加载要留好占位图。玩家快速滚动时,新条目从对象池拿出来先显示默认图标,等 SoftObjectPath 加载完成再替换。这样既不会阻塞滚动,也不会看到空白格子。

数据更新与刷新

虚拟化列表的数据源通常是一个 TArray<UObject*>。数据变更后,列表不会自动感知,需要主动通知。

最常用的是 RequestListRefresh。它会重新生成可见条目,适合数据发生结构性变化,比如排序、筛选、删除。排序和筛选看起来简单,对虚拟化列表却容易变成性能陷阱。每次排序都调用 SetListItems 会清空对象池并重新分配,数据量一大就会有明显掉帧。更好的做法是先改本地数组,再调一次 RequestListRefresh,让列表只重建可见部分。筛选时也是同样,不要把数组反复清空再压入。如果只是某一条目的某个属性变了,比如堆叠数从 99 变成 100,直接改数据对象再调 RequestListRefresh 有点重,因为所有可见条目都会被重建。

void UBackpackWidget::OnStackCountChanged(UInventoryItemData* ChangedItem)
{
    // 标记脏状态,下一帧刷新时只重新绑定可见条目
    ChangedItem->bDirty = true;
    InventoryListView->RequestListRefresh();
}

void UBackpackWidget::FocusOnItem(UInventoryItemData* Target)
{
    InventoryListView->SetSelectedItem(Target);
    InventoryListView->ScrollItemIntoView(Target);
}

更轻量的做法是在 Entry Widget 里监听数据对象。数据对象实现 INotifyFieldValueChanged 或者自定义委托,属性变化时通知当前绑定它的 Entry Widget 直接更新 Text 或 ProgressBar,不走完整刷新。这种方式在背包实时获得掉落物、好友在线状态切换时非常有用。UE5.8 的蓝图 VM 对属性变更的通知效率已经很高,但前提是只在真正变化时广播。如果每帧都触发,再好的虚拟化也救不了主线程。

sequenceDiagram
    participant Player
    participant BP as BackpackWidget
    participant LV as ListView
    participant Pool as WidgetPool
    participant EW as EntryWidget

    Player->>BP: 打开背包(300 格)
    BP->>LV: SetListItems(Items)
    LV->>Pool: 预分配可见数量 Widget
    Pool-->>LV: 返回 16 个 EntryWidget
    LV->>EW: NativeOnListItemObjectSet
    Player->>LV: 向下滚动
    LV->>Pool: 回收顶部不可见 Widget
    LV->>Pool: 请求底部新 Widget
    Pool-->>LV: 返回复用实例
    LV->>EW: NativeOnListItemObjectSet 绑定新数据

刷新频率也要控制。背包里道具每帧都变的场景并不多,不要把 RequestListRefresh 放在 Tick 里。可以累积一帧内的所有变更,在下一帧统一刷新一次。

复杂条目布局

真实项目里的列表条目往往不止一个图标加文字。商店商品条目可能同时包含商品图、名称、原价、折后价、限时标签、库存条、购买按钮。TreeView 的节点还要处理缩进和展开箭头。

布局复杂后,固定尺寸是最稳妥的选择。ListView 和 TileView 都依赖条目尺寸来做可见范围计算。如果每个条目高度不固定,ScrollBox 就需要先构造条目才能知道它多高,虚拟化的收益会被削弱。UE5.8 支持 VariableRowHeight,但建议只在确实需要时使用,并且提前做好性能测试。

SizeBox 在复杂条目里非常好用。它可以固定子控件的宽和高,避免内容变化时整体布局抖动。TileView 里尤其需要固定每个格子的尺寸,否则引擎需要多次测量才能决定网格列数。Button 的点击区域要和视觉区域对齐,防止玩家误触到隔壁条目。

设计条目时,层级要尽量扁平。一个 HorizontalBox 套一个 VerticalBox,再套若干 SizeBox,已经能满足大部分需求。过多嵌套会让 Slate 的无效化布局和重绘路径变长,滚动时掉帧。

TreeView 的缩进可以通过数据对象里的 IndentLevel 控制,也可以让 Entry Widget 根据节点深度动态调整 Padding。展开箭头建议用 Button 加 Image,状态变化时在 NativeOnItemExpansionChanged 里旋转图标。

如果同一种列表需要展示差异很大的两种条目,比如商城里既有普通商品又有 Banner 广告,直接用一个 Entry Widget 把所有元素都塞进去再按需显示会很臃肿。这时可以拆成两个列表,或者自定义 UListView::GetEntryWidgetClass 逻辑按数据类型返回不同类。UE5.8 允许覆写 GetEntryWidgetClassForItem,让不同类型条目使用不同 Widget 蓝图。不同 Widget 类的对象池是独立的。如果同列表混用普通商品和 Banner 两种类,池子里会分别缓存两类实例。设计时要估算最大可见数,避免两类同时大量存在时内存反超单一复杂 Widget。

性能对比

我们把背包界面从 VerticalBox 手动创建改成 ListView 虚拟化后,测了一组数据。

指标VerticalBox 手动创建ListView 虚拟化
打开背包耗时420 ms35 ms
同时存在 Widget 实例300 个16 个
打开时内存峰值38 MB6 MB
滚动平均帧率28 FPS60 FPS
垃圾回收压力
graph LR
    A["VerticalBox 手动 300 格"] -->|"打开耗时"| B["420 ms"]
    A -->|"Widget 实例"| C[300]
    A -->|"滚动帧率"| D["28 FPS"]
    E["ListView 虚拟化"] -->|"打开耗时"| F["35 ms"]
    E -->|"Widget 实例"| G[16]
    E -->|"滚动帧率"| H["60 FPS"]

好友列表和商店列表的优化结果类似。好友列表 200 人虚拟化后稳定在 12 个 Widget 实例;商店 80 件商品用 TileView 后同时存在的条目也控制在 20 个以内。

测试时建议打开 stat slatestat widgetcomponent 观察 Invalidation 和 Paint 次数。虚拟化列表滚动时,Paint 次数应该稳定在一个小范围内;如果随着滚动持续上涨,说明 Entry Widget 里有东西在持续触发重绘,需要回头检查事件绑定和动画。

另外一个常见现象是首屏加载后滚动到中段出现白块。这通常说明对象池预分配不足,或者 Entry Widget 的 DesiredSize 计算太慢。可以在 ListView 上适当增大缓冲行数,并检查条目层级里是否有在 Construct 时做大量初始化的地方。

这些数字会随条目复杂度变化,但数量级基本稳定。关键结论不是省了多少毫秒,而是列表规模从影响性能的主导因素变成了普通数据量。

一些实践建议

把复杂列表接入虚拟化并不难,但要让体验稳下来,有几个细节值得记住。

复杂列表的优化从来不是一次性把代码写完就结束。数据量、条目复杂度、交互频率都会随版本变化。每次新增功能前先在真机上测一遍列表滚动帧率,能避免问题拖到上线前才暴露。

数据对象和显示对象要彻底解耦。数据对象里不要直接引用 Texture、Mesh 这类重资源。Entry Widget 拿到数据后再决定怎么加载和显示。

条目 Widget 的层级能浅就浅。Border、Overlay、CanvasPanel 这些容器面板按需使用,不要为了视觉方便无限嵌套。

事件绑定要配对。NativeOnListItemObjectSet 里注册的委托,在 NativeOnEntryReleased 或者下一次数据绑定前清理掉,避免滚动越多次监听越多的内存泄漏。

刷新粒度要匹配变更范围。属性变化用局部更新,结构变化才调 RequestListRefresh,不要把刷新当万能钥匙。

上线前最好准备一套自动化测试:生成 500 条随机数据,反复打开、滚动、筛选、选中 100 次,记录平均帧率和峰值内存。这个测试会帮你在后续版本里快速发现性能回归。

最后,TileView 和 TreeView 的虚拟化逻辑与 ListView 同源,掌握了一套就能迁移到其他两种列表。把背包、好友、商店这些高频界面按这套思路重做一遍,玩家打开 UI 时的停顿基本能消失。这套方案也成了我们项目后续所有列表界面的默认起点。