PAYATHON 2026

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;如果集合需要较大幅度增长,选择扁平列表会更容易控制每次同步的数据量。

备注:内容仅供参考。