Unity网络编程——高效读写数据

NetworkWriter/NetworkReader高效读写数据&池化复用

Pool<T>

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
public class Pool<T>
{
// 单线程不用考虑并发集合
// 栈可以提升可复用writer留存在缓存的几率(栈顶元素是最近使用的元素,更有可能留存在缓存中)
readonly Stack<T> objects = new Stack<T>();

// 可以视为工厂方法,创建新的T实例
readonly Func<T> objectGenerator;

public Pool(Func<T> objectGenerator, int initialCapacity)
{
this.objectGenerator = objectGenerator;

// 预先填充对象池,保证首次使用时池中就有对象,避免延迟
for(int i = 0; i < initialCapacity; i++)
{
objects.Push(objectGenerator());
}
}

// 从池子中取出一个可用元素,如果没有就新创建一个
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public T Get() => objects.Count > 0 ? objects.Pop() : objectGenerator();

// 元素重新入池
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public void Return(T item) => objects.Push(item);

public int Count => objects.Count;
}

NetworkWriter

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
    public class NetworkWriter
{
// 发送字符串时,先发送一个ushort表示字符串的长度,然后发送具体字符串
// 留存ushort.MaxValue这个值表示字符串不存在或为空
public const ushort MaxStringLength = ushort.MaxValue - 1;

// 立即创建一个Writer,分配自己的缓冲区确保不会被修改,并且可以根据需求调整大小
// 默认容量设置为1500,大多数数据包<=MTU
public const int DefaultCapacity = 1500;
private byte[] buffer = new byte[DefaultCapacity];

// 数据写入缓冲区的标志位
public int Position;

// 当前容量,根据需求自动扩容
public int Capacity => buffer.Length;

// 缓存encoding对象,而不是每次Write时创建,减少垃圾回收导致的开销
// 非静态字段是为了保证线程安全,保证每个实例有自己的encoding对象
// 编码过程中遇到无效Unicode字符抛出异常
private readonly UTF8Encoding encoding = new UTF8Encoding(false, true);

/// <summary>
/// 重置数据流长度和位置
/// 保持容量不变,重复使用Writer而不用重新分配
/// </summary>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public void Reset()
{
Position = 0;
}

/// <summary>
/// 动态调整buffer容量
/// 运行过程中调整容量大小没有额外成本,因为检查是否有足够空间这一步骤即使是固定大小的buffer也是需要的
/// 每次取value和两倍当前容量的最大值,随着数据增长缓冲区将会增长到一个稳定足够大的状态,就不会频繁调整了
/// </summary>
/// <param name="value"></param>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private void EnsureCapacity(int value)
{
if(buffer.Length < value)
{
int capacity = Mathf.Max(value, buffer.Length * 2);
Array.Resize(ref buffer, capacity);
}
}

/// <summary>
/// 将缓冲区Position长度的内容复制到新数组中
/// 尽可能使用ArraySegment避免内存分配
/// </summary>
/// <returns></returns>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public byte[] ToArray()
{
byte[] data = new byte[Position];
Array.ConstrainedCopy(buffer, 0, data, 0, Position);
return data;
}

/// <summary>
/// 将缓冲区Position长度的内容复制到新数组中
/// </summary>
/// <returns></returns>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public ArraySegment<byte> ToArraySegment() => new ArraySegment<byte>(buffer, 0, Position);

/// <summary>
/// 定义隐式转换操作符
/// </summary>
/// <param name="w"></param>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static implicit operator ArraySegment<byte>(NetworkWriter w) => w.ToArraySegment();

/// <summary>
/// 写入Blittable类型(在托管代码c#和非托管代码c/c++之间可以直接共享内存布局的数据类型) T约束为非托管类型
/// </summary>
/// <typeparam name="T"></typeparam>
/// <param name="value"></param>
internal unsafe void WriteBlittable<T>(T value) where T : unmanaged
{
#if UNITY_EDITOR
// 编辑器环境下需要检查T是否为blittable类型(即使已经约束T的类型)
if (!UnsafeUtility.IsBlittable(typeof(T)))
{
Debug.LogError($"{typeof(T)} is not blittable!");
return;
}
#endif
// T是blittable类型,编译时就可以获得其大小,比在运行时使用Marshal.SizeOf<T>要快得多
int size = sizeof(T);

EnsureCapacity(Position + size);

// fixed关键字固定ptr变量,使其指向的内存地址在GC期间不会移动
fixed(byte* ptr = &buffer[Position])
{
#if UNITY_ANDROID
// 安卓平台如果指针未对齐直接赋值会抛出空引用异常
// 因此首先使用stackalloc分配一个T类型的数组再进行内存复制
T* valueBuffer = stackalloc T[1]{value};
UnsafeUtility.MemCpy(ptr, valueBuffer, size);
#else
// 非安卓平台直接将值赋给指针所指向的内存区域
*(T*)ptr = value;
#endif
}
// 写入后更新缓冲区位置
Position += size;
}

/// <summary>
/// 写入可空Blittable类型
/// </summary>
/// <typeparam name="T"></typeparam>
/// <param name="value"></param>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
internal void WriteBlittableNullable<T>(T? value) where T : unmanaged
{
// 写入一个字节0x01表示存在,0x00表示不存在
WriteByte((byte)(value.HasValue ? 0x01 : 0x00));
// 如果值存在,调用WriteBlittable写入实际值
if (value.HasValue) WriteBlittable(value.Value);
}

public void WriteByte(byte value) => WriteBlittable(value);

public void WriteBytes(byte[] array, int offset, int count)
{
EnsureCapacity(Position + count);
Array.ConstrainedCopy(array, offset, this.buffer, Position, count);
Position += count;
}

public unsafe bool WriteBytes(byte* ptr, int offset, int size)
{
EnsureCapacity(Position + size);

fixed(byte* destination = &buffer[Position])
{
UnsafeUtility.MemCpy(destination, ptr + offset, size);
}

Position += size;
return true;
}

public override string ToString()
{
// $"[{ToArraySegment().ToHexString()} @ {Position}/{Capacity}]";
return base.ToString();
}

}
  • 安卓平台一些处理器(尤其RISC架构,例如ARM)对未对齐的内存访问有严格限制,未对齐访问可能会导致硬件异常,进而在操作系统层面抛出异常(空引用异常)。所以使用 stackalloc 关键字直接在栈上分配内存且满足平台对齐要求
  • Blittable读写性能相当高,有以下原因:
    • 内存中布局连续
    • 省去额外的序列化和反序列化操作
    • 避免了拆装箱开销
    • 内存复制使用 memcpy 直接复制内存块内容

