《Unity3D网络游戏实战》正确收发数据流

news2024/9/23 5:21:06

TCP数据流

系统缓冲区

当收到对端数据时,操作系统会将数据存入到Socket的接收缓冲区中

操作系统层面上的缓冲区完全由操作系统操作,程序并不能直接操作它们,只能通过socket.Receive、socket.Send等方法来间接操作。当系统的接收缓冲区为空,Receive方法会被阻塞,直到里面有数据。同样地,Socket的Send方法只是把数据写入到发送缓冲区里,具体的发送过程由操作系统负责。当操作系统的发送缓冲区满了,Send方法将会阻塞

粘包半包现象

如果发送端快速发送多条数据,接收端没有及时调用Receive,那么数据便会在接收端的缓冲区中累积

解决粘包半包现象

一般有三种方法可以解决粘包和半包问题,分别是长度信息法、固定长度法和结束符号法

长度信息法

长度信息法是指在每个数据包前面加上长度信息。每次接收到数据后,先读取表示长度的字节,如果缓冲区的数据长度大于要取的字节数,则取出相应的字节,否则等待下一次数据接收。

游戏程序一般会使用16位整型数或32位整型数来存放长度信息 。16位整型数的取值范围是0~65535,32位整型数的取值范围是0~4294967295。对于大部分游戏,网络消息的长度很难超过65535字节,使用16位整型数来存放长度信息较合适

固定长度法

每次都以相同的长度发送数据,假设规定每条信息的长度都为10个字符,那么发送“Hello”​“Unity”两条信息可以发送成“He llo... ”​“Unity... ”​,其中的“. ”表示填充字符,是为凑数,没有实际意义,只为了每次发送的数据都有固定长度。接收方每次读取10个字符,作为一条消息去处理。如果读到的字符数大于10,比如第1次读到“He llo...Un”​,那它只要把前10个字节“Hello... ”抽取出来,再把后面的两个字节“Un”存起来,等到再次接收数据,拼接第二条信息。

结束符号法

规定一个结束符号,作为消息间的分隔符

实现

发送数据

        //点击发送按钮
        public void Send(string sendStr)
        {
            //组装协议
            byte[] bodyBytes = System.Text.Encoding.Default.GetBytes(sendStr);
            Int16 len = (Int16)bodyBytes.Length;
            byte[] lenBytes = BitConverter.GetBytes(len);
            byte[] sendBytes = lenBytes.Concat(bodyBytes).ToArray();
            //为了精简代码:使用同步Send
            //不考虑抛出异常
            socket.Send( sendBytes);
        }

接收数据

游戏程序一般会使用“长度信息法”处理粘包问题,核心思想是定义一个缓冲区(readBuff)和一个指示缓冲区有效数据长度变量(buffCount)​。 

        //接收缓冲区
        byte[] readBuff = new byte[1024];
        //接收缓冲区的数据长度
        int buffCount = 0;

比如,readBuff中有5个字节的数据“world”​(其余为byte的默认值0)​,那么buffCount的值应是5

因为存在粘包现象,缓冲区里面会保存尚未处理的数据。所以接收数据时不再从缓冲区开头的位置写入,而是把新数据放在有效数据之后

如果使用异步Socket, BeginReceive的参数应填成下面的样子:

        socket.BeginReceive(readBuff,          //缓冲区
                            buffCount,        //开始位置
                            1024-buffCount,   //最多读取多少数据
                            0,                  //标志位,设成0即可
                            ReceiveCallback, //回调函数
                            socket);           //状态

在收到数据后,程序需要更新buffCount,以使下一次接收数据时,写入到缓冲区有效数据的末尾

        public void ReceiveCallback(IAsyncResult ar){
            Socket socket = (Socket) ar.AsyncState;
            //获取接收数据长度
            int count = socket.EndReceive(ar);
            buffCount+=count;
            ……
        }

处理数据

收到数据后,如果缓冲区的数据足够长,超过1条消息的长度,就把消息提取出来处理。如果数据长度不够,不去处理它,等待下一次接收数据。

        public void OnReceiveData(){
            //消息长度
            if(buffCount <= 2)
                return;
            Int16 bodyLength = BitConverter.ToInt16(readBuff, 0);
            //消息体
            if(buffCount < 2+bodyLength)
                return;
            string s = System.Text.Encoding.UTF8.GetString(readBuff, 2, buffCount);
            //s是消息内容
            //更新缓冲区
            int start = 2 + bodyLength;
            int count = buffCount - start;
            Array.Copy(readBuff, start, readBuff, 0, count);
            buffCount -= start;
            //继续读取消息
            if(readBuff.length > 2){
                OnReceiveData();
            }
        }

