【Unity】Socket网络通信(TCP) - 实现简单的多人聊天功能

多客户端连接服务器其原理是在服务端保存客户端连入后与客户端通信的socket,由于等待客户端连接会阻塞主线程,所以结合多线程就能实现多客户端连入功能。多人聊天只需要将A客户端发来的消息,转发给除A客户端外的其他客户端,即可实现。如果你还不怎么熟悉服务端和客户端的通信流程,可以看一下我的这两篇文章。
【Unity】Socket网络通信(TCP) - 最基础的C#服务端通信流程
【Unity】Socket网络通信(TCP) - 最基础的客户端通信流程
这篇文章只实现了简单的发送String类型的消息,发送复杂的消息根据需求封装一个消息类,再把消息类对象序列化成对应的字节数组进行发送,接收方收到字节数组再根据对应的方法反序列化成消息类对象就行。
效果如下
在这里插入图片描述

服务端程序逻辑

封装客户端连入时返回的Socket

新建一个C#的控制台项目,添加一个类封装一下客户端连入时返回的socket,让其拥有自己的发送消息、接收消息、释放连接的方法,由于我们还需要实现消息的转发功能,所以我们还需要定义一个客户端的ID,让每个连入服务器的客户端都拥有唯一的ID。
需要注意的是客户端连入时返回的Socket是服务端用于和客户端通信的socket,并不是客户端的socket。
代码如下:

using System;
using System.Net.Sockets;
using System.Text;
using System.Threading;

namespace TCPServer
{
    
    
    public class ClientSocket
    {
    
    
    	//定义一个静态的变量用于赋予客户端ID
        public static int CLIENT_BEGIN_ID = 1;
        //客户端ID
        public int clientID;
        //客户端连入返回的socket
        private Socket socket;
        //封装好的服务端的socket,调用其封装好的转发消息功能(后面再封装这个ServerSocket类)
        private ServerSocket serverSocket;

		//构造函数中传入客户端连入返回的socket和服务端的ServerSocket对象
        public ClientSocket(Socket clientSocket, ServerSocket serverSocket)
        {
    
    
        	//记录一下socket
            socket = clientSocket;
            this.serverSocket = serverSocket;
			//记录ID
            clientID = CLIENT_BEGIN_ID;
            ++CLIENT_BEGIN_ID;
        }

        //发送消息,这里的发送消息是指服务端给客户端发送消息
        public void SendMsg(string msg)
        {
    
    
        	//将string类型消息序列化成字节数组并发送
            if(socket != null)
                socket.Send(Encoding.UTF8.GetBytes(msg));
        }

        //接收消息
        public void ReceiveClientMsg()
        {
    
    
            if (socket == null)
                return;

			//判断一下客户端即将收到消息的数量,如果没有消息,就不需要接收处理
            if(socket.Available > 0)
            {
    
    
            	//定义一个字节数组来装载收到的消息
                byte[] msgBytes = new byte[1024];
                //接收消息,返回的是消息长度,反序列化时需要用到
                int msgLength = socket.Receive(msgBytes);
                //BroadcastMsg这个是服务端socket封装的广播消息方法,将消息和客户端ID传进去进行消息转发
                serverSocket.BroadcastMsg(Encoding.UTF8.GetString(msgBytes, 0, msgLength), clientID);
            }
        }
        
        //释放连接
        public void Close()
        {
    
    
            socket.Shutdown(SocketShutdown.Both);
            socket.Close();
            socket = null;
        }
    }
}

封装服务端Socket

服务端的socket需要有以下几个功能,保存客户端连入后返回的和客户端通信的socket、持续等待客户端连接、持续接收客户端发来的消息、广播消息等功能。
因为等待客户端连入的方法Accept是阻塞式的,不开新线程的话,会阻塞主线程进行,而且无法持续不断地监听客户端连接,所以需要开启一个新的线程。
广播消息不用转发给发送消息的客户端,也就是例如有A、B、C客户端连入了服务器,A客户端发送了消息,服务端进行转发则不需要转发给A客户端。
代码如下:

using System;
using System.Collections.Generic;
using System.Net;
using System.Net.Sockets;
using System.Threading;