NetworkWriterPool

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public static class NetworkWriterPool
{
// 创建writer对象池,给定初始容量
static readonly Pool<NetworkWriterPooled> Pool = new Pool<NetworkWriterPooled>(() => new NetworkWriterPooled(), 1000);

// 从池子中取出一个可用writer,如果没有就新创建一个
public static NetworkWriterPooled Get()
{
NetworkWriterPooled writer = Pool.Get();
writer.Reset();
return writer;
}

// writer重新入池
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static void Return(NetworkWriterPooled writer)
{
Pool.Return(writer);
}
}

NetworkWriterPooled

1
2
3
4
5
public class NetworkWriterPooled : NetworkWriter, IDisposable
{
// 在using语句块中使用,自动调用Dispose,保证重新入池
public void Dispose() => NetworkWriterPool.Return(this);
}
  • 实现 IDisposable 接口,调用Dispose方法时保证当前对象重新入池

NetworkWriterExtensions

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
public static class NetworkWriterExtensions
{
public static void WriteByte(this NetworkWriter writer, byte value) => writer.WriteBlittable(value);
public static void WriteByteNullable(this NetworkWriter writer, byte? value) => writer.WriteBlittableNullable(value);

public static void WriteChar(this NetworkWriter writer, char value) => writer.WriteBlittable((ushort)value);
public static void WriteCharNullable(this NetworkWriter writer, char? value) => writer.WriteBlittableNullable((ushort?)value);

public static void WriteBool(this NetworkWriter writer, bool value) => writer.WriteBlittable((byte)(value ? 1 : 0));
public static void WriteBoolNullable(this NetworkWriter writer, bool? value) => writer.WriteBlittableNullable(value.HasValue ? ((byte)(value.Value ? 1 : 0)) : new byte?());

public static void WriteString(this NetworkWriter writer, string value)
{
// 如果字符串为空直接写入0
if (value == null)
{
writer.WriteUShort(0);
return;
}

// 计算转换后的最大字节数,如果容量不足先扩容
int maxSize = writer.encoding.GetMaxByteCount(value.Length);
writer.EnsureCapacity(writer.Position + 2 + maxSize); // 2 bytes position + N bytes encoding

// 这里GetBytes返回值是实际写入buffer的字节数,偏移量2是留给字符串长度的
int written = writer.encoding.GetBytes(value, 0, value.Length, writer.buffer, writer.Position + 2);

// 判断长度是否超过限制
if (written > NetworkWriter.MaxStringLength)
throw new IndexOutOfRangeException($"NetworkWriter.WriteString - Value too long: {written} bytes. Limit: {NetworkWriter.MaxStringLength} bytes");

// 字符串长度用ushort类型写入(已经保证written<ushort.max)
// checked关键字保证运算溢出时抛出OverflowException
writer.WriteUShort(checked((ushort)(written + 1))); // Position += 2

// 更新writer位置指针
writer.Position += written;
}


public static void WriteRect(this NetworkWriter writer, Rect value)
{
writer.WriteVector2(value.position);
writer.WriteVector2(value.size);
}
public static void WriteRectNullable(this NetworkWriter writer, Rect? value)
{
writer.WriteBool(value.HasValue);
if (value.HasValue)
writer.WriteRect(value.Value);
}
}
  • 针对不同类型写拓展方法,blittable类型的就不写了,给出一些特殊处理的类型
    • char类型 可以一一映射到 ushort 类型
    • bool类型 用一个字节 0|1 表示
    • string类型 写字符串长度和转换后的字节数组
    • Rect类型 拆解成基础类型Vector2写入(其余特殊类型类似,都是拆解成可以直接写入的类型表示)