读取出的缓冲区数据已经没有用了,需要删除它。一个直观的办法是将缓冲区后面的数据向前移位

移动缓冲区数据可使用Array.Copy方法,它的原型如下:

        public static void Copy(
            Array sourceArray,
            long sourceIndex,
            Array destinationArray,
            long destinationIndex,
            long length
        )

sourceArray代表源数组,destinationArray代表目标数据,sourceIndex代表源数组的起始位置,destinationIndex代表目标数组的起始位置,length代表要复制的消息的长度。

        public void OnReceiveData(){
            //处理一条消息(略)
            //更新缓冲区
            int start = 2 + bodyLength;
            int count = buffCount - start;
            Array.Copy(readBuff, start, readBuff, 0, count);
            buffCount -= start;
            //如果有更多消息,就处理它
        }

完整示例

        using System.Collections;
        using System.Collections.Generic;
        using UnityEngine;
        using System.Net.Sockets;
        using UnityEngine.UI;
        using System;
        using System.Linq;

        public class Echo : MonoBehaviour {

            //定义套接字
            Socket socket;
            //UGUI
            public InputField InputFeld;
            public Text text;
            //接收缓冲区
            byte[] readBuff = new byte[1024];
        //接收缓冲区的数据长度
        int buffCount = 0;
        //显示文字
        string recvStr = "";

        //点击连接按钮
        public void Connection()
        {
            //Socket
            socket = new Socket(AddressFamily.InterNetwork,
                SocketType.Stream, ProtocolType.Tcp);
            //为了精简代码:使用同步Connect
            //不考虑抛出异常
            socket.Connect("127.0.0.1", 8888);
            socket.BeginReceive( readBuff, buffCount, 1024-buffCount, 0,
                ReceiveCallback, socket);
        }

        //Receive回调
        public void ReceiveCallback(IAsyncResult ar){
            try {
                Socket socket = (Socket) ar.AsyncState;
                //获取接收数据长度
                int count = socket.EndReceive(ar);
                buffCount+=count;
                //处理二进制消息
                OnReceiveData();
                //继续接收数据
                socket.BeginReceive( readBuff, buffCount, 1024-buffCount, 0,
                    ReceiveCallback, socket);
            }
            catch (SocketException ex){
                Debug.Log("Socket Receive fail" + ex.ToString());
            }
        }

        public void OnReceiveData(){
            Debug.Log("[Recv 1] buffCount=" +buffCount);
            Debug.Log("[Recv 2] readbuff=" + BitConverter.ToString(readBuff));
            //消息长度
            if(buffCount <= 2)
                return;
            Int16 bodyLength = BitConverter.ToInt16(readBuff, 0);
            Debug.Log("[Recv 3] bodyLength=" +bodyLength);
            //消息体
            if(buffCount < 2+bodyLength)
                return;
            string s = System.Text.Encoding.UTF8.GetString(readBuff, 2, buffCount);
            Debug.Log("[Recv 4] s=" +s);
            //更新缓冲区
            int start = 2 + bodyLength;
            int count = buffCount - start;
            Array.Copy(readBuff, start, readBuff, 0, count);
            buffCount -= start;
            Debug.Log("[Recv 5] buffCount=" +buffCount);
            //消息处理
            recvStr = s + "\n" + recvStr;
            //继续读取消息
            OnReceiveData();
        }

        //点击发送按钮
        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);
            byte[] sendBytes = lenBytes.Concat(bodyBytes).ToArray();
            //为了精简代码:使用同步Send
            //不考虑抛出异常
            socket.Send(sendBytes);
            Debug.Log("[Send]" + BitConverter.ToString(sendBytes));

        }

        public void Update(){
            text.text = recvStr;
        }
    }
  • 使用buffCount记录缓冲区的数据长度,使缓冲区可以保存多条数据;
  • 接收数据(BeginReceive)的起点改为buffCount,由于缓冲区总长度为1024,所以最大能接收的数据长度变成了1024-buffCount;
  • 通过OnReceiveData处理消息
  • 给发送的消息添加长度信息。

