
这次我们来讲一个很多 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 编译、UHTUnreal 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() TObjectPtrUUI_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) { TSubclassOfUUserWidget WidgetClass UUI_BaseWidget::StaticClass(); MainWidget CreateWidgetUUI_BaseWidget(GetWorld(), WidgetClass); } if (MainWidget) { MainWidget-AddToViewport(10); } }这是动态创建 Widget 的最小闭环CreateWidgetAddToViewport。调试时可以观察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)) TObjectPtrUButton ConfirmButton; UPROPERTY(meta (BindWidget)) TObjectPtrUTextBlock 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-ConstructWidgetUCanvasPanel(UCanvasPanel::StaticClass()); WidgetTree-SetRootWidget(RootCanvas); ConfirmButton WidgetTree-ConstructWidgetUButton(UButton::StaticClass()); ConfirmButton-OnClicked.AddDynamic(this, UUI_BaseWidget::OnConfirmClicked); TitleText WidgetTree-ConstructWidgetUTextBlock(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_ItemDataUSTRUCT(BlueprintType) struct FUI_ItemInfo { GENERATED_BODY() UPROPERTY(BlueprintReadOnly) FString ItemName; UPROPERTY(BlueprintReadOnly) int32 Count 0; UPROPERTY(BlueprintReadOnly) FLinearColor ItemColor FLinearColor::White; };列表项 Widget 负责接收这个数据并刷新自身显示。新建UI_ItemWidgetUCLASS() 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)) TObjectPtrUTextBlock NameText; UPROPERTY(meta (BindWidget)) TObjectPtrUTextBlock CountText; UPROPERTY(meta (BindWidget)) TObjectPtrUImage 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 的基础数据对象传入 WidgetWidget 只做显示不做业务判断。6.2 动态生成列表使用UVerticalBox动态添加子项。优势是逻辑直观适合数量较少的列表。如果列表数量很大需要换成UListView配合 EntryWidget 使用。#include Components/VerticalBox.h #include UI_Widgets/UI_ItemWidget.h void UUI_ListViewPage::RebuildList(const TArrayFUI_ItemInfo Items) { if (!ItemListBox) { return; } ItemListBox-ClearChildren(); for (const FUI_ItemInfo Info : Items) { UUI_ItemWidget* ItemWidget CreateWidgetUUI_ItemWidget(GetOwningPlayer(), UUI_ItemWidget::StaticClass()); if (ItemWidget) { ItemWidget-SetupItemData(Info); ItemListBox-AddChildToVerticalBox(ItemWidget); } } }清空再生成是一种稳定做法。如果每秒只刷新一次数据量在几十条级别这种方式完全够用。6.3 使用 UListView 做大批量数据展示当列表项数量超过几百条或者需要滚动裁剪时应该使用UListView。#include Components/ListView.h UPROPERTY(meta (BindWidget)) TObjectPtrUListView ItemListView; void UUI_ListViewPage::SetupListView(const TArrayFUI_ItemInfo Items) { if (!ItemListView) { return; } ItemListView-ClearListItems(); TArrayUObject* ItemObjects; for (const FUI_ItemInfo Info : Items) { UUI_ItemDataObject* DataObject NewObjectUUI_ItemDataObject(); DataObject-Info Info; ItemObjects.Add(DataObject); } ItemListView-SetListItems(ItemObjects); }UListView的 EntryWidget 需要在蓝图或 C 中指定。如果列表项 Widget 逻辑简单建议用 C 创建对应 Entry 类避免蓝图层数据回传困难。6.4 大批量测试要点测试数据准备TArrayFUI_ItemInfo GenerateTestData(int32 Count) { TArrayFUI_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) { TSharedRefIHttpRequest 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; } TSharedPtrFJsonObject JsonObject; TSharedRefTJsonReader Reader TJsonReaderFactory::Create(Response-GetContentAsString()); if (!FJsonSerializer::Deserialize(Reader, JsonObject) || !JsonObject.IsValid()) { return; } // 在这里解析 Json 并调用 UI 刷新函数 TArrayFUI_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) TSoftObjectPtrUTexture2D 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、SlateCoreCreateWidget 返回空指针控件类不合法或蓝图子类未编译打印 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 设置频闪或掉帧严重每帧刷新 Widgetstat UMG 观察更新频率按间隔刷新仅更新变化内容界面出现不可见碰撞根节点没有设置 Visibility检查 CanvasPanel 可见性设置 SelfHitTestInvisible 或 NotHitTestable10. 最佳实践与工程化建议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 系统只要架构能立住后面加页面只是数量问题。