NetworkReader

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
    public class NetworkReader
{
private ArraySegment<byte> buffer;

// 从缓冲区读取的下一个位置
// 这里用int是最好的,如果使用short类型,发送的数据>32kb会导致溢出
// 同时数据<2GB前将long转化为int也是没有问题的,当然数据量不会这么大
public int Position;

// 剩余可读的字节
public int Remaining => buffer.Count - Position;

// 缓冲区总容量,与Position无关
public int Capacity => buffer.Count;

// 缓存encoding对象,而不是每次Read时创建,减少垃圾回收导致的开销
// 非静态字段是为了保证线程安全,保证每个实例有自己的encoding对象
// 编码过程中遇到无效Unicode字符抛出异常
private readonly UTF8Encoding encoding = new UTF8Encoding(false, true);

// 鼓励复用已分配的ReadArraySegment避免频繁的内存分配和GC
// 做限制的目的:
// -> 防止服务器在移动设备上意外分配2GB内存
// -> 防止客户端使用[SyncVar]在服务器分配2GB内存
// 限制可能根据集合中的元素T的大小而改变,所以这里是一个"集合长度"限制
public const int AllocationLimit = 1024 * 1024 * 16; // 16MB * sizeof(T)

// NetworkWriter有隐式转化,所以可以直接:
// NetworkReader reader = new NetworkReader(writer);
public NetworkReader(ArraySegment<byte> segment)
{
buffer = segment;
}

#if !UNITY_2021_3_OR_NEWER
// 低版本没有byte[]到arraySegment的隐式转换
public NetworkReader(byte[] bytes)
{
buffer = new ArraySegment<byte>(bytes, 0, bytes.Length);
}
#endif

// 指向另一个缓冲区而不是分配新的
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public void SetBuffer(ArraySegment<byte> segment)
{
buffer = segment;
Position = 0;
}

#if !UNITY_2021_3_OR_NEWER
public void SetBuffer(byte[] bytes)
{
buffer = new ArraySegment<byte>(bytes, 0, bytes.Length);
Position = 0;
}
#endif

// 读取Blittable类型
internal unsafe T ReadBlittable<T>() where T : unmanaged
{
#if UNITY_EDITOR
if (!UnsafeUtility.IsBlittable(typeof(T)))
{
throw new ArgumentException($"{typeof(T)} is not blittable!");
}
#endif
// T是blittable类型,编译时就可以获得其大小,比在运行时使用Marshal.SizeOf<T>要快得多
int size = sizeof(T);

// 保证有足够剩余可读内容
if(Remaining < size)
{
throw new EndOfStreamException($"ReadBlittable<{typeof(T)}> not enough data in buffer to read {size} bytes: {ToString()}");
}

T value;
fixed(byte* ptr = &buffer.Array[buffer.Offset + Position])
{
#if UNITY_ANDROID
T* valueBuffer = stackalloc T[1];
UnsafeUtility.MemCpy(valueBuffer, ptr, size);
value = valueBuffer[0];
#else
value = *(T*)ptr;
#endif
}
Position += size;
return value;
}

[MethodImpl(MethodImplOptions.AggressiveInlining)]
internal T? ReadBlittableNullable<T>() where T : unmanaged => ReadByte() != 0 ? ReadBlittable<T>() : default(T?);

public byte ReadByte() => ReadBlittable<byte>();

// 将count字节的内容读入数组
public byte[] ReadBytes(byte[] bytes, int count)
{
// 可以通过 ReadBytes(ReadInt()) 确保计数正确
if (count < 0) throw new ArgumentOutOfRangeException("ReadBytes requires count >= 0");

// 需要检查传入的字节数组是否足够大
if (count > bytes.Length)
{
throw new EndOfStreamException($"ReadBytes can't read {count} + bytes because the passed byte[] only has length {bytes.Length}");
}

// 保证有足够剩余可读内容
if (Remaining < count)
{
throw new EndOfStreamException($"ReadBytesSegment can't read {count} bytes because it would read past the end of the stream. {ToString()}");
}

Array.Copy(buffer.Array, buffer.Offset + Position, bytes, 0, count);
Position += count;
return bytes;
}

// 以指向内部数组的ArraySegment形式读取count字节,不进行分配
public ArraySegment<byte> ReadBytesSegment(int count)
{
if (count < 0) throw new ArgumentOutOfRangeException("ReadBytesSegment requires count >= 0");

if (Remaining < count)
{
throw new EndOfStreamException($"ReadBytesSegment can't read {count} bytes because it would read past the end of the stream. {ToString()}");
}

ArraySegment<byte> result = new ArraySegment<byte>(buffer.Array, buffer.Offset + Position, count);
Position += count;
return result;
}

public override string ToString()
{
// $"[{buffer.ToHexString()} @ {Position}/{Capacity}]";
return base.ToString();
}
}
  • 鼓励使用ArraySegment<byte>而不是直接使用byte[]
    • 减少内存复制,ArraySegment直接引用原始数组的一个片段,不进行数据复制,避免不必要的内存分配提高性能
    • ArraySegment实际是一个结构体,通常分配成本和内存占用更低