大端小端问题

下面是经过简化的BitConverter.ToInt16源码,其中的IsLittleEndian代表这台计算机是大端编码还是小端编码,不同的计算机编码方式会有不同。

        public static short ToInt16(byte[] value, int 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)));
            }
        }

完整发送数据

如何解决发送不完整问题

要让数据能够发送完整,需要在发送前将数据保存起来;如果发送不完整,在Send回调函数中继续发送数据,示意代码如下。

        //定义发送缓冲区
        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);
            }
        }
        socket.BeginSend(sendBytes,       //发送缓冲区
                          readIdx,        //从索引为6的数据开始发送
                          length,         //因为缓冲区只剩下4个数据,最多发送4个数据
                          0,              //标志位,设置为0即可
                          SendCallback,   //回调函数
                          socket);        //传给回调函数的对象

上面的方案解决了一半问题,因为调用BeginSend之后,可能要隔一段时间才会调用回调函数,如果玩家在SendCallback被调用之前再次点击发送按钮,按照前面的写法,会重置readIdx和length, SendCallback也就不可能正确工作了。为此我们设计了加强版的发送缓冲区,叫作写入队列(writeQueue)​,它的结构如图

图展示了一个包含三个缓冲区的写入队列,当玩家点击发送按钮时,数据会被写入队列的末尾,比如一开始发送“08hellolpy”​,那么就在队列里添加一个缓冲区,这个缓冲区和本节前面介绍的缓冲区一样,包含一个bytes数组,以及指向缓冲区开始位置的readIdx、缓冲区剩余长度的length。Send方法会做这样的处理,示意代码如下:

        public void Send() {
            sendBytes = 要发送的数据;
            writeQueue.Enqueue(ba);     //假设ba封装了readbuff、readIdx、length等数据
            if(writeQueue只有一条数据){
                socket.BeginSend(参数略);
            }
        }

        public void SendCallback(IAsyncResult ar){
            count = socket.EndSend(ar);
            ByteArray ba = writeQueue.First();  //ByteArray后面再介绍
            ba.readIdx+=count;  //length的处理略
            if(发送不完整){
                取出第一条数据,再次发送
            }
            else if(发送完整,且writeQueue还有数据){
                删除第一条数据
                取出第二条数据,如有,发送
            }
        }

ByteArray 和 Queue

ByteArray是封装byte[​]​、readIdx和length的类,可以这样定义它(添加文件ByteArray.cs)​: 

        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;
            }
        }

        byte[] sendBytes = new byte[]{'0', '3', 'c', 'a', 't'};
        ByteArray ba = new ByteArray(sendBytes);
        socket.BeginSend(ba.bytes, ba.readIdx, ba.length, 0, SendCallback, socket);

Queue

        Queue<ByteArray> writeQueue = new Queue<ByteArray>();
        ByteArray ba = new ByteArray(sendBytes);

        writeQueue.Enqueue(ba);                //将ba放入队列
        ByteArray ba2 = writeQueue.First();  //获取writeQueue的第一个元素,队列保持不变
        be2 = writeQueue.Dequeue();            //弹出队列的第一个元素

Enqueue代表把元素放入到队列中,该元素会放到队列的末尾;Dequeue代表出列,队列的第一个元素被弹出来;First代表获取队列的第一个元素

解决线程冲突

由异步的机制可以知道,BeginSend和回调函数往往执行于不同的线程,如果多个线程同时操作writeQueue,有可能引发些问题。

玩家连续点击两次发送按钮,假如运气特别差,第二次发送时,第一次发送的回调函数刚好被调用。如果线程1的Send刚好走到writeQueue.Enqueue(ba)这一行(t2时刻)​,按理说writeQueue.Count应为2,不应该进入if(writeQueue.Count == 1)的真分支去发送数据(因为此时writeQueue.Count== 2)​。但假如在条件判断之前,回调线程刚好执行了writeQueue.Dequeue()(t3时刻)​,由于writeQueue里只有1个元素,在t4时刻主线程判断if(writeQueue.Count == 1)时,条件成立,会发送数据。但SendCallback中ba = writeQueue.First()也会获取到队列的第一条数据,也会把它发送出去。第二次发送的数据将会被发送两次,显然不是我们需要的。

