2.3 数组与结构体的复制语义
结构体:整体复制
USTRUCT(BlueprintType)
struct FPlayerVitals
{
GENERATED_BODY()
UPROPERTY() float Health;
UPROPERTY() float Shield;
UPROPERTY() int32 Level;
};
UPROPERTY(Replicated)
FPlayerVitals Vitals;
复制时整个结构体当作一个属性:结构体任何一个成员变化,都会把整个结构体重新发送一遍;客户端 OnRep 也只在结构体变化时触发一次(无法得知是哪个成员变的)。因此:
- 结构体别塞太多成员。一个含 20 个字段的「角色面板结构体」会制造可观的带宽浪费。
- 要细粒度,拆成多个独立属性或多个小结构体。
- 想知道哪个变了:OnRep 带旧值参数,自己 diff 新旧结构体。
数组:也按整体复制
UPROPERTY(Replicated)
TArray<FItem> Inventory; // 物品列表
默认复制语义:整个数组重新发送。数组每次变化(加一个、删一个、改一个元素),客户端都会收到完整的新数组。对几十个元素的装备列表尚可接受,对上千个槽位、每帧都在变的列表就是灾难(每帧发整表)。
结构体数组的三难
游戏开发最常见的容器是 TArray<FMyStruct>,它同时暴露三个问题:
- 无增量:加一个物品 = 重发整数组。
- 无元素回调:客户端不知道「是哪个槽位变了」,只能整体重建 UI。
- 顺序即意义:客户端按数组索引绑定 UI 槽位,如果服务器端中途 RemoveAt 中间元素,客户端 UI 上槽位内容会整体错位。
对应解法:
| 问题 | 解法 |
|---|---|
| 无增量 | FastArray 复制(2.4 节) |
| 无元素回调 | FastArray 的 ItemRepNotify / 或旧值 diff |
| 槽位错位 | 用稳定 ID(GUID/UniqueId)而非数组下标做槽位绑定 |
Map / Set 的复制
- TMap 支持 SetMap」之外,原生 Map 复制在 UE5 上已有支持但仍需注意:总体也是整体复制,性能特性与数组类似。
- TSet:复制行为类似,注意 Set 无序,客户端不能假设遍历顺序稳定。
指针/引用的复制
普通 UPROPERTY 指针(AActor* Target)复制的是引用路径:复制系统通过「软引用序列化」在客户端找到对应的复制 Actor 并自动赋值。前提是:被引用的 Actor 本身是复制 Actor,且对当前客户端相关。否则客户端拿到的是 nullptr——这是「为什么客户端上目标指针是空的」最常见原因。客户端拿不到引用时的常见补救:
- 改用 FNetworkObjectPtr / 弱引用 + 手动 Find
- 用 GUID / 唯一 ID(Replicated 的 FString 或 FName)在客户端查找
- 确保被引用 Actor 对该客户端相关再访问
字符串
FName / FString / FText 都可复制,注意带宽:FString 每字符占 1 字节(UTF-8 编码)但中文等宽字符更占,FName 发送的是 NameIndex-较小,FText 复制的是 TextKey。聊天系统里长 FString 高频复制是带宽大户,注意节流。
心得:容器复制的总原则——「结构体越小越好,数组越静态越好,变化越频繁越要上 FastArray」。我们的背包系统最初用 TArray<FItem> 整表复制,后期换成 FastArray 后,拾取物品时的同步包从几百字节降到 20 字节级别。