NetworkReaderPool

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
public static class NetworkReaderPool
{
// 创建reader对象池,给定初始容量
static readonly Pool<NetworkReaderPooled> Pool = new Pool<NetworkReaderPooled>(() => new NetworkReaderPooled(new byte[] { }), 1000);

// 从池子中取出一个可用reader,如果没有就新创建一个
public static NetworkReaderPooled Get(byte[] bytes)
{
NetworkReaderPooled reader = Pool.Get();
reader.SetBuffer(bytes);
return reader;
}

// 从池子中取出一个可用reader,如果没有就新创建一个
public static NetworkReaderPooled Get(ArraySegment<byte> segment)
{
NetworkReaderPooled reader = Pool.Get();
reader.SetBuffer(segment);
return reader;
}

// reader重新入池
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static void Return(NetworkReaderPooled reader)
{
Pool.Return(reader);
}
}

NetworkReaderPooled

1
2
3
4
5
6
7
8
public class NetworkReaderPooled : NetworkReader,IDisposable
{
// 保证构造函数参数匹配
internal NetworkReaderPooled(byte[] bytes) : base(bytes) { }
internal NetworkReaderPooled(ArraySegment<byte> segment) : base(segment) { }
// 在using语句块中使用,自动调用Dispose,保证重新入池
public void Dispose() => NetworkReaderPool.Return(this);
}

NetworkReaderExtensions

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
public static class NetworkReaderExtensions
{
public static byte ReadByte(this NetworkReader reader) => reader.ReadBlittable<byte>();
public static byte? ReadByteNullable(this NetworkReader reader) => reader.ReadBlittableNullable<byte>();

public static char ReadChar(this NetworkReader reader) => (char)reader.ReadBlittable<ushort>();
public static char? ReadCharNullable(this NetworkReader reader) => (char?)reader.ReadBlittableNullable<ushort>();

public static bool ReadBool(this NetworkReader reader) => reader.ReadBlittable<byte>() != 0;
public static bool? ReadBoolNullable(this NetworkReader reader)
{
byte? value = reader.ReadBlittableNullable<byte>();
return value.HasValue ? (value.Value != 0) : default(bool?);
}

public static string ReadString(this NetworkReader reader)
{
// 先读长度
ushort size = reader.ReadUShort();

// 判断是否null
if (size == 0)
return null;

// 减一拿真实长度
ushort realSize = (ushort)(size - 1);

if (realSize > NetworkWriter.MaxStringLength)
throw new EndOfStreamException($"NetworkReader.ReadString - Value too long: {realSize} bytes. Limit is: {NetworkWriter.MaxStringLength} bytes");

ArraySegment<byte> data = reader.ReadBytesSegment(realSize);

return reader.encoding.GetString(data.Array, data.Offset, data.Count);
}
}

Unity网络编程——高效读写数据
https://baifabaiquan.cn/2024/07/12/Unity网络编程高效数据读写/
作者
白发败犬
发布于
2024年7月12日
许可协议