游戏网络通信浅析
游戏网络通信浅析
服务器三问
我们都知道现在游戏可以分为单机游戏和网络游戏,简单区别就是一个独自快乐,一个是多人快乐。
什么是服务器
简单地说就是一台电脑。在那台电脑上运行一些特定程序,这些程序主要的功能:
- 游戏和玩家的数据存储
- 玩家交互数据进行广播和同步
- 重要逻辑要在服务器上运算,做好验证,防止外挂
为啥要服务器
因为玩家不想让他很牛逼这个事情,只有他知道,他想告诉全世界。当然对开发者来说,是一个比较简单防破解的方式。
怎么用服务器
我们从编程的角度来看看单机游戏和网络游戏的区别:
单机游戏设计思路如下图

在游戏开始时,需要加载一些资源,这些资源包括地图信息、模型、配置表等。加载完成之后,选择角色进入有即可。
从编码的角度来说,游戏一旦开始,运行的就是一个无限循环,就像是Unity中Monobehavior的Update,我们可以认为它是一个 while(true){...}
,除非离开游戏,否则这个循环会一直执行下去。循环的每一次执行称为一帧。在一帧中包括三个主要操作:
- 检测玩家输入
- 根据输入更新内存数据,刷新场景、人物模型、界面等。
- 捕捉退出请求,如果有退出请求,这个循环就会被打破,游戏结束。
理论上来讲,一秒能产生的循环数越大,程序的反应就越灵敏,玩家看到就是越流畅。
网络游戏设计思路如下图

网络游戏和单机游戏一样也有一个循环,只是多了一个网络层的处理。简单的理解就是把以前一个人完成的任务,分成两个人来做。因为是两个人做事,所以就会出现异步这个概念。也就是一个人需要另一个人的支持,所以就会出现一个人等待另一个人的支持。
我们以登录为例来展示一下具体不同之处,登录流程:

在登录界面输入账号和密码之后,要经历一个异步的操作,客户端向服务端发送协议,等待服务端返回数据,由此来判断登录成功或者失败。网络游戏yum的哭护短发出命令,只有等到服务端给它结果,它才会做出反应。客户端向服务端请求账号验证,这个请求数据不是一个函数可以完成的,这就是一个异步的过程。
网络基础

我们可以把网络使用不同的数据模型分层,我通常使用五层协议来对网络理解。
数据流程
这里我们使用发一个微信消息给好友来简单解释一下数据的流向。
使用
我们游戏服务器与客户端使用到的就是运输层中的TCP与UPD协议,我们一般使用套接字(socket)创建
我们可以使用套接字(Socket)来对不同主机上的应用进程进行通信。一个套接字就是网络上进程通信的一端,提供了应用层进程利用网络协议交换数据的机制。从所处的地位来讲,套接字上联应用进程,下联网络协议栈,是应用程序通过网络协议进行通信的接口,是应用程序与网络协议根进行交互的接口。
Api:
- 连接 socket.Connect
- 发送 socket.Send
- 接收 socket.Receive
- 关闭 socket.Close
当收到对端(服务器或客户端)数据时,操作系统会将数据存入到Socket的接收缓冲区中,接收缓冲区存有数据的TCP Socket示意图如下:

上图可以看到,接收缓冲区有4个字节数据,分别是1、2、3、4。操作系统层面上的缓冲区完全由操作系统操作,程序不能直接操作它们,只能通过socket.Receive、socket.Send等方法来间接操作。
Socket的Receive方法只是把接收缓冲区的数据提取出来,比如调用 Receive(readBuff,0,2)
,接收2个字节的数据到readbuff。上图例子中,调用后操作系统接收缓冲区只剩下了2个字节数据,用户缓冲区readBuff保存了接收到的2字节数据,形成下图:

当系统的接收缓冲区为空,Receive方法会被阻塞,知道里面有数据。
同样地,Socket的Send方法只是把数据写入到发送缓冲区里,具体的发送过程由操作系统负责。当操作系统的发送缓冲区满了,Send方法将会阻塞。
网络模型
我们知道可以使用计算机网络中的Socket来传输信息。那么我们是以什么角色来使用这个工具呢?这里就引出我们常说几个名词:
- 客户端
- 服务器
我们使用排列组合,就可以列出两种常见的网络模型
客户端-服务器
我们从名字(Client-Server)可以看出这个模型中是有两个不同的角色。这种网络模型也是最常用的。我们看看他们各自的定义:
- 服务器:一个具有高性能可以存储数据和信息的机器
- 客户端:一个让用户可以访问远程数据的机器

系统管理员在服务器上管理数据。客户端与服务器通过网络连接。客户端可以访问数据,即使客户端机器离服务器机器很远在物理距离上。
客户端向服务器发送请求,服务器接收到请求后,处理数据,再向客户端回复信息。
所有的服务都是由中心服务器提供,所以这个模型会出现瓶颈的问题。
点对点
不同于客户端-服务器模型,在点对点(Peer to Peer — P2P)并没有特别定义哪个是服务器那个是客户端。每一个节点都可以作为客户端和服务器。

在P2P网络环境中,彼此连接的多台计算机之间都处于对等的地位,各台计算机有相同的功能,无主从之分,一台计算既可作为服务器,设定共享资源供网络中其他计算机所使用,又可以作为工作站,整个网络一般来说不依赖专用的集中服务器,也没有专用的工作站。网络中的每一台计算机既能充当网络服务的请求者,又对其他计算机的请求做出响应,提供资源、服务和内容。通常这些资源和服务包括:信息的共享和交换、计算资源(如CPU计算能力共享)、存储共享(如缓存和磁盘空间的使用)、网络共享、打印机共享等。
区别
内容 | 服务器 - 客户端 | 点对点 |
---|---|---|
基础 | 有一个服务器与多个连接到服务器的客户端 | 没有定义服务器与客户端,每一个节点都可以是客户端或服务器 |
服务 | 客户端发出请求,服务器响应请求 | 每个节点都可以发出请求也可以提供服务 |
聚焦 | 共享信息 | 连接 |
数据 | 数据存在中心服务器 | 没有端都有自己的数据 |
服务器 | 当多个客户端向一个服务器发出请求,这个服务器会出现瓶颈 | 由于服务是由分布在对等系统中的多个服务器提供的,因此服务器不会出现瓶颈 |
价格 | 贵 | 不是那么贵 |
稳定性 | 很稳 | 节点越多稳定减少 |
同步理论
在客户端——服务端架构中,无论是用什么样的同步方法,都始终遵循着下图所示的过程:

客户端1向服务端发送一条消息,服务端收到后稍作处理,把它广播给所需的客户端(客户端1、客户端2和客户端3)。
所传递的消息可以是角色的位置、旋转这样的状态值,也可以是“向前走”这样的指令值。前者称之为状态同步,后者称之为指令同步。
同步的难题
由于存在网络延迟和抖动,往往很难做到精确的同步。
例如:下图左边展示的是理想的网络情况,服务端定时发送消息给客户端,客户端立刻就能够收到。

而实际的网络情况并非如此,更像右边的情况,这里出现了两个问题:
- 消息的传播需要时间,会有延迟
- 消息的倒带时间并不稳定,有时候两条消息会像个较长时间,有时候却相隔很短
传播时间长了,也就是我们常说的延迟高了,就会出现卡顿的现象。
看一个实例:
玩家1控制的坦克从A点走向C点,玩家2看到的坦克总是延迟了一小段时间,所以玩家1和玩家2看到的战场不会完全一致。