为了避免线程竞争,可以通过加锁(lock)的方式处理。当两个线程争夺一个锁的时候,一个线程等待,被阻止的那个锁变为可用

        //发送缓冲区
        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);
            }
        }

以上代码把临界区设计得很小,拥有较高的执行效率。

参考书籍:《Unity3D网络游戏实战(第2版)》 (豆瓣) (douban.com)

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.coloradmin.cn/o/2034197.html

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈,一经查实,立即删除!

相关文章

C#去掉文件夹或文件名非法字符

实现有输入字符串创建文件夹的功能&#xff0c;需要检查字符串中是否包含不能在文件夹中使用的非法字符 。C#中如果需要生成文件路径&#xff0c;有时会抛出“文件路径中出现非法字符”的异常&#xff0c;这时我们就会找到这个非法的字符串&#xff0c;然后Replace替换掉。net类…

牛客JS题(四十)字体高亮

注释很详细&#xff0c;直接上代码 涉及知识点&#xff1a; 正则表达式逆向思路 题干&#xff1a; 我的答案 <!DOCTYPE html> <html lang"en"><head><meta charset"UTF-8" /></head><body><input type"text&…

图形编辑器基于Paper.js教程14:使用 Paper.js 绘制数学图形与交互的实现,画布缩放保持大小的圆,正弦,余弦,螺旋线

技术分析&#xff1a;使用 Paper.js 绘制数学图形与交互的实现 在现代Web开发中&#xff0c;动态图形和交互式视觉表现已成为提升用户体验的重要手段。通过一个详细的示例&#xff0c;我们将探索如何使用 Paper.js 进行数学图形&#xff08;正弦曲线、余弦曲线和螺旋线&#x…

拯救打工人的4款可ai生成ppt神器大PK,谁是加班狗的最爱?

在忙碌的工作日常里&#xff0c;做PPT常常让不少上班族头疼。晚上加班时&#xff0c;办公室里总能看到那些为了做出完美PPT而熬夜的人。不过现在好了&#xff0c;科技进步带来了好消息&#xff0c;有几款AI生成PPT的神器出现了&#xff0c;今天&#xff0c;咱们就来比一比&…

网络编程----TCP/IP协议

使用TCP/IP协议实现客户端和服务器端进行通信: 1.服务器端(test1.c): #include <sys/socket.h> #include <sys/types.h> #include <arpa/inet.h> #include <stdio.h> #include <unistd.h>// 创建服务器端 int main() {//1.创建套接字int serfd…

锂电池剩余寿命预测 | Matlab基于Transformer-GRU的锂电池剩余寿命预测

目录 预测效果基本介绍程序设计参考资料 预测效果 基本介绍 Matlab基于Transformer-GRU的锂电池剩余寿命预测&#xff0c;Transformer结合门控循环单元。 Matlab基于Transformer-GRU的锂电池剩余寿命预测&#xff08;单变量&#xff09; 运行环境Matlab2023b及以上。 首先从…

API网关:SpringCloud GateWay

一. 网关的作用及背景 1.API网关的作用 请求路由 在我们的系统中由于同一个接口新老两套系统都在使用&#xff0c;我们需要根据请求上下文将请求路由到对应的接口。 统一鉴权 对于鉴权操作不涉及到业务逻辑&#xff0c;那么可以在网关层进行处理&#xff0c;不用下层到业务…

嵌入式开发--STM32的GPIO输入和输出复用

嵌入式开发–STM32的GPIO输入和输出复用 MCU的引脚数量非常有限&#xff0c;做项目时&#xff0c;经常是为了成本而选择引脚尽量少的芯片&#xff0c;这也给布线和编程带来更大的挑战。 最近一个项目&#xff0c;需要在某些时候通过拨码开关预置参数&#xff0c;预置完成后&am…

QT自定义系统快捷键任务

关键代码 //自定义快捷键检测 connect(this->ui->hotkeySequenceEdit_1, &QKeySequenceEdit::keySequenceChanged,this, &HotTestWidget::setShortcut_1);// 托盘显示 trayIcon new QSystemTrayIcon(this); QPixmap pixmap("tray.png"); QIcon icon(…

