1. 为什么动态UI需要结构体数组
先说一下我自己的经历。早几年做UE项目时,背包系统是用一堆单独的变量硬凑出来的,物品名一个变量、图标一个变量、数量一个变量,然后直接在Widget上摆好Item_1、Item_2、Item_3……这种做法的痛处我太清楚了:UI要做成动态伸缩列表时,蓝图节点连得密密麻麻,想加一行新物品得复制三五个控件,改一次布局要动几十个变量引用,项目一打包还经常因为控件路径写错直接崩。
后来转向结构体数组,才算是把这块硬骨头啃下来了。
结构体数组,核心就一句话:把一批逻辑上相关的数据打包成一种自定义类型,再用数组方式统一管理。比如物品的ID、名称、图标、数量、品质这些字段,原本要散落在各个变量里,现在合成一个FItemData结构体,背包里的所有物品就是TArray<FItemData>。UI生成的时候,只需要遍历这个数组,一个一个创建对应的控件实例就行。
这个思路之所以在动态UI场景里特别香,是因为动态UI的本质需求就是“数据和显示解耦”。你有多少条数据,UI就生成多少个Item,数据变了,UI跟着刷新,不需要手工去管控件数量和位置。用结构体数组做数据源,UE里无论是UMG的ListView还是动态AddChild,都能直接绑定,非常顺手。
这篇文章会把从创建结构体、设计数组、搭建UI到动态生成的完整链路讲清楚,蓝图和C++两种方式都会涉及。适合刚开始接触UE动态UI的开发者,也适合那种“写完静态UI但不知道怎么让它动起来”的朋友,看完可以直接在自己的项目里落地。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 结构体数组解决的核心问题
2.1 传统静态UI的维护困境
静态UI的问题不在“能不能用”,而在“改起来有多痛”。假设你要做一个任务列表,任务一、任务二、任务三,每个任务三个字段:标题、进度、奖励。静态做法就是画三个Item控件,每个控件里拖三个Text,然后一个一个把内容填进去。
这套方案在任务数量固定的时候没毛病,但只要业务一变,比如要根据玩家的等级动态显示不同数量的任务,或者任务列表要在运行时从服务器拉取再刷新,静态UI立刻变成灾难。你要么预先藏好几十个Item控件挨个显隐,要么在蓝图层层嵌套WidgetSwitcher,逻辑复杂到连自己都看不懂,改一个字段要揪出三四条线重新连。
2.2 数据驱动UI的核心优势
结构体数组的思路完全反过来。先在逻辑层把数据整理成结构体数组,UI层只负责“根据数组长度创建对应数量的控件”,每个控件从数组里取对应下标的数据填入。
这个模式的好处有几个:
- 数据与显示解耦。业务层只管修改数组内容,UI自动响应,不需要手动去翻Widget里某个Text的引用。
- 数量天然动态。从3条数据改成30条,不需要动UI布局,遍历创建即可。
- 复用性强。同一个结构体数组,换个Item控件样式就可以用在背包、商店、任务等多个界面。
说到底,动态UI的本质就是让UI跟着数据走,而不是数据跟着UI走。结构体数组把数据整理成了干净统一的格式,动态UI才有了可靠的源头。
2.3 典型应用场景
哪些地方最值得用结构体数组去做动态UI?我实际接触过的场景里,这几类最常见:
- 背包/仓库系统。物品数量不固定,每件物品有ID、名称、图标、堆叠数、品质等字段。
- 任务列表/成就列表。任务条目来自玩家进度,数量随玩法动态增减。
- 商店/图鉴。条目多,需要滚动列表,还要支持筛选排序。
- 角色选择/载具列表。玩家拥有可展示的对象集合,UI按集合动态生成。
这些场景共同的特点是:数据条目数量不确定、字段结构固定、需要在运行时刷新显示。结构体数组刚好命中全部需求。
3. 结构体与数组的基础认知
3.1 结构体(Struct)是什么
结构体是一种自定义数据类型,把多个不同字段打包成一个整体。可以把它想象成一个“表格的行”,行里的每一列就是结构体的一个字段。在UE里,结构体可以包含整型、浮点型、字符串、布尔值,也可以嵌套其他结构体、包含Object引用、甚至套数组。
和类(Class)不同的地方在于,结构体是纯数据容器,不包含逻辑方法。在UE里定义一个结构体,你得到的只是一个数据模板,需要配合蓝图类或函数来处理它的逻辑。这个特性正好契合动态UI的数据源需求:我们需要的是“一份干净的数据”,而不是带一堆方法的对象。
3.2 数组(Array)与结构体的组合
数组是同一类型元素的集合,元素按索引访问。结构体数组就是“多条结构体数据的集合”,相当于把一个数据表格整装打包。
在UE里的实际使用中,最舒服的一点是:结构体数组可以直接作为变量类型,也能作为函数参数传递。你可以把一个TArray<FItemData>传给任何需要数据源的函数,UI层只管接收循环,逻辑层只管填数据。
在蓝图中创建结构体数组的方式很直接:在变量面板添加变量,类型选择数组,再指定元素类型为你创建的结构体。比如我做一个背包结构体,就在数组变量上把Element Type设为ItemData即可。
3.3 为什么选结构体数组而不是TMap或单变量
这个问题常被问到。既然动态UI要遍历数据,为什么用结构体数组,不用TMap或者直接拉一堆变量?
TMap是键值对,适合按键查找,但UI遍历必须拿到所有条目,TMap没有一个明确的顺序概念。而数组有顺序,索引从0开始,UI逐条生成时天然有排列基准。另外一个原因是结构体在蓝图里可视化编辑非常友好,直接在Details面板里逐行填写数据,看着就是一沓表格,不容易漏字段。
当然如果有“按键查数据”的刚需,可以结构体数组做主数据,再用TMap做索引,两者配合着用,但UI生成层的数据沟通方式,我始终建议用数组。
3.4 蓝图中定义结构体数组的正确姿势
打开内容浏览器,右键选择Blueprint Structure,命名ItemData,然后在结构体编辑器中点击New Variable逐个添加字段。字段类型、默认值都可以在Details面板中设置。保存之后,回到你需要的地方添加变量,Variable Type选择ItemData并勾选Array,这样这个变量就是结构体数组了。
有一处细节:结构体字段如果改了名字,之前赋值的蓝图实例数据会失效。所以结构体设计阶段尽量想好字段命名,中途改名是动态UI开发中很常见的一个坑。
4. 动态UI的搭建与数据绑定
4.1 控件布局设计:ListView还是WrapBox
进入UMG设计UI之前,先确定一个问题:动态生成的Item要排成什么形式?
如果只是纵向列表,建议直接用ListView。UE自带的ListView对结构体数组支持很到位,它的Entry Widget可以自动创建并绑定数据,官方这套方案性能优秀,滚动流畅。如果你要做网格形式的背包或图鉴,可以用WrapBox配合动态AddChild,让控件自动换行。
如果列表很长(比如几百条),ListView直接选用。WrapBox灵活,但条目多时手工创建所有控件会有性能压力。实际项目里我的习惯是:条目数量几十以内且要灵活排布的用WrapBox,数量大或需要滚动虚拟化的用ListView。
4.2 创建Item控件
无论哪种排布方式,你都需要一个“单条数据”的控件蓝图。比如WBP_ItemEntry,里面放几个Text Block显示名称和数量,一个Image显示图标,一个Border或Button作为选中反馈。
关键设计点有三个:
一是数据的输入口要统一。给这个Item控件做一个Init函数,入参是ItemData结构体,函数内部把数据填到各个子控件上。这样外部调用方不需要知道Item内部有哪些控件,只需传一个数据。
二是控件根节点名要明确,动态创建的AddChild操作会引用这个根节点,名字起得不好在后续维护时容易搞混。
三是如果用了ListView,Entry Widget需要实现GetListItemObject的模式,UE官方封装了一套数据绑定机制,直接在OnListItemObjectSet事件中取数据。
4.3 遍历数组生成UI
Widget蓝图里准备好容器控件后,核心逻辑就是把结构体数组遍历一遍,逐条生成Item并放入容器。蓝图的做法是:用ForEachLoop遍历数组,循环体里Create Widget节点创建Item控件,再调用AddChild把它加到容器上,然后调用Item的Init函数把当前循环的数组元素传进去。
这里有一个新手容易踩的坑:Create Widget节点必须在Widget所在的类上下文里调用,如果你在别的类里创建Item,必须指定好Outer或使用正确的类引用,否则生成的控件可能因为归属错误而显示异常。我自己调试时遇到过一次创建出来的控件不显示也不报错,查了半小时发现是Class Settings设错了。
C++环境下流程类似:UUserWidget::CreateWidget
4.4 数据刷新与增量更新
首屏生成只是第一步,动态UI更常见的是数据刷新。结构体数组变化后,怎么让UI同步更新?
最简单粗暴的方式:清空容器重新生成。WrapBox则先获取所有子节点逐个RemoveFromParent,然后重新遍历生成。这个方式代码简单、逻辑直观,在条目不多时完全没有性能问题,几十个控件重建一次开销很小,推荐入门阶段先这么做。
另一种方式是增量更新:定位到某个Item控件实例,调用它的Init函数传入新数据,实现局部更新。这需要维护一个“控件实例和数组下标”的对应关系,可以在生成时把下标存到Item的Tag或者专门字段里。条目多且刷新频繁时再用这种优化方式。
5. 完整实操流程:从数据到界面的落地
5.1 定义数据结构:一个背包的例子
我拿背包来做完整示例,这个场景最经典也最实用。
先在内容浏览器创建Blueprint Structure,命名为ItemData。字段如下:
| 字段名 | 类型 | 说明 |
|---|---|---|
| ItemID | Integer | 物品唯一ID |
| ItemName | String | 显示名称 |
| Icon | Texture2D | 物品图标 |
| Count | Integer | 堆叠数量 |
| Quality | Integer | 品质等级,0普通/1稀有/2史诗 |
| Description | Text | 物品描述 |
字段的类型决定Item控件里对应显示控件的类型。Icon用Texture2D,UI层的Image控件直接可以把纹理赋上。Quality这种不会直接在UI上显示的字段,可以用来做颜色切换或排序。
5.2 创建背包数据数组
在GameInstance或Player Controller里创建一个ItemData类型数组变量,比如叫BackpackItems。在BeginPlay或者玩家进入游戏时给数组填充几条测试数据。
蓝图中可以直接右键变量,选择“Add Array Element”在Details面板里逐条填写数据。结构体数组的细节编辑面板和Excel表格的体验很像,每行一条数据,每个字段一列,填起来非常直观。
C++里则是在构造函数或初始化时用初始化列表给数组赋值,比如:
cpp复制BackpackItems.Add(FItemData(1001, TEXT("生命药水"), LoadObject<UTexture2D>(nullptr, TEXT("/Game/Textures/T_Potion")), 5, 0));
这里要注意LoadObject是在非加载阶段获取资源的做法。如果是运行时动态加载,最好用FStreamableManager做异步加载,避免加载大资源时卡顿。
5.3 搭建UMG界面
创建一个Widget Blueprint,命名为WBP_Backpack。根节点下放一个ScrollBox作为滚动容器,命名为ItemList。ScrollBox的优势是自带滚动条和裁剪,回去研究一下它的属性面板,有一项“Is Variable”要勾上,这样蓝图才能引用到这个容器。
新建一个WBP_ItemEntry控件。根节点建议用Border,方便设置底色和选中高亮。Border下面放水平排列:一个Image用于图标,两个Text分别显示名称和数量,可选再放一个Border作为品质底色。
在WBP_ItemEntry内部创建一个自定义事件,命名为Init,入参是ItemData结构体。函数逻辑:
- 把入参数据存到一个变量里,后续其他操作可能要用。
- ItemNameText的SetText绑定ItemName字段。
- CountText的SetText绑定数量,格式化成“x3”这种样式。
- IconImage的SetBrushFromTexture绑定Icon字段。
- 根据Quality字段切换Border的Brush Color。
这一步的关键思维:Item控件只负责“拿到一个ItemData并显示它”,不关心数据从哪来、数组多长。数据来源是外部传入的,显示逻辑封装在Item内部。
5.4 蓝图层实现动态生成
回到WBP_Backpack的Event Pre Construct或者Event Construct中调用一个自定义事件RefreshList。
RefreshList的逻辑:
- 从GameInstance里取BackpackItems数组。
- ClearChildren清空ItemList容器的所有子节点。
- ForEachLoop遍历数组,在循环体内用Create Widget创建WBP_ItemEntry实例。
- AddChild把新Item加到容器。
- 调用Item的Init事件把当前循环的Element传进去。
这个流程在蓝图里大约五六个节点,连完就能跑了。Enter Play后你会看到WMG里动态生成了和数组条目数量一致的Item列表。
5.5 C++方式实现同款逻辑
C++玩家的动态UI逻辑其实更直接。先给ItemEntry控件类加一个数据设置函数:
cpp复制void UItemEntryWidget::Init(const FItemData& InData)
{
ItemNameText->SetText(FText::FromString(InData.ItemName));
CountText->SetText(FText::FromString(FString::Printf(TEXT("x%d"), InData.Count)));
if (InData.Icon != nullptr)
{
IconImage->SetBrushFromTexture(InData.Icon);
}
}
容器侧的Refresh逻辑:
cpp复制void UBackpackWidget::RefreshList()
{
ItemList->ClearChildren();
auto MyGI = Cast<UMyGameInstance>(GetGameInstance());
for (const FItemData& Item : MyGI->BackpackItems)
{
UItemEntryWidget* Entry = CreateWidget<UItemEntryWidget>(this, ItemEntryClass);
Entry->Init(Item);
ItemList->AddChild(Entry);
}
}
这套代码很短,但背后涉及的封装很重要:ItemEntryClass要在蓝图子类里指定,CreateWidget的Outer参数传的是当前Widget的this,窗口的归属正确。
6. 常见问题与排查技巧实录
6.1 动态创建的控件不显示
这个问题几乎每个新手都会遇到,我自己调试时也栽过跟头。最常见的原因是Create Widget和AddChild的上下文不一致,或者容器引用失效。
排查建议:
- 确认ItemList变量的Is Variable打了勾,并且正确绑定到了容器控件上。
- 加一个Print String打印容器的子节点数量,如果AddChild成功但数量为0,说明逻辑根本没有执行到AddChild这一步。
- 检查Item控件的创建类型是不是你修改过的蓝图类,而不是基础UserWidget类。
6.2 数据刷新后UI不更新
常见原因是刷新逻辑写在Pre Construct而不是Event Construct里。Pre Construct在编辑器预览时触发,实际运行时不一定重新执行。尤其当你修改了数组内容后如果不调用RefreshList,UI永远停留在旧数据上。
我的习惯是:把生成逻辑封装成RefreshList自定义事件,在Construct和业务数据变化后都调用它。
6.3 Item控件数据错乱
这是动态UI最容易出问题的地方,尤其在ScrollBox里滚动复用场景下。并不是每个Item控件只对应一条数据,而是滚动过程中控件会被复用。
如果你用ListView并开启了复用机制,每条Item显示的应该是“当前绑定的ListItemObject”的数据,而不是自己在变量里存的缓存。解决思路是:在OnListItemObjectSet事件里重新从数据对象取一遍所有字段,不要只刷新一次然后依赖旧状态。
如果用的是WrapBox手动创建,不存在复用问题,但要防止生成时下标传递错误。ForEachLoop里的Loop Index在传给Item的Init时,一定要确认取的是当前迭代的元素,而不是数组末位元素。这个坑很隐蔽,在C++里使用引用要格外注意别把循环变量写错。
6.4 结构体数组为空或取不到数据
可能原因:
- 数组变量声明在了错误的类上。比如在Level Blueprint的计局里创建的数据,进关卡就重置了。
- 结构体字段默认值设为空,实际填充时没赋值成功。
- 数组是BaseClass中私有变量,在C++侧访问时权限不够。
排查时分别加Print String验证数据的Count和每条数据的ItemName是否正常。先确认数据源OK,再检查Widget层面。
6.5 常见问题速查表
| 现象 | 可能原因 | 首选排查动作 |
|---|---|---|
| 生成的Item没出现 | Create Widget类别设错/容器引用失效 | Print容器子节点数量 |
| 控件出现但内容空白 | Init调用时机晚于AddChild | 调整Init和AddChild顺序 |
| 滚动后内容错乱 | ListView复用了旧控件 | 在OnListItemObjectSet中完整刷新 |
| 数组数据共享 | 多Widget引用同一数组 | 考虑按需复制结构体 |
| 数据只填了一条 | ForEachLoop循环错误 | 打印Loop Index和数组长度 |
| 修改结构体字段后初始化丢失 | 结构体名变更导致旧存档读不进来 | 避免中途改名,必要时做版本兼容 |
7. 性能优化与扩展思路
7.1 大数据量下的UI优化方向
结构体数组的动态UI在数据量超过50条时,就该考虑性能了。WrapBox方式创建大量控件,每个控件都是一个UUserWidget实例,各有自己的Widget Tree,内存和渲染开销都会上升。
优先方案是ListView配合虚拟化。ListView只会创建当前可见区域的Entry,滚动时复用,不是一次性创建全部Item。UE官方这套机制对几千条数据的滚动列表都扛得住,前提是Entry本身别太复杂,避免每帧实时改Text。
如果必须在WrapBox里显示大量数据,可以做分页或延迟生成,先显示前20条,下拉到底时追加更多。这个思路在手游商城、图鉴界面最常用。
7.2 结构体数组与存档系统结合
结构体数组天然适合存档。UE的SaveGame系统可以直接把结构体数组作为成员变量保存,物品列表、任务状态这类数据,做成结构体数组存到SaveGame里,读写都非常简单。C++侧用USaveGame对象存储数组,蓝图侧只需调SaveGameToSlot和LoadGameFromSlot就能持久化。
要注意的是结构体里若包含UObject引用(比如Texture2D),存档时不会保存资源本身,只能保存资源路径。正确做法是存路径字符串,加载时再动态LoadObject。
7.3 结构体数组结合数据表格
如果你有Excel表格格式的配置数据,UE的DataTable可以直接把表内容import成结构体数组。DataTable每一行就是一条结构体数据,列对应字段。这种做法的好处是策划可以直接改表,不用在编辑器里一个个点变量。
DataTable到动态UI的链路很顺溜:DataTable的RowMap可以拿到所有行数据,转成数组后走同一个生成逻辑。这个方案适合物品表、任务表这种批量配置的场合。
7.4 这套思路的更多玩法
动态UI用结构体数组这个模式,本质上是“数据驱动”思想的体现,理解之后能玩出很多花样:
- 给同一个结构体数组更换不同的Entry控件,同一份数据可以生成列表、网格、轮播图三种形态。
- 结构体内增加类型字段,Item控件根据类型切换显示样式,一套代码适配多种条目。
- 结构体数组存装备属性,再配合强大类型,一套面板通吃背包、仓库、商店购买框。
- 用结构体数组驱动敌人波次、关卡配置等纯逻辑内容,不只是UI绑定。
“动态”两个字真正的含义就是运行时数据变化带动界面变化,结构体数组给这个变化提供了标准化、可扩展的数据支撑。
根据我自己的经验,做动态UI最需要培养的习惯就是:先想清楚数据长什么样,再去想UI怎么画。数据结构和UI结构一一对应,问题就解决了一半以上。亲手用结构体数组把一个小列表做出来,比看多少份文档都管用。UE里花式UI看着复杂,拆开看底层都是数据的整理和展示,把这一层打通,后面学什么界面系统都轻松。
