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>,它同时暴露三个问题:

  1. 无增量:加一个物品 = 重发整数组。
  2. 无元素回调:客户端不知道「是哪个槽位变了」,只能整体重建 UI。
  3. 顺序即意义:客户端按数组索引绑定 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 字节级别。