网络颜值问题基本无解,只能权衡。比如:
- 尽量发送更少的数据,数据越少,发生数据丢失并重传的概率就越小,平均速度越快。
- 在客户端上做些“障眼法”,让玩家感受不到延迟。
状态同步
状态同步是同步状态信息。如在坦克游戏中,客户端把坦克的位置坐标、旋转发给服务端,服务端再做广播。
我们都以坦克的位置同步来讲解几种不同的状态同步方式。前提条件都为:
玩家1位发送位置信息的一方,玩家2为同步方,网络延迟为250毫秒。
直接状态同步
是什么
最直接的同步方案就是客户端定时向服务器报告位置,其他玩家收到转发的消息后,直接将对方坦克移动到指定位置。
分析
当玩家1在经过B点时发送同步信息,经过一定的网络延迟,当玩家1的坦克走到C点时,玩家B才收到消息。这时两个客户端的误差为“速度*延迟”。

如果网络延迟是固定的,那么两个客户端虽然有着不同的状态,但是最终结果会是一样的。实际上网络是波动的,假设玩家1到C点时又发送了位置信息,这时没有网络延迟,玩家2看到的同步坦克瞬移的,从B直接跳到C,很不自然。所以我们一般不这样同步位置。
跟随算法
是什么
为了解决“直接状态同步”的瞬移问题,人们引入了一种障眼法————“跟随算法”。在收到同步消息后,客户端不直接将坦克到目的地,而是让坦克以一定的速度移动。
分析
玩家1经过B点发送同步信息,玩家2收到后,将坦克以同样的速度从A点移动到B点。此种情况下,误差更大了,因为在玩家1从B点移动到C点的过程中,玩家2看到的坦克才从A点移向B点。

然而很多时候,游戏并不需要非常精确的同步,只要同步频率足够高(玩家每1秒发送位置的次数,比如每秒发送30次),误差就可以忽略
预测算法
是什么
跟随算法的一大缺陷就是误差会变得很大,在某些有规律可循的条件下,比如坦克匀速运动,或者匀加速运动,我们能够预测坦克在接下来某个时间点的位置,让坦克提前走到预测的位置上去。这就是预测算法。
分析
假设坦克匀速前进。玩家1经过B点时发送位置信息,玩家2根据 “距离=速度*时间” 可以计算出下一次收到同步信息时,坦克应移动到C点。于是玩家2让同步坦克移向C点,玩家1和玩家2之间的误差会很小。

然而玩家1操控的坦克不可能一直保持匀速。当玩家1突然停下,玩家2看到的坦克会向前移动一段距离,又再向后移动一段距离。

跟随算法和预测算法各有优缺点,具体使用哪种算法,应当视项目需求而定。
帧同步
帧同步是指令同步的一种,即同步操作信息。基本上所有指令同步方法都结合了帧同步,两者可以视为一体。这里 “帧” 的概念与Unity中 “每一帧执行一次Update” “30FPS(每秒传输帧数,Frames Per Second)” 里的 “Unity帧” 有所不同,我们会实现独立于 “Unity帧” 的另外一种 “同步帧”。
指令同步
是什么
状态同步所同步的是状态消息,如果要同步坦克的位置和旋转,那就需要同步六个值(三个坐标值和三个旋转值)。上面提到,缓解网络延迟的一个办法是减少传输的数据量,如果只传输玩家的操作指令,数据量就会减少很多。
分析