namespace TCPServer
{
    
    
    public class ServerSocket
    {
    
    
    	//服务端socket
        private Socket socket;
        //是否已经关闭socket释放连接
        private bool isClose;
        //保存所有连入客户端与其通信的socket
        private List<ClientSocket> clientList = new List<ClientSocket>();

		//传入IP地址,端口号和最大连入客户端数量
        public void Start(string ip, int port, int clientNum)
        {
    
    
        	//建立连接开始,改变状态
            isClose = false;
            
            //创建socket
            socket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
            
            //绑定IP地址和端口号
            IPEndPoint iPEndPoint = new IPEndPoint(IPAddress.Parse(ip), port);
            socket.Bind(iPEndPoint);
            
            Console.WriteLine("服务器启动成功...IP:{0},端口:{1}", ip, port);
            Console.WriteLine("开始监听客户端连接...");
            //设置监听数量
            socket.Listen(clientNum);

			//开启线程,持续监听客户端连接
            ThreadPool.QueueUserWorkItem(AcceptClientConnect);
            //开启线程,持续监听连入客户端是否发送了消息
            ThreadPool.QueueUserWorkItem(ReceiveMsg);
        }

        //等待客户端连接(新线程)
        private void AcceptClientConnect(object obj)
        {
    
    
            Console.WriteLine("等待客户端连入...");
            //死循环,持续不断地监听客户端连接
            while (!isClose)
            {
    
    
            	//这里会阻塞线程,当有客户端连入时,返回一个socket与客户端通信,才会执行接下来的逻辑
                Socket clientSocket = socket.Accept();
                //有客户端连入了,创建一个我们上面封装好了的ClientSocket对象
                ClientSocket client = new ClientSocket(clientSocket, this);
                Console.WriteLine("客户端{0}连入...", clientSocket.RemoteEndPoint.ToString());
                //向客户端发送一条欢迎消息
                client.SendMsg("欢迎连入服务端...");
                //将客户端添加到List,后续和客户端通信可以从这个List里面取
                clientList.Add(client);
            }
        }

        //接收消息(新线程)
        private void ReceiveMsg(object obj)
        {
    
    
            int i;
            //持续不断地监听客户端有没有发送消息
            while (!isClose)
            {
    
    
            	//当连入客户端数量大于0的时候才去接收消息
                if (clientList.Count > 0)
                {
    
    
                	//遍历所有连入的客户端
                    for(i = 0; i < clientList.Count; i++)
                    {
    
    
                        try
                        {
    
    
                        	//调用上面代码封装好的接收消息方法
                            clientList[i].ReceiveClientMsg();
                        }
                        catch(Exception e)
                        {
    
    
                            Console.WriteLine(e.Message);
                        }
                    }
                }
            }
        }

        //广播消息,传入消息,和发送消息的客户端的ID
        public void BroadcastMsg(string msg, int clientID)
        {
    
    
            if (isClose)
                return;
            //遍历所有连入的客户端
            for(int i = 0; i < clientList.Count; i++)
            {
    
    
            	//如果不是发送消息的客户端则转发消息,不用转发给发送消息的客户端
                if(clientList[i].clientID != clientID)
                    clientList[i].SendMsg(msg);
            }
        }

        //释放连接
        public void Close()
        {
    
    
            isClose = true;
            for(int i = 0; i < clientList.Count; i++)
            {
    
    
                clientList[i].Close();
            }

            clientList.Clear();

            socket.Shutdown(SocketShutdown.Both);
            socket.Close();
            socket = null;
        }
    }
}

封装好了这两个类,只需要在入口函数创建ServerSocket对象并调用Start方法就可以了

using System;

namespace TCPServer
{
    
    
    class Program
    {
    
    
        private static ServerSocket serverSocket;
        static void Main(string[] args)
        {
    
    
            serverSocket = new ServerSocket();
            serverSocket.Start("127.0.0.1", 8080, 1024);

            while (true)
            {
    
    
                string inputStr = Console.ReadLine();
                if(inputStr == "Quit")
                {
    
    
                    serverSocket.Close();
                    break;
                }
                else if (inputStr.Substring(0, 2) == "B:")
                {
    
    
                    serverSocket.BroadcastMsg(inputStr.Substring(2), -1);
                }
            }
        }
    }
}

服务端的代码就写好了,以上代码没有实现客户端主动断开连接服务端的逻辑,还不是很完善,后续有时间还需要再完善一下。

