Unity NGO 如何同步包含结构体数组的 NetworkList 元素?
可以保留 NetworkList<ContainerStruct>,把其中的 SimpleDataStruct[] 换成容量固定的 FixedList,再手动序列化长度和有效元素。如果单位条目需要持续增长,可以拆成两个扁平的 NetworkList,用军队 ID 关联。
为什么实现 INetworkSerializable 仍然无法编译?
NetworkList<T> 对元素类型有以下约束:
where T : unmanaged, IEquatable<T>
ContainerStruct 虽然是 struct,但其中的 SimpleDataStruct[] 是托管引用,因此整个结构体不满足 unmanaged 约束。即使数组不为 null,或者数组元素全部由值类型字段组成,也不会改变这一点。NetworkList API 文档列出了这一约束。
INetworkSerializable 定义数据如何写入和读出网络缓冲区,不能改变 C# 类型本身是否包含托管引用。添加 [System.Serializable]、把数组包装进另一个 struct,或换成 List<SimpleDataStruct>,都不能解决这个编译错误。
方案一:使用 FixedList 保存有限数量的单位条目
如果每支军队的单位条目有明确上限,可以使用 Unity.Collections.FixedList512Bytes<SimpleDataStruct>。它将数据直接保存在结构体内部,并要求元素类型为 unmanaged。FixedList512Bytes 文档说明了它的存储方式和容量限制。
其中的 512 表示整个容器占用的字节数,不是可保存的元素数量。实际可容纳的条目数应读取 Capacity。
下面的实现保留字段名 array,将其类型替换为 FixedList512Bytes<SimpleDataStruct>。示例假定项目已安装提供该类型的 Unity Collections 包,未在问题所述环境中实际编译运行。
using System;
using Unity.Collections;
using Unity.Netcode;
[Serializable]
public struct SimpleDataStruct :
INetworkSerializable,
IEquatable<SimpleDataStruct>
{
public FixedString32Bytes id;
public int amount;
public bool Equals(SimpleDataStruct other)
{
return id.Equals(other.id) && amount == other.amount;
}
public void NetworkSerialize<T>(BufferSerializer<T> serializer)
where T : IReaderWriter
{
serializer.SerializeValue(ref id);
serializer.SerializeValue(ref amount);
}
}
[Serializable]
public struct ContainerStruct :
INetworkSerializable,
IEquatable<ContainerStruct>
{
public ulong ownerID;
public FixedList512Bytes<SimpleDataStruct> array;
public int moreData;
public bool Equals(ContainerStruct other)
{
if (ownerID != other.ownerID ||
moreData != other.moreData ||
array.Length != other.array.Length)
{
return false;
}
for (int i = 0; i < array.Length; i++)
{
if (!array[i].Equals(other.array[i]))
{
return false;
}
}
return true;
}
public void NetworkSerialize<T>(BufferSerializer<T> serializer)
where T : IReaderWriter
{
serializer.SerializeValue(ref ownerID);
int count = array.Length;
serializer.SerializeValue(ref count);
if (count < 0 || count > array.Capacity)
{
throw new InvalidOperationException(
"Invalid unit count for FixedList capacity.");
}
if (serializer.IsReader)
{
array.Clear();
for (int i = 0; i < count; i++)
{
SimpleDataStruct item = default;
serializer.SerializeValue(ref item);
array.Add(item);
}
}
else
{
for (int i = 0; i < count; i++)
{
SimpleDataStruct item = array[i];
serializer.SerializeValue(ref item);
}
}
serializer.SerializeValue(ref moreData);
}
}
序列化单位条目时,先发送元素数量,再发送有效元素;接收时校验数量,清空旧数据并重建集合。这样就不依赖 SerializeValue(ref array) 是否提供对应的 FixedList 重载。
这里的 Equals 按元素顺序比较全部同步字段。如果业务认为单位条目的顺序无关,需要在写入前统一排序,或另行定义符合业务语义的比较规则。
修改嵌套元素后,要写回 NetworkList
结构体按值复制。从 NetworkList 取出的 ContainerStruct 是一个副本,其中的 FixedList 也会随之复制。修改后,需要通过列表索引器写回。
下面保留原问题中的 Everyone 读取权限和 Owner 写入权限,并在 Awake 中初始化列表。Unity 的网络变量使用文档也采用这种 NetworkList 初始化方式。
using System;
using Unity.Netcode;
public class PlayerArmyManager : NetworkBehaviour
{
private NetworkList<ContainerStruct> myContainers;
private void Awake()
{
myContainers = new NetworkList<ContainerStruct>(
null,
NetworkVariableReadPermission.Everyone,
NetworkVariableWritePermission.Owner);
}
public void SetAmount(int containerIndex, int unitIndex, int amount)
{
if (!IsSpawned || !IsOwner)
{
return;
}
if (containerIndex < 0 || containerIndex >= myContainers.Count)
{
throw new ArgumentOutOfRangeException(nameof(containerIndex));
}
ContainerStruct container = myContainers[containerIndex];
if (unitIndex < 0 || unitIndex >= container.array.Length)
{
throw new ArgumentOutOfRangeException(nameof(unitIndex));
}
SimpleDataStruct unit = container.array[unitIndex];
unit.amount = amount;
container.array[unitIndex] = unit;
myContainers[containerIndex] = container;
}
}
新增单位条目时也按相同方式操作:取出军队副本,检查 array.Length < array.Capacity,调用 array.Add(...),然后写回 myContainers[containerIndex]。新增军队则使用 myContainers.Add(...)。
Owner 指承载这个 NetworkBehaviour 的 NetworkObject 的拥有者。ContainerStruct.ownerID 只是普通数据字段,给它赋值不会改变 NGO 的写入权限。如果改由服务器维护军队数据,需要同时调整写入权限和调用端的权限判断。
方案二:将军队和单位条目拆成两个 NetworkList
如果单位类型数量没有合适的固定上限,或者单个条目修改很频繁,可以将同步结构拆开:
- 军队列表保存
containerID、ownerID、moreData。 - 单位列表保存
containerID、单位类型id、数量amount。
两个列表的元素都只使用满足 unmanaged 约束的字段,并分别实现 INetworkSerializable 和 IEquatable<T>。客户端根据 containerID 重建本地的军队集合。
需要新增独立的 containerID,因为同一个玩家可以拥有多支军队,ownerID 无法唯一标识军队。也不宜使用列表下标作为长期关联键,因为删除或重排条目会改变下标。
采用这个方案时,删除军队需要同时清理对应的单位条目。两个列表的更新也不能视为一个跨列表的原子操作,因此客户端展示层应能处理军队和单位条目暂时未配齐的状态。
选择方案时还要考虑哪些限制?
FixedList 方案改动较小,适合规模有限的嵌套数据。但按上面的序列化实现,修改一个单位数量后,写回的是整个 ContainerStruct,该军队的有效单位条目都会参与序列化,不会只发送那个 amount 字段。
扁平列表更适合按单位条目更新,代价是需要维护关联关系和清理逻辑。它仍然受网络消息大小和带宽限制,不能当作无限容量的同步存储。
也不要仅因为 NativeArray 或 NativeList 是结构体,就直接替换原数组并按内存复制发送。它们涉及独立的原生内存、分配和释放,必须另外处理序列化与生命周期。
对当前需求,如果每支军队的条目上限可控,可以先采用 FixedList;如果集合需要较大幅度增长,选择扁平列表会更容易控制每次同步的数据量。
备注:内容仅供参考。