当玩家1要移动坦克,按下键盘上的 “上” 键时,玩家1会发送 “往前走” 的消息给服务端,经由转发,玩家2收到后,让同步坦克向前移动。当玩家1要定制移动坦克,会放开按键,发送 “停止” 指令,玩家2收到后,让坦克停止移动。
缺点
上诉过程的一大缺点是误差的累积。有些电脑速度快,有些电脑速度慢,尽管玩家2收到了玩家1的指令,但只要两者的电脑运行速度不同,可能有人看到坦克走了很远,有人看到的却只移动了一点点的距离。为了解决这个问题,人们操作同步的基础上,引入了 “同步帧” 的概念。
从Update说起
如果有一种办法,让不同的电脑有一致的运行效果,便可以解决指令同步中的误差累积问题。
我们看看坦克移动的代码 :
public void MoveUpdate(){ //在Unity的Update中调用
……
float y = Input.GetAxis("Vertical"); // 如果是 SyncTank ,改为由网络传播的指令
Vector3 s = y*transform.forward * speed * Time.deltaTime;
transform.transform.position += s;
}
由于采用了 “ 速度*时间” 的公式,理论上说,无论电脑运行速度快慢,坦克移动的路径都能够保持一致。因为当电脑很慢时,Update的执行次数会变少,但 Time.deltaTime
的值变大,反之一样,但坦克移动的路径不变。
尽管如此,我们还不能够保证经由网络同步的坦克能够有一致的行为。因为网络延迟的存在,从发出 “前进” 到 “停止” 指令之间的时间可能不一致,坦克移动的路径也就不同。一种解决办法是,在发送命令的时候附带时间信息,客户端根据指令的时间信息区修正路程的计算方式,使所有客户端表现一致。人们定义了 “帧” 的概念,来表示时间(为和Unity本身的帧区分,这里称为 “同步帧”)。
什么是同步帧
假如我们自己实现一个类似 Update
的方法,称之为 FrameUpdate
,程序会固定每隔0.1秒调用它一次。每一次调用 FrameUpdate
称之为一帧,第1次调用称为第一帧,第二次调用称为第2帧,以此类推。

上图展示了一种理想情况,现实往往很残酷。比如在执行第2帧的时候,系统突然卡顿了一下,这一帧的执行时间变长了,就超过0.1秒,这会导致第3帧无法按时执行。

为了保证后面的帧能够按时执行,程序需要做出调整,即减少第2帧和第3帧之间、第3帧和第4帧之间的时间间隔,保证程序在第0.5秒是,执行到第5帧。
同步帧的具体实现如下:
int frame = 0; // 当前执行到第几帧
float interval = 0.1f; // 两帧之间的理想间隔时间
public void Update(){
while(Time.time < frame*interval){
FrameUpdate();
frame++;
}
}
上述程序中,如果某几帧的执行时间太长,程序就会立即调用下一帧(注意使用到while循环),间隔时间为0。程序尽量保证在第N秒的时候,执行到第10*N帧。FrameUpdate
每执行一次,即表示执行一次同步帧。如果程序运行了较长时间,FrameUpdate
的执行频率会相对稳定。
帧同步所保证的,就是各个客户端在执行到同一个 “同步帧” 时,表现效果完全一样。如果将移动坦克的逻辑在 FrameUpdate
里,无论这一帧的执行时间多长,每一帧移动的距离都设定为 “速度*0.1秒” ,只要执行相同的帧数,移动的距离必然相同。
指令
比起操作同步,在指令同步中,客户端向服务端发送的指令包含了具体的指令和时间信息,即是在哪一帧(特指同步帧)做了哪些操作。例如:在第10帧发出了“前进”指令(按下“上”键),在第20帧发出了“后退”指令(按下“下”键)。
指令同步的协议形式如下:
// 同步协议
public class MsgFrameSync:MsgBase {
public MsgFrameSync() {protoName = "MsgFrameSync"; }
// 指令, 0- 前进 1- 后退 2- 左转 3- 右转 4- 停止 ...
public int cmd = 0;
// 在第几帧发生事件
public int frame = 0;
}
指令的执行
为了保持所有客户端有一样的表现,往往要做一些妥协,有两种常见的妥协方案:
- 有的客户端运行速度快,有运行速度慢,如果要让它们表现一致,那只能让快的客户端区等待慢的客户端,所有客户端和最慢的客户端保持一致,才有可能表现一致,毕竟,慢的客户端无论如何都快不了。这种方案对速度快的客户端较为不利。达成此方案的一个方法称为
延迟执行
,如果客户端1在第3帧发出向前的指令,由于网络延迟,客户端2可能在第5帧才收到,所以客户端1的坦克也只能在第5帧(或之后的某一帧)才开始前进。 - 对于速度慢客户端所发送的,丢弃安歇已经过时的指令,知道它赶上来。此种方案也称之
乐观帧同步
,对速度慢的玩家较为不利,因为某些操作指令会被丢弃。比如发出“前进”指令,但该指令被丢弃了,坦克不会移动。
所以,帧同步是一种为了保证多个客户端表现一致,让某些客户端做妥协的方案。而且如果启用了延迟执行,在玩家发出“前进”指令之后,要隔一小段时间坦克才能移动,玩家会感受到延迟。但无论如何,只要帧率(每秒执行多少帧)足够高,玩家就不会感觉到明显的延迟。
在方案一中,为了让各个客户端知道对方是否执行完某一帧,我们假定客户端每一帧都需要向服务端发送指令,没有操作也要发一个代表“没有操作”的指令。服务端要收集各个客户端的指令,收集满时,才在接下的某一帧广播出去。而客户端也只有在收到服务端的消息时,才执行下一帧。此时客户端的帧调用完全由服务端控制。

