这次我们来讲一个很多 UE5 开发者会卡住的方向:用 C++ 写“前端 UI”。
不是拖几个蓝图节点去拼界面,而是用 C++ 直接创建控件、绑定数据、响应事件,做一套具备复用性、可维护性、能对接网络数据和批量列表的 UMG UI 系统。这个方向在项目里到底怎么落地,怎么调试,性能和生命周期要注意什么,这篇直接给你拆开讲。
1. UMG C++ UI 编程核心能力速览
| 能力项 | 说明 |
|---|---|
| 技术方向 | 虚幻引擎 5 UMG 与 Slate 之上的 C++ UI 开发 |
| 主要功能 | 动态创建 Widget、UMG 数据绑定、按钮事件绑定、列表批量刷新、动态加载资源、HTTP 数据接入 |
| 开发语言 | C++,蓝图可作为辅助配合 |
| 推荐环境 | Windows 10/11 + Visual Studio 2022 + UE5.0 及以上版本 |
| 硬件门槛 | CPU 多核,内存 16 GB 起步;GPU 取决于编辑器分辨率和 UI 特效复杂度 |
| 批量任务 | 支持动态生成列表,支持批量刷新 Widget 数据 |
| 接口 API | 支持接入 HTTP/JSON 服务,需在工程模块中启用 HTTP、Json 模块 |
| 调试方式 | 编辑器日志、VS 断点、stat UMG、stat Slate |
| 适合场景 | 复杂主界面、数据驱动列表、战斗 HUD、大世界交互 UI、网络数据展示 |
表格先给你判断的一个框架。下面所有内容,都围绕“用 C++ 把这些能力做出来”这条主线展开。
2. 适用场景与使用边界
2.1 这套方案适合谁
- 项目里 UI 数量多、界面复杂,蓝图节点多到难以维护。
- 需要频繁刷新数据,例如任务系统、背包、商店、排行榜。
- 需要动态生成同一结构的控件,例如列表项、消息滚动条、多页签内容。
- 需要把 UI 操作和网络请求、业务逻辑分开管理。
- 团队有 C++ 开发能力,希望 UI 层也走代码评审、版本管理和复用逻辑。
2.2 能解决什么问题
用 C++ 写 UMG 最直接的价值是:把界面逻辑从蓝图里搬出来,用代码控制控件的创建、销毁和刷新。这样可以做到:
- 控件结构统一。列表项、按钮、文本、输入框都按同一个 C++ 类生成,而不是在蓝图层复制粘贴。
- 数据驱动。界面不再关心业务数据从哪里来,只负责接收数据并刷新。
- 逻辑可测试。Widget 控制器可以用纯逻辑函数处理数据,方便断点和单元化验证。
- 加载更灵活。可以按模块动态加载资产,不需要所有 UI 都常驻内存。
2.3 不适合什么场景
- 快速原型验证:团队只是想看一下交互效果,蓝图拖节点效率远高于写 C++。
- 策划频繁调整布局和样式:纯 C++ 创建的布局每次调整都要重新编译,不利于试错。
- 没有 C++ 经验的团队:维护成本会比蓝图更高。
更合理的做法是:静态布局用 UMG 设计器摆放,动态结构和数据处理用 C++ 控制。两者配合,而不是谁替代谁。
3. 环境准备与前置条件
3.1 安装 Visual Studio 2022
在 Epic Games Launcher 安装引擎前,先确认 VS 环境可用。
安装 VS 2022 时建议勾选:
- 使用 C++ 的桌面开发
- MSVC v143 生成工具
- Windows 10/11 SDK
- 用于虚幻引擎的 C++ 工具(如果有该组件)
安装完成后重启一次电脑,让环境变量生效。
3.2 安装虚幻引擎 5
通过 Epic Games Launcher 安装最新稳定版 UE5。项目创建时选择“游戏”类,然后选择“Blank”模板。
关键点:模板本身没有特别重要,因为 UI 系统完全由自己创建。但创建项目时必须确保可以使用 C++。
方法一:直接创建 C++ 项目。
方法二:如果已经建好蓝图项目,在项目文件夹右键“服务”,选择“添加 C++ 类”,让引擎生成一个 C++ 类并更新项目文件。这种方式会自动补全模块依赖。
3.3 检查模块依赖
用 VS 或 IDE 打开项目的Build.cs文件,路径在:
Source/<项目名>/<项目名>.Build.cs要使用 UMG、HTTP、Json,需要在PublicDependencyModuleNames或PrivateDependencyModuleNames中增加模块。
示例:
PublicDependencyModuleNames.AddRange(new string[] { "Core", "CoreUObject", "Engine", "InputCore", "UMG", "Slate", "SlateCore", "HTTP", "Json", "JsonUtilities", "HeadMountedDisplay" // 按模板自带情况保留 });修改后,如果项目文件没有自动更新,可以右键.uproject文件选择“Generate Visual Studio project files”,重新生成解决方案。
3.4 磁盘与性能
UE5 编辑器本身占用空间较大,建议预留 100 GB 以上磁盘空间。首次编译 C++ 工程需要等待较长时间,后续增量编译会明显加快。
4. 安装部署与启动方式
4.1 创建 UI 目录结构
进入Source/<项目名>/目录,建议按以下结构组织 UI 代码:
Source/ <项目名>/ UI/ Widgets/ UI_BaseWidget.h/.cpp UI_ItemWidget.h/.cpp UI_ListViewPage.h/.cpp Controllers/ UI_MainController.h/.cpp Data/ UI_ItemData.h/.cpp这种分层的目的是:控件类只负责展示,控制器类负责数据和事件,数据类承载业务模型。后面所有测试代码都会按这个分层来写。
4.2 创建第一个 C++ Widget 类
在编辑器中点击菜单:
Tools -> New C++ Class -> 父类选择 UserWidget -> 命名为 UI_BaseWidget生成后,默认类大概是这样:
#pragma once #include "CoreMinimal.h" #include "Blueprint/UserWidget.h" #include "UI_BaseWidget.generated.h" UCLASS() class YOURPROJECT_API UUI_BaseWidget : public UUserWidget { GENERATED_BODY() };这个类可以有蓝图子类,也可以直接在 C++ 中动态创建。下一节会写具体的创建和展示流程。
4.3 启动编辑器验证编译链路
C++ 工程创建完成后,用 VS 打开解决方案,右键项目“生成”。生成完成后,回到 Epic Launcher 或项目目录,打开编辑器。
如果一切正常,编辑器启动后会加载刚才创建的 C++ 类。这一步只验证一件事:C++ 编译、UHT(Unreal Header Tool)扫描、编辑器加载三个环节都通。
5. 用 C++ 动态创建 Widget 并绑定事件
这一节是核心,所有功能都从这里展开。建议新建一个普通 Actor 来模拟 UI 管理器,避免直接塞进 GameMode。
5.1 创建 UI 管理器类
新建 C++ 类,父类选择 Actor:
#pragma once #include "CoreMinimal.h" #include "GameFramework/Actor.h" #include "UI_MainController.generated.h" class UUI_BaseWidget; UCLASS() class YOURPROJECT_API AUI_MainController : public AActor { GENERATED_BODY() public: AUI_MainController(); virtual void BeginPlay() override; UFUNCTION(BlueprintCallable, Category = "UI") void ShowMainUI(); private: UPROPERTY() TObjectPtr<UUI_BaseWidget> MainWidget; };5.2 动态创建并添加到屏幕
在BeginPlay中创建 Widget:
#include "UI_MainController.h" #include "UI_Widgets/UI_BaseWidget.h" #include "Blueprint/UserWidget.h" AUI_MainController::AUI_MainController() { PrimaryActorTick.bCanEverTick = false; } void AUI_MainController::BeginPlay() { Super::BeginPlay(); ShowMainUI(); } void AUI_MainController::ShowMainUI() { if (!MainWidget) { TSubclassOf<UUserWidget> WidgetClass = UUI_BaseWidget::StaticClass(); MainWidget = CreateWidget<UUI_BaseWidget>(GetWorld(), WidgetClass); } if (MainWidget) { MainWidget->AddToViewport(10); } }这是动态创建 Widget 的最小闭环:CreateWidget+AddToViewport。调试时可以观察MainWidget是否为空,如果为空优先检查WidgetClass是否为有效蓝图类。
5.3 在 C++ 类中手动创建按钮和文本
上面的UI_BaseWidget并没有内容。现在给它增加一个SButton。这里有两种做法,一种是创建 UMG 的 C++ 版控件,另一种是直接创建 Slate 按钮。先从 UMG 控件开始,保持与蓝图一致。
修改UI_BaseWidget:
#pragma once #include "CoreMinimal.h" #include "Blueprint/UserWidget.h" #include "Components/Button.h" #include "Components/TextBlock.h" #include "UI_BaseWidget.generated.h" UCLASS() class YOURPROJECT_API UUI_BaseWidget : public UUserWidget { GENERATED_BODY() protected: virtual void NativeConstruct() override; UPROPERTY(meta = (BindWidget)) TObjectPtr<UButton> ConfirmButton; UPROPERTY(meta = (BindWidget)) TObjectPtr<UTextBlock> TitleText; UFUNCTION() void OnConfirmClicked(); };注意BindWidget的用法:如果这个 C++ 类有蓝图子类,那么蓝图里必须存在同名按钮和文本,否则编辑器会警告。如果完全在 C++ 中创建控件,则不需要BindWidget,而要用WidgetTree动态添加。
NativeConstruct中动态添加控件:
#include "UI_BaseWidget.h" #include "Components/CanvasPanel.h" #include "Components/CanvasPanelSlot.h" void UUI_BaseWidget::NativeConstruct() { Super::NativeConstruct(); if (!ConfirmButton && WidgetTree) { UCanvasPanel* RootCanvas = WidgetTree->ConstructWidget<UCanvasPanel>(UCanvasPanel::StaticClass()); WidgetTree->SetRootWidget(RootCanvas); ConfirmButton = WidgetTree->ConstructWidget<UButton>(UButton::StaticClass()); ConfirmButton->OnClicked.AddDynamic(this, &UUI_BaseWidget::OnConfirmClicked); TitleText = WidgetTree->ConstructWidget<UTextBlock>(UTextBlock::StaticClass()); TitleText->SetText(FText::FromString(TEXT("UI System Ready"))); TitleText->SetColorAndOpacity(FSlateColor(FLinearColor::White)); if (UCanvasPanelSlot* ConfirmSlot = RootCanvas->AddChildToCanvas(ConfirmButton)) { ConfirmSlot->SetPosition(FVector2D(100.f, 100.f)); } if (UCanvasPanelSlot* TextSlot = RootCanvas->AddChildToCanvas(TitleText)) { TextSlot->SetPosition(FVector2D(100.f, 50.f)); } } } void UUI_BaseWidget::OnConfirmClicked() { // 事件绑定测试,后续可以替换成实际业务逻辑 if (TitleText) { TitleText->SetText(FText::FromString(TEXT("Button Clicked!"))); } }需要包含库:
#include "Blueprint/WidgetTree.h" #include "Components/CanvasPanel.h" #include "Components/CanvasPanelSlot.h" #include "Components/Button.h" #include "Components/TextBlock.h"这里重点观察WidgetTree是否为空。如果为空,表示 Widget 不是通过正常流程创建的,需要检查创建方式。
5.4 测试步骤
- 将
AUI_MainController拖入当前 Level。 - 启动 PIE。
- 观察是否出现按钮和文本。
- 点击按钮,观察文本是否变为
Button Clicked!。
判断标准:
- PIE 启动后 1 秒内看到 UI 元素。
- 点击按钮后文本刷新。
- 编辑器输出日志无空指针引用。
- Widget 不显示且无日志,优先看
Visibility是否为SelfHitTestInvisible或Collapsed。
常见问题:
BindWidget与动态创建冲突:确认当前类是否有蓝图子类。- 按钮不响应点击:检查是否使用
AddToViewport,以及是不是在无鼠标模式下测试。 - 编译失败:检查
Build.cs是否添加UMG、Slate、SlateCore模块。
6. 数据绑定与列表批量刷新
UI 进入项目后,核心需求通常不是单个按钮,而是列表、数据、动态内容。
6.1 定义列表项数据结构
新建一个普通 UObject 数据结构UI_ItemData:
USTRUCT(BlueprintType) struct FUI_ItemInfo { GENERATED_BODY() UPROPERTY(BlueprintReadOnly) FString ItemName; UPROPERTY(BlueprintReadOnly) int32 Count = 0; UPROPERTY(BlueprintReadOnly) FLinearColor ItemColor = FLinearColor::White; };列表项 Widget 负责接收这个数据并刷新自身显示。
新建UI_ItemWidget:
UCLASS() class YOURPROJECT_API UUI_ItemWidget : public UUserWidget { GENERATED_BODY() public: UFUNCTION(BlueprintCallable, Category = "UI|Data") void SetupItemData(const FUI_ItemInfo& Info); protected: UPROPERTY(meta = (BindWidget)) TObjectPtr<UTextBlock> NameText; UPROPERTY(meta = (BindWidget)) TObjectPtr<UTextBlock> CountText; UPROPERTY(meta = (BindWidget)) TObjectPtr<UImage> IconImage; };实现:
void UUI_ItemWidget::SetupItemData(const FUI_ItemInfo& Info) { if (NameText) { NameText->SetText(FText::FromString(Info.ItemName)); } if (CountText) { CountText->SetText(FText::FromString(FString::Printf(TEXT("x%d"), Info.Count))); } if (IconImage) { IconImage->SetColorAndOpacity(Info.ItemColor); } }这种模式很简单,但它是所有复杂 UI 的基础:数据对象传入 Widget,Widget 只做显示,不做业务判断。
6.2 动态生成列表
使用UVerticalBox动态添加子项。优势是逻辑直观,适合数量较少的列表。如果列表数量很大,需要换成UListView配合 EntryWidget 使用。
#include "Components/VerticalBox.h" #include "UI_Widgets/UI_ItemWidget.h" void UUI_ListViewPage::RebuildList(const TArray<FUI_ItemInfo>& Items) { if (!ItemListBox) { return; } ItemListBox->ClearChildren(); for (const FUI_ItemInfo& Info : Items) { UUI_ItemWidget* ItemWidget = CreateWidget<UUI_ItemWidget>(GetOwningPlayer(), UUI_ItemWidget::StaticClass()); if (ItemWidget) { ItemWidget->SetupItemData(Info); ItemListBox->AddChildToVerticalBox(ItemWidget); } } }清空再生成是一种稳定做法。如果每秒只刷新一次,数据量在几十条级别,这种方式完全够用。
6.3 使用 UListView 做大批量数据展示
当列表项数量超过几百条,或者需要滚动裁剪时,应该使用UListView。
#include "Components/ListView.h" UPROPERTY(meta = (BindWidget)) TObjectPtr<UListView> ItemListView; void UUI_ListViewPage::SetupListView(const TArray<FUI_ItemInfo>& Items) { if (!ItemListView) { return; } ItemListView->ClearListItems(); TArray<UObject*> ItemObjects; for (const FUI_ItemInfo& Info : Items) { UUI_ItemDataObject* DataObject = NewObject<UUI_ItemDataObject>(); DataObject->Info = Info; ItemObjects.Add(DataObject); } ItemListView->SetListItems(ItemObjects); }UListView的 EntryWidget 需要在蓝图或 C++ 中指定。如果列表项 Widget 逻辑简单,建议用 C++ 创建对应 Entry 类,避免蓝图层数据回传困难。
6.4 大批量测试要点
测试数据准备:
TArray<FUI_ItemInfo> GenerateTestData(int32 Count) { TArray<FUI_ItemInfo> Result; Result.Reserve(Count); for (int32 i = 0; i < Count; ++i) { FUI_ItemInfo Info; Info.ItemName = FString::Printf(TEXT("Item_%d"), i); Info.Count = i * 3 + 1; Result.Add(Info); } return Result; }预期结果:1000 条数据在垂直列表中滚动无明显卡顿。如果出现明显掉帧,观察stat UMG和stat Slate数据。
7. 接口 API 调用与实时数据刷新
UI 本身不产生数据,数据通常来自后端接口。下面给出 UE5 原生 HTTP 调用的基础模型。
7.1 发送 GET 请求
在 UI 控制器中添加请求接口:
#include "HttpModule.h" #include "Interfaces/IHttpRequest.h" #include "Interfaces/IHttpResponse.h" #include "JsonObjectConverter.h" #include "Json.h" void UUI_MainController::RequestServerData(const FString& Url) { TSharedRef<IHttpRequest> Request = FHttpModule::Get().CreateRequest(); Request->SetURL(Url); Request->SetVerb(TEXT("GET")); Request->SetHeader(TEXT("Content-Type"), TEXT("application/json")); Request->OnProcessRequestComplete().BindUObject(this, &UUI_MainController::OnServerDataReceived); Request->ProcessRequest(); } void UUI_MainController::OnServerDataReceived(FHttpRequestPtr Request, FHttpResponsePtr Response, bool bSucceeded) { if (!bSucceeded || !Response.IsValid()) { return; } TSharedPtr<FJsonObject> JsonObject; TSharedRef<TJsonReader<>> Reader = TJsonReaderFactory<>::Create(Response->GetContentAsString()); if (!FJsonSerializer::Deserialize(Reader, JsonObject) || !JsonObject.IsValid()) { return; } // 在这里解析 Json 并调用 UI 刷新函数 TArray<FUI_ItemInfo> NewItems; // 根据实际返回字段填充数据 if (IsValid(CurrentPageWidget)) { CurrentPageWidget->RebuildList(NewItems); } }7.2 批量刷新策略
批量刷新不是一次刷新完成,而是要避免每一条数据到达都触发 UI 刷新。最稳妥的模式是:
- 启动请求后,UI 进入 Loading 状态。
- 收到完整响应后,一次性构建数据模型。
- 数据模型构建完成后,再调用列表刷新函数。
这样可以防止列表在数据填充中途出现多次ClearChildren和重建。
7.3 接口请求注意事项
- HTTP 请求回调默认在游戏线程回调,可以直接操作 UMG,但不要在
ProcessRequest后立刻等待数据,必须使用回调函数处理。 - 每条请求都要绑定回调函数,否则回调不会执行。
- 批量请求建议使用时间戳做编号管理,避免旧请求覆盖新数据。
- 如果使用第三方服务器,考虑加超时和重试机制。
8. 资源占用与性能观察
UI 性能问题是 UE5 项目上线的常见瓶颈。C++ 写 UI 同样无法避免这些问题,只能更精确地控制。
8.1 观察工具
stat UMG:查看控件数量、布局更新频率。stat Slate:查看 Slate 渲染和布局耗时。stat FPS:结合整体帧率判断 UI 造成的影响。- VS 中的性能分析器:定位哪个函数占用过高。
8.2 控件数量
动态生成列表时,控件数量会直接反映在内存上。即使只有 100 条数据,每条数据包含 5 个控件,就会产生 500 个 Slate 控件。
优化思路:
- 小数据量使用
VerticalBox,简单清晰。 - 大数据量改用
UListView,按可见区域生成节点。 - 已经生成的 Widget 不要反复
CreateWidget。 - 不需要显示时用
RemoveFromParent而不是仅SetVisibility。
8.3 每帧更新的代价
不要在Tick里更新文本。文本刷新涉及 Slate 布局、文字缓存、脏标记。如果确实需要实时刷新,每秒限制最多 10 次,并且只刷新变化内容。
可以从控制器提供一个刷新频率:
float UpdateInterval = 0.2f; float LastUpdateTime = 0.0f; void AUI_MainController::Tick(float DeltaSeconds) { Super::Tick(DeltaSeconds); LastUpdateTime += DeltaSeconds; if (LastUpdateTime >= UpdateInterval) { LastUpdateTime = 0.0f; // 批量刷新 UI 数据 } }这种方式比每帧刷新节省大量开销。
8.4 图片资源的处理
动态创建UImage时,尽量使用已经常驻内存的贴图引用,避免每帧去LoadObject。图标类资源建议合并图集,减少 DrawCall。
可以用软引用预加载:
UPROPERTY(EditDefaultsOnly) TSoftObjectPtr<UTexture2D> IconTexture;在SetupItemData中异步加载:
FSoftObjectPath TexturePath = IconTexture.ToSoftObjectPath(); if (!TexturePath.IsValid()) { return; } FStreamableManager& StreamableManager = UAssetManager::GetStreamableManager(); StreamableManager.RequestAsyncLoad( TexturePath, FStreamableDelegate::CreateLambda([this](UTexture2D* LoadedTexture) { if (IconImage && LoadedTexture) { IconImage->SetBrushFromTexture(LoadedTexture); } }) );具体写法根据 UE 版本会有差异,5.1 之后推荐使用FSoftObjectPtr配合UAssetManager。
9. 常见问题与排查方法
下面是 C++ UI 开发中高频问题的排查表:
| 问题现象 | 可能原因 | 排查方式 | 解决方案 |
|---|---|---|---|
| C++ 编译无法识别 UMG 相关类型 | 模块未添加 | 检查Build.cs模块列表 | 增加 UMG、Slate、SlateCore |
| CreateWidget 返回空指针 | 控件类不合法,或蓝图子类未编译 | 打印 WidgetClass 日志 | 确保类有UCLASS(),蓝图已保存 |
| Widget 不显示 | 没有调用 AddToViewport,或 PlayerController 失效 | 打断点查 MainWidget 状态 | 检查GetOwningPlayer(),调用 AddToViewport |
| 按钮无法点击 | Visibility 不对,或父容器遮挡 | 查看控件层级 | 设置合适的 Visibility,调整 ZOrder |
| 点击回调不触发 | 动态创建的 Button 没有绑定 | 查看 NativeConstruct 是否执行 | 在 NativeConstruct 中绑定,避免在构造函数中绑定 |
| 列表刷新后旧 UI 仍在 | 没有 ClearChildren | 查看控件数量 | 刷新前先清空容器 |
| HTTP 请求回调不执行 | 绑定了临时对象的回调 | 检查回调是否被 GC | 使用 UPROPERTY 持有请求对象,或改用方法绑定 |
| 中文字符串乱码 | 源文件编码问题 | 检查文件保存格式 | 源码保存为 UTF-8 with BOM,使用 TEXT() 宏 |
| 打包后下载图片不显示 | 软引用路径非法 | 看 Log 中加载报错 | 检查纹理路径和 Cook 设置 |
| 频闪或掉帧严重 | 每帧刷新 Widget | stat UMG 观察更新频率 | 按间隔刷新,仅更新变化内容 |
| 界面出现不可见碰撞 | 根节点没有设置 Visibility | 检查 CanvasPanel 可见性 | 设置 SelfHitTestInvisible 或 NotHitTestable |
10. 最佳实践与工程化建议
10.1 保持 UI 控制器独立
业务逻辑和 UI 逻辑要分开。
建议模式:
- 数据层:业务数据由 GameMode、PlayerState 或数据服务提供。
- 控制器层:接收数据,解析数据,决定何时刷新 UI。
- 控件层:只负责显示。
在这套结构下,后续改数据源时不需要动 UI 代码,改 UI 样式时不需要影响数据处理。
10.2 控件生命周期管理
UUserWidget是 UObject 对象,受 GC 管理。动态创建的 Widget 如果没有持有引用,可能被回收。
建议:
- 全局 UI 控件用
UPROPERTY()保存引用。 - 临时弹窗创建后,如果希望延迟销毁,不要立刻
RemoveFromParent,可以在控制器中统一管理。 - 在
BeginDestroy或NativeDestruct中清理定时器和绑定。
10.3 统一入口启动 UI
不要在每个地图都直接创建 UI。做一个UIManager,提供统一的打开、关闭、切换接口。接口名称就像这样:
UFUNCTION(BlueprintCallable, Category = "UI") void OpenPanel(FName PanelId); UFUNCTION(BlueprintCallable, Category = "UI") void ClosePanel(FName PanelId);这样可以避免多个 UI 互相覆盖、层级混乱。
10.4 数据刷新策略
- 接收数据前先判断是否为空。
- 数据量超过 100 条时使用 ListView。
- 阶段刷新频率控制在 0.2 秒以上。
- 有大量图片动态加载时,使用异步加载和小图集中方案。
10.5 接口与安全边界
UI 对接网络接口时,需要把处理重点放在数据安全上:
- 不把服务器密钥或内部配置写死在 UI 层。
- 敏感信息,例如账号、Token,避免写入日志。
- 涉及用户个人信息、聊天内容、上传文件时,确保来源合法、展示授权明确。
- 涉及图形界面中的人脸、头像、角色形象等素材,确保具备使用授权。
11. 总结与下一步
这篇文章演示了从环境准备到 C++ 动态创建 Widget、事件绑定、数据列表、网络请求、批量刷新和性能优化的完整链路。最值得自己先跑通的是:创建UI_BaseWidget、动态加按钮、点击改文本。这一步通了,后面对接列表和接口就只是同一个模式的延伸。
最容易踩的坑有三个:Build.cs模块缺失导致编译失败、CreateWidget返回空指针、控件生命周期没有持有引用。
下一步可以按这个顺序继续深入:
- 去源码里翻一下
UListView、UListViewBase的实现,掌握 EntryWidget 复用机制。 - 用 Slate 直接写一个自定义控件,看 Slate 和 UMG 的互相嵌套方式。
- 接一套真实后端接口,做数据驱动的排行榜或者背包页面。
- 给 UI 加上统一状态管理,用类似栈的结构控制页面跳转。
建议先把这一套代码放到一个新测试工程里跑通,再迁移到正式项目。UI 系统只要架构能立住,后面加页面只是数量问题。