Unity客户端逻辑

Unity客户端实现一个聊天面板,可以主动发送消息,封装一个网络模块用来连接服务器、收发消息。
效果图

网络模块封装

由于网络模块一般都是唯一的,我这里做成单例模式,方便使用和管理,因为要用到Unity的生命周期函数,所以继承了MonoBehaviour。
客户端的网络模块需要

  • 与服务器通信的socket
  • 发送消息队列
  • 接收消息队列
  • 存放收到的消息的字节数组
  • 连接服务器方法
  • 发送消息方法
  • 接收消息方法

当点击发送按钮时,将消息放到消息队列中,开启一个线程去发送消息,避免了网络不好或者是消息太大发送缓慢,一个消息没发完又有新消息要发送的问题,
代码如下:

//NetManager.cs

using System.Collections;
using System.Collections.Generic;
using System.Net;
using System.Net.Sockets;
using System.Text;
using System.Threading;
using UnityEngine;

public class NetManager : MonoBehaviour
{
    
    
    public static NetManager Instance => instance;
    private static NetManager instance;

	//客户端socket
    private Socket socket;
	//是否连接状态
    private bool isConnect;
	//发送消息队列
    private Queue<string> sendQueue = new Queue<string>();
    //接收消息队列
    private Queue<string> receiveQueue = new Queue<string>();
	//存放收到消息的字节数组
    private byte[] receiveBytes = new byte[1024 * 1024];
    
	//UI面板(这里违背了单例模式的设计思想,由于项目比较简单,图方便就直接在这里得到UI面板了)
	//实际项目可以封装一个UI管理类(单例模式)管理UI更新,收到消息通过UI管理类去更新对应的UI面板
    private ChatPanel chatPanel;

    private void Awake()
    {
    
    
    	//初始化单例对象
        if(instance == null)
        {
    
    
            instance = this;
            DontDestroyOnLoad(gameObject);
        }
    }

    private void Start()
    {
    
    
    	//得到UI面板(这里违背了单例模式的设计思想,由于项目比较简单,图方便就直接在这里得到UI面板了)
    	//实际项目可以封装一个UI管理类(单例模式)管理UI更新,收到消息通过UI管理类去更新对应的UI面板
        GameObject chatPanelObj = GameObject.Find("ChatPanel");
        chatPanel = chatPanelObj.GetComponent<ChatPanel>();
    }

    // Update is called once per frame
    void Update()
    {
    
    
    	//如果收到消息队列中存在消息,则让UI更新面板
        if(receiveQueue.Count > 0)
        {
    
    
	        //这里图方便,实际项目可以封装一个UI管理类(单例模式)管理UI更新,收到消息通过UI管理类去更新对应的UI面板
            chatPanel.UpdateChatInfo("他人:" + receiveQueue.Dequeue());
        }
    }

    /// <summary>
    /// 连接服务器
    /// </summary>
    /// <param name="ip">服务器IP地址</param>
    /// <param name="port">服务器程序端口号</param>
    public void ConnectServer(string ip, int port)
    {
    
    
        //如果在连接状态,就不执行连接逻辑了
        if (isConnect)
            return;

        //避免重复创建socket
        if(socket == null)
            socket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);

        //连接服务器
        IPEndPoint ipEndPoint = new IPEndPoint(IPAddress.Parse(ip), port);

        try
        {
    
    
            socket.Connect(ipEndPoint);
        }
        catch (SocketException e)
        {
    
    
            print(e.ErrorCode + e.Message);
            return;
        }
        
        isConnect = true;