上图展示了一种帧同步的执行情况。在第1帧时(0.1s)客户端1和客户端2都向服务端发送指令,由于网络延迟,指令到达的时间也不同。服务端收集两个客户端的指令后将两条指令都广播出去,两个客户端会根据指令去执行,比如让坦克向前移动。如果某一个客户端执行很慢,另一个客户端也要等待很久才能收到服务端的指令,才会执行新的一帧,相当于等待慢的客户端。

按照每秒执行30帧的频率,客户端和服务端之间的信息交流也许太过频繁,会带来较大的网络负担。于是人们把多个帧合成为一轮(比如4帧组成一轮),每一轮向服务端同步一次指令。
帧同步还可以配合投票法来防止作弊。例如在坦克游戏中,某个玩家击中另外一个玩家,由于所有客户端的运行结果严格一致,它们都可以向服务端发送“谁击中了谁”的消息。服务端可以收集这些信息,如果半数以上的玩家都发送了击中消息,才认为有效。
传输问题
沾包半包
问题:
如果发送端快速发送多条数据,接收端没有及时调用Receive,那么数据便会在接收端的缓冲区中积累,如下图:

客户端先发送 “1、2、3、4”
四个字节的数据,紧接着又发送 “5、6、7、8”
四个字节的数据。等到服务端调用Receive时,服务端操作系统已经将接收到的数据全部写入缓冲区,共接收到8个数据。
我们举个例子:在聊天软件中,客户端依次发送 “LSP”
和 “_is_handsome”
,期望其他客户端也展示出 “LSP”
和 “_is_handsome”
两条信息,但由于Receive可能把两条信息当做一条信息处理,有可能只展示 “LSP_is_handsome”
一条信息,如下图:

Receive方法返回多少个数据,取决于操作系统接收缓冲区中存放的内容。
发送端发送的数据还有可能被拆分,如发送 “HelloWorld”
,如下图:

但在接收端调用Receive时,操作系统只接收到了部分数据,如 “Hel”
,在等待一小段时间后再次调用Receive才接收到另一部分数据 “loWord”
。
解决 :
我们可以效仿网络协议包体,为我们的数据包加一个包头,这个包头中存放该数据包长度,当然这个包头还可以放入其他的自定义信息,但是我们的包头长度需要固定。
游戏程序的包头只包含了数据包长度,这个长度一般使用16位整数或32位整数来存放。
16位消息长度的格式如下图:

32位消息长度的格式如下图:

大端小端
问题:
我们读取接收到的字节流时,可以按照来类型来读取,比如我们要先读取消息长度,规定消息长度为16位整数,使用API BitConverter.ToInt16(buffer,offset)
来读取。这个方法的底层是怎么实现的呢?我们看看 .Net的源码:
// Converts an array of bytes into a short.
public static unsafe short ToInt16(byte[] value, int startIndex) {
if( value == null) {
ThrowHelper.ThrowArgumentNullException(ExceptionArgument.value);
}
if ((uint) startIndex >= value.Length) {
ThrowHelper.ThrowArgumentOutOfRangeException(ExceptionArgument.startIndex, ExceptionResource.ArgumentOutOfRange_Index);
}
if (startIndex > value.Length -2) {
ThrowHelper.ThrowArgumentException(ExceptionResource.Arg_ArrayPlusOffTooSmall);
}
Contract.EndContractBlock();
fixed( byte * pbyte = &value[startIndex]) {
if( startIndex % 2 == 0) { // data is aligned
return *((short *) pbyte);
}
else {
if(IsLittleEndian) {
return (short)((*pbyte) | (*(pbyte + 1) << 8)) ;
}
else {
return (short)((*pbyte << 8) | (*(pbyte + 1)));
}
}
}
}
我们看到代码中有一个变量 IsLittleEndian
,它代表这台计算机是大端编码还是小端编码,不同的计算机编码方式会有不同。所以问题就是,不同编码方式下,计算方法不同,那对与不同的计算机,读取出来的数据长度肯定是不同的,这个是需要我们处理。
什么是大端小端?
我们知道在计算机中所有数据都是用二进制表示的,举个例子,如果用16位二进制表示数字 258
,它的二进制是 0000000100000010
,转换成16进制是 0x0102
。
假如使用大端模式存入内存,内存数据如图:

还原这个数字的步骤是:
- 拿到第1个字节的数据
00000001
,乘以进制位256(2的8次方),得到256,即第1个字节(低地址)代表了十进制数字256; - 拿到第2个字节的数据
00000010
,它代表十进制数字2,乘以进制位1,得到2; - 将前两步得到的数字相加,即256+2,得到258,还原出数字。
假如使用小端模式存入内存,内存数据如图:

还原这个数字的步骤是:
- 拿到第1个字节的数据
00000010
,它代表十进制数字2,乘以进制位1,得到2; - 拿到第2个字节的数据
00000001
,乘以进制位256(2的8次方),得到256,即第1个字节(低地址)代表了十进制数字256; - 将前两步得到的数字相加,即256+2,得到258,还原出数字。
常用的X86结构是小端模式,很多的ARM、DSP都为小端模式,但KEIL C51则为大端模式,有些ARM处理器还可以由硬件来选择是大端模式还是小端模式。也就是说市面上的手机有些采用大端,有些采用小端模式。
解决
在不确定的问题出现了,我们的解决方案就是把它确定就好了,就可以规定都以小端的方式发送即可。以发送代码为例,就可以写成:
public void Send()
{
string sendStr = InputFeld.text;
// 组装协议
byte[] bodyBytes = System.Text.Encoding.Default.GetBytes(sendStr);
Int16 len = (Int16)bodyBytes.Length;
byte[] lenBytes = BitConverter.GetBytes(len);
// 大小端编码
if(!BitConverter.IsLittleEndian){
Log("[Send] Reverse lenBytes");
lenBytes.Reverse();
}
// 拼接字节
byte[] sendBytes = lenBytes.Concat(bodyBytes).ToArray();
socket.Send(sendBytes);
}
完整发送数据
问题
回忆一下Send方法,该方法会把要发的数据存入操作系统的发送缓冲区,然后返回成功写入的字节数。简单的解释是,对于那些没有成功发送的数据,程序需要把他们保存起来,在适当的时机再次发送。由于在网络通畅的环境下,Send只发送部分数据的概率并不高,所以很多商业游戏也没有处理这种情况。
我们以异步聊天客户端为例,假设操作系统缓冲区被设置得很小,只有8个字节,再假设网络环境很差,缓冲区的数据没能及时的发出去。我们看下图:

在客户端步骤:
- 假设客户端发送字符串
hero
,发送后,Send返回6(包含两字节的长度),数据全部存入操作系统缓冲区中。但此时网络拥堵,TCP尚未把数据发给服务器。 - 客户端又发送了字符串
cat
,由于操作系统的发送缓冲区只剩下2字节空位,只有把代表数据长度03
写入缓冲区。 - 此时网络环境有所改善,TCP成功把缓冲区的数据发送给服务器,操作系统缓冲区被清空。
- 客户端又发送了字符串
hi
,数据发送成功
服务器收到的数据 04hero0302hi
,第一个字符串 hero
可以被解析,但对于后续 0302hi
,服务端会解析成一串3个字节的数据 02h
,以及不完整的长度信息 i
。04hero
往后的数据全部无法解析,通讯失败。
解决
我们使用 Sokect.BeginSend
方法可以传入一个异步回调方法。
public IAsyncResult BeginSend(IList<ArraySegment<byte>> buffers, SocketFlags socketFlags, AsyncCallback callback, object state)
我们可以在这个回调中加入检测我们此次发送的数据是否发送完整。简单的实例代码:
//定义发送缓冲区
byte[] sendBytes = new byte[1024];
//缓冲区偏移值
int readIdx = 0;
//缓冲区剩余长度
int length = 0;
//点击发送按钮
public void Send()
{
sendBytes = 要发送的数据;
length = sendBytes.Length; //数据长度
readIdx = 0;
socket.BeginSend(sendBytes, 0, length, 0, SendCallback, socket);
}
//Send回调
public void SendCallback(IAsyncResult ar){
//获取state
Socket socket = (Socket) ar.AsyncState;
//EndSend的处理
int count = socket.EndSend(ar);
readIdx + =count;
length -= count;
//继续发送
if(length > 0){
socket.BeginSend(sendBytes,readIdx, length, 0, SendCallback, socket);
}
}
在检测到未发送完成后,我们在发送一次即可。
这里发送缓冲区设置的是一个长度为1024
的数组,这个会有潜在的一些风险。我们可以简单的封装一个数据结构ByteArray
,然后把这个数据结构使用队列来存储。
ByteArray的简单实现如下:
using System;
public class ByteArray {
//缓冲区
public byte[] bytes;
//读写位置
public int readIdx = 0;
public int writeIdx = 0;
//数据长度
public int length { get { return writeIdx-readIdx; }}
//构造函数
public ByteArray(byte[] defaultBytes){
bytes = defaultBytes;
readIdx = 0;
writeIdx = defaultBytes.Length;
}
}
最终我们需要把上面发送缓冲区换成队列的实例如下:
// 发送缓冲区
Queue<ByteArray> writeQueue = new Queue<ByteArray>();
// 点击发送按钮
public void Send()
{
// 拼接字节,省略组装 sendBytes 的代码
byte[] sendBytes = 要发送的数据 ;
ByteArray ba = new ByteArray(sendBytes);
int count = 0;
lock(writeQueue){
writeQueue.Enqueue(ba);
count = writeQueue.Count;
}
// send
if(count == 1){
socket.BeginSend(sendBytes, 0, sendBytes.Length,
0, SendCallback, socket);
}
Debug.Log("[Send]" + BitConverter.ToString(sendBytes));
}
// Send 回调
public void SendCallback(IAsyncResult ar){
// 获取 state、EndSend 的处理
Socket socket = (Socket) ar.AsyncState;
int count = socket.EndSend(ar);
ByteArray ba;
lock(writeQueue){
ba = writeQueue.First();
}
ba.readIdx+=count;
if(count == ba.length){
lock(writeQueue){
writeQueue.Dequeue();
ba = writeQueue.First();
}
}
if(ba ! = null){
socket.BeginSend(ba.bytes, ba.readIdx, ba.length,
0, SendCallback, socket);
}
}
这里我们对缓冲队列做了加锁的操作,因为在发送回调中极可能会是多个线程操作。所以我们需要保证这个队列只能被修改一次。
客户端网络模块
我们客户端网络模块的架构如下图:

工作流程:
- 初始化,使用
Networkmanager
创建一个NetworkChannel
, - 当需要发消息时,我们使用特定的序列化工具把对象转化为bytes,再把bytes交给协议栈
- 当收到消息时,我们使用特定的序列化工具把
bytes
转化为对象,再把对象内容由事件传递出去。
我们可以使用一个 Networkmanager
去管理多个 NetworkChannel
。
Networkmanager
网络管理器,管理,驱动多个连接。其中主要的方法:
- CreateNetworkChannel : 创建一个连接
- GetNetworkChannel :获得一个连接
- DestroyNetworkChannel : 销毁一个连接
类图

NetworkChannel
管理一个Socket连接。其中主要方法:
- Connect :连接服务器
- Send :发送消息
- ReceiveCallback : 处理接收消息
- Close :关闭连接
类图

NetworkChannelHelper
序列化与反序列化消息包。主要方法:
- Serialize : 序列化消息包
- DeserializePacketHeader : 反序列化消息包头
- DeserializePacket : 反序列化消息包
类图

EventManager
事件分发器,负责分发消息包到执行,使用这个可以控制线程处理,可以把其他线程的运行的抛到主线程执行。主要方法:
- Subscribe :订阅事件处理函数
- Unsubscribe :取消订阅事件处理函数
- Fire :抛出事件
类图
Packet
消息包体,封装字节流,用于逻辑层使用。主要内容有:
- Id :消息ID
类图

通用服务器网络模块
我们服务器的网络模块框架如下:

可以看出跟客户端的网络模块没有啥区别,很多就是换了个名字而已。这里跟客户端不同的是这里既有Server
对象又有Client
的对象。
这里的Client
并不是连接这个服务器的客户端的意思,而是当该服务器去作为客户端去连接别的服务器时。
我们在游戏中实现一些跨服的功能(如跨服交易)时,就需要一个公共服务器来转发一些数据,或者是设计分布式服务器。我们当前的服务器都会作为客户端去连接别的服务器。
Server
负责作为服务器时,驱动多个Session的适配器,其中主要方法:
- ListenAccept : 监听新的连接
- NewServer : 启动服务器
- Update :轮询服务器,处理发送与接收数据流
Client
负责处理作为客户端时,驱动一个Session的适配器。其中主要方法
- Send : 发送消息到Session层
- Update :轮询客户端,处理发送与接收数据流
- NewClient : 新建一个连接到服务器
Session
控制每一个连接的收发消息。主要方法有:
- Write : 发送消息到协议栈
- Read : 读取协议栈消息
- NewSession : 新建一个连接的管理器
Router
转发接收到的消息到逻辑层。主要方法有:
- Register :注册路由
- Handle : 处理路由
PacketPaser
消息的解码器。主要方法有:
- Encode :序列化对象到bytes
- Decode :反序列化bytes到对象
Packet
消息包体,可以分为:
- SendPacket
- ReceivePacket
参考
https://developer.valvesoftware.com/wiki/Source_Multiplayer_Networking
https://www.zhihu.com/question/41498780/answer/1537480110
https://www.zhihu.com/question/433768405/answer/1700193198
https://gameinstitute.qq.com/community/detail/117210
https://zhuanlan.zhihu.com/p/28447002
https://www.cnblogs.com/panchanggui/p/9841768.html
https://unitylist.com/p/j35/Unity-Lockstep
https://unitylist.com/p/bmi/Unity-texture-curve
https://unitylist.com/p/k9s/Unity-ECS-RTS
https://techdifferences.com/difference-between-client-server-and-peer-to-peer-network.html