[Android] [解决]Bottom Navigation Views Activity工程带来的fragment顶部空白间距问题

用Android Stuio创建一个Bottom Navigation Views Activity工程&#xff0c; 我们刻意设置一下fragment背景为黑色&#xff0c;会发现&#xff0c;这个fragment离顶部还有一段不小空白距离&#xff0c; 怎么解决呢&#xff1f; 在activity_main.xml里面&#xff0c;删掉这句&a…

2024年【中级消防设施操作员(考前冲刺)】找解析及中级消防设施操作员(考前冲刺)试题及解析

题库来源&#xff1a;安全生产模拟考试一点通公众号小程序 中级消防设施操作员&#xff08;考前冲刺&#xff09;找解析考前必练&#xff01;安全生产模拟考试一点通每个月更新中级消防设施操作员&#xff08;考前冲刺&#xff09;试题及解析题目及答案&#xff01;多做几遍&a…

SuccBI+低代码文档中心 — 可视化分析(仪表板)(下)

制作仪表板 引入数据模型 仪表板所需模型已经在数据模块中准备好&#xff0c;可以将对应模型表添加到数据模型中。提供了两种添加方式&#xff1a; 在数据栏中点击添加按钮&#xff0c;在弹出框中通过搜索或直接在其所在目录下选中该模型&#xff0c;点击确定。 点击数据按钮…

【安卓】调用摄像头和相册

文章目录 调用摄像头拍照从相册中选择照片 调用摄像头拍照 新建一个CameraAlbumTest项目&#xff0c;然后修改activity_main.xml中的代码。在布局文件中添加两个控件&#xff1a;一个Button和一个ImageView。Button是用于打开摄像头进行拍照的&#xff0c;而ImageView则是用于将…

如何使用 Puppeteer 和 Node.JS 进行 Web 抓取?

什么是 Headlesschrome&#xff1f; Headless&#xff1f;是的&#xff0c;这意味着这个浏览器没有图形用户界面 (GUI)。不用鼠标或触摸设备与视觉元素交互&#xff0c;你需要使用命令行界面 (CLI) 来执行自动化操作。 Headlesschrome 和 Puppeteer 很多网页抓取工具都可适用…

【千帆平台】零代码结合知识库或者插件开发一款AI应用,2024年奥运会运动员获奖信息(人工智能应用)

欢迎来到《小5讲堂》 这是《千帆》系列文章&#xff0c;每篇文章将以博主理解的角度展开讲解。 温馨提示&#xff1a;博主能力有限&#xff0c;理解水平有限&#xff0c;若有不对之处望指正&#xff01; 目录 背景组件方式prompt新参数运动员信息组件奖牌排行榜组件 知识库方式…

【Python】函数入门(上)

本篇文章将讲解以下知识&#xff1a; &#xff08;1&#xff09;初识函数 &#xff08;2&#xff09;函数的参数 &#xff08;3&#xff09;函数的返回值 1、初识函数 什么是函数&#xff1f; 函数可以看成是一大堆功能代码的集合。 定义函数格式&#xff1a; def 函…

【ARM】Cortex-A72技术手册(1)

目录 1.介绍 1.1接口 1.2 实现选项 2.功能描述 2.1 processor内部的组件 2.2 接口 2.2.1 Memory接口 2.2.2 可选的Accelerator Coherency Port 2.2.3 可选的GIC CPU接口 2.2.4 Debug 接口 2.2.5 Trace 接口 2.2.6 PMU接口 2.2.7 通用的Timer接口 2.2.8 Cross tri…

html 关于table合并外边框以及自动滚动问题汇总

合并外边框 .tab_main{ width: 100%; height:100%; border: 1px solid #ccc; text-align: center; border-spacing: 0; border-collapse: collapse;//合并外边框 } 固定高度显示上下滑动 <div styleoverflow:scroll;height:100%> <di…

LeetCode 热题 HOT 100 (035/100)【宇宙最简单版】

【栈】No. 0394 字符串解码【中等】&#x1f449;力扣对应题目指路 希望对你有帮助呀&#xff01;&#xff01;&#x1f49c;&#x1f49c; 如有更好理解的思路&#xff0c;欢迎大家留言补充 ~ 一起加油叭 &#x1f4a6; 欢迎关注、订阅专栏 【力扣详解】谢谢你的支持&#xff…