        //开启发送消息线程
        ThreadPool.QueueUserWorkItem(SendMsg_Thread);
        //开启接收消息线程
        ThreadPool.QueueUserWorkItem(ReceiveMsg_Thread);
    }

    //发送消息
    public void Send(string msg)
    {
    
    
        //将消息放入到消息队列中
        sendQueue.Enqueue(msg);
    }

    private void SendMsg_Thread(object obj)
    {
    
    
        while (isConnect)
        {
    
    
            //如果消息队列中有消息,则发送消息
            if(sendQueue.Count > 0)
            {
    
    
                socket.Send(Encoding.UTF8.GetBytes(sendQueue.Dequeue()));
            }
        }
    }

    //接收消息
    private void ReceiveMsg_Thread(object obj)
    {
    
    
        print("持续监听是否收到消息");
        int msgLength;
        while (isConnect)
        {
    
    
        	//判断有没有收到消息
            if(socket.Available > 0)
            {
    
    
                msgLength = socket.Receive(receiveBytes);
                print("接收到消息,长度为" + msgLength);
                //收到消息,反序列化后放入收到消息队列中,在Update中不停检测有没有收到的消息,收到了就让UI更新面板
                receiveQueue.Enqueue(Encoding.UTF8.GetString(receiveBytes, 0, msgLength));
            }
        }
    }

	//释放连接
    public void Close()
    {
    
    
        if (socket != null && isConnect)
        {
    
    
            socket.Shutdown(SocketShutdown.Both);
            socket.Close();

            isConnect = false;
        }
    }

    private void OnDestroy()
    {
    
    
        Close();
    }
}

避免忘记挂单例对象的脚本,我这里直接新建一个Main的空物体挂载Main.cs脚本,后续所有继承MonoBehaviour的单例模式都可以通过这个脚本自动挂载。

//Main.cs
using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class Main : MonoBehaviour
{
    
    
    // Start is called before the first frame update
    void Start()
    {
    
    
    	//如果网络模块单例对象为空
        if (NetManager.Instance == null)
        {
    
    
        	//创建一个空物体
            GameObject netGameObj = new GameObject("Net");
            //挂载NetManager脚本
            netGameObj.AddComponent<NetManager>();
        }
		//连接服务器
        NetManager.Instance.ConnectServer("127.0.0.1", 8080);
    }
}

在这里插入图片描述

UI面板逻辑

UI面板很简单,就一个输入框,一个按钮,一个Scroll View来展示聊天记录,把这三个创建出来,摆好位置就行,我这里用了一个Panel来包裹住上面的三个UI控件。
在这里插入图片描述
Scroll View去掉横向的滚动条,Scroll View下面的Content添加一个竖向自动布局的组件Vertical Layout Group用来自动布局,增加Content Size Fitter组件控制Content大小自适应,将Vertical Fit调整为Preferred Size,将Content的锚点改为底部对齐。
在这里插入图片描述
创建一个Text预制体,用来在Scroll View中显示聊天内容
在这里插入图片描述
创建ChatPanel.cs挂在场景ChatPanel上

//ChatPanel.cs
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.UI;

public class ChatPanel : MonoBehaviour
{
    
    
	//发送按钮
    public Button sendBtn;
    //输入框
    public InputField inputField;
    //Scroll View
    public ScrollRect sr;

    // Start is called before the first frame update
    void Start()
    {
    
    
    	//为按钮添加点击事件监听函数
        sendBtn.onClick.AddListener(() =>
        {
    
    
        	//当输入框的内容不是空时,发送消息到服务器,并更新Scroll View的聊天内容
            if(inputField.text != "")
            {
    
    
                NetManager.Instance.Send(inputField.text);
                UpdateChatInfo("我:" + inputField.text);
                //发送完将输入框的内容清空
                inputField.text = "";
            }
        });
    }

	//更新聊天内容
    public void UpdateChatInfo(string msgInfo)
    {
    
    
    	//在Scroll View中的Content中动态创建Text预制体
        Text chatInfoText = Instantiate(Resources.Load<Text>("UI/MsgInfoText"), sr.content);
        //修改Text的内容
        chatInfoText.text = msgInfo;
    }
}

客户端代码就完成了

测试

将Unity项目打包一个可执行文件,先运行服务器,依次打开客户端程序,就能完成多个客户端互相通信聊天!
在这里插入图片描述

总结

这次的客户端和服务端通信部分代码并不完善,比如服务端没做客户端主动断开的一些处理以及分包黏包的处理等等,只是TCP长连接的一个简单的雏形,如有哪些地方写的不够好的地方请多多包容,也欢迎指出,一起探讨、共同进步、无限进步!
以上就是这篇文章的所有内容了,此为个人学习记录,如有哪个地方写的有误,劳烦大佬指出,感谢,希望对各位看官有所帮助!

猜你喜欢

转载自blog.csdn.net/weixin_43453797/article/details/127313983