[学习笔记][Unity3D网络游戏实战]客户端基本网络框架(重制版)

前言:

此项目基于unity,使用c#语言构建一个通用的简单的客户端框架,比较简陋但是易懂。

在第一版的基础上,我更详细的描述了模块的编写过程逻辑。

学习目标:学习构建一个游戏客户端基本的网络模块


网络模块的设计实现:

1.网络管理者NetManager类

该类将有关网络通讯部分的功能封装,实现网络模块的主要功能。

主要利用观察者模式,让框架拥有松耦合,灵活性,可拓展性的特性。

整合了客户端连接服务器Connect,关闭与服务器的连接Close,想服务器发送消息Send等基本网络通讯功能。


网络通讯可以抽象为如下阶段

连接Connect -> 发送Send -> 接收Receive -> 客户端处理消息 -> 发送/接收/处理循环->关闭Close


下面是具体实现

关键属性

Socket:连接套接字

readBuff:接受消息的缓冲区

writeQueue:发送消息的写入队列

isClosing :记录是否连接 

    private static Socket socket;
    //接收缓存区
    private static ByteArray readBuff;
    //写入队列
    private static Queue<ByteArray> writeQueue;

    private static bool isClosing = false;

这些属性是NetManager的基本字段,十分重要。是实现网络通讯的基础。

网络事件处理

为了更好的可扩展性,

我们使用观察者模式,也就是采用事件驱动模式。

在这种模式中,存在一个发布者(Subject)和多个订阅者(Observers),订阅者会注册(监听)某些事件,当事件发生时,所有订阅该事件的订阅者都会被通知并触发相应的处理逻辑。

这样可以在实现具体方法的时候,方便的触发各种方法。

下面是实现观察者模式的逻辑

网络事件枚举类型

首先利用一个枚举类型NetEvent,存储常用的网络事件名称,该类型作为监听器的key值,用来触发对应事件,方便后续调用。

(其实当然也可以用其他类型,来定义监听器的key值,比如直接用string注册,但是不方便调用,可能会出错)

    public enum NetEvent
    {
        ConnectSucc = 1,
        ConnectFail = 2,
        Close = 3,
    }

实现观察者模式/事件驱动核心部分
1.定义委托

类型名EventListener(参数为string)。

作为监听者的模板,在事件触发的时候,监听该事件的监听者被会触发。

2.定义一个事件列表,

为字典类型<NetEvent,EventListener>

用于记录每种网络事件对应的回调方法。存储了不同网络事件及其对应事件的监听者。

3.实现 添加事件监听者 与 删除事件监听者 的方法。

其中的if/else逻辑是:

如果这个事件不存在就创建一个事件,并为其添加监听者。如果已经存在,就在原事件上再添加监听者。

4.实现 事件触发方法。

方法需要一个网络事件类型,和一个字符串(用于作为被触发的监听器的参数)

具体源代码:

 #region 事件处理

    //事件委托类型
    public delegate void EventListener(string err);
    //事件监听列表
    private static Dictionary<NetEvent, EventListener> 
        eventListeners = new Dictionary<NetEvent,EventListener>();
    
    //添加事件监听
    public static void AddEventListener(NetEvent netEvent, EventListener listener)
    {
        if (eventListeners.ContainsKey(netEvent))
        {
            eventListeners[netEvent] += listener;
        }
        else
        {
            eventListeners[netEvent] = listener;
        }
    }
    
    //删除事件监听
    public static void RemoveEventListenner(NetEvent netEvent, EventListener listener)
    {
        if (eventListeners.ContainsKey(netEvent))
        {
            eventListeners[netEvent] -= listener;
        }
        else
        {
            eventListeners.Remove(netEvent);
        }
    }
    
    
    #endregion

 
    //分发(触发)事件
    public static void FireEvent(NetEvent netEvent, string err)
    {
        if (eventListeners.ContainsKey(netEvent))
        {
            eventListeners[netEvent](err);
        }
        else
        {
            throw new Exception("不存在此事件");
        }
    }
   

具体基本网络通讯功能的实现(向服务器发送请求)

使用套接字进行端对端连接

其中观察者在其中用于触发连接成功与失败的提示

1.Connet

功能要求:根据ip和端口,向服务器发起连接。

实现逻辑,初始化状态->发起连接->连接回调

具体源代码:

#region Connect

    private static bool isConnecting = false;

    public static void Connect(string ip, int port)
    {
        //状态判断
        if (socket != null && socket.Connected)
        {
            Debug.Log("Connect fail,is already connected");
        }

        if (isConnecting)
        {
            Debug.Log("Connect fail is connecting");
        }
        InitState();
        socket.NoDelay = true;
        socket.BeginConnect(ip, port,ConnectCallback, socket);

    }

    private static void ConnectCallback(IAsyncResult ar)
    {
        try
        {
            Socket socket = ar.AsyncState as Socket;
            socket.EndConnect(ar);
            Debug.Log("Socket Connect Succ ");
            FireEvent(NetEvent.ConnectSucc,"");
            isConnecting = false;
        }
        catch (SocketException ex)
        {
            Debug.Log("Socket connect fail" + ex.ToString());
            FireEvent(NetEvent.ConnectFail,ex.ToString());
            isConnecting = false;
        }
    }
    
    //初始化状态
    private static void InitState()
    {
        socket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
        readBuff = new ByteArray();
        writeQueue = new Queue<ByteArray>();
        isConnecting = false;
        isClosing = false;
    }
    

    #endregion
2.Close 

功能要求:关闭客户端与服务器的连接

#region Close

    public static void Close()
    {
        //状态判断
        if (socket == null || !socket.Connected)
        {
            return;
        }

        if (isConnecting)
        {
            return;
        }
        
        //还有数据在发送
        if (writeQueue.Count > 0)
        {
            isClosing = true;
        }
        else
        {
            socket.Close();
            FireEvent(NetEvent.Close,"");
        }
    }

    #endregion
3.Send(该事件需要先实现协议类) 

功能实现:将消息从客户端发送给服务器

逻辑:先进行条件检测有没有连接上服务器,

再对发送的数据编码,组装长条,组装名字,组装消息体(其中编码部分由消息类自己的方法实现)组织成功后,将消息写入发送队列。

然后,再调用发送消息指令,并且在发送回调当中。

其中在发送回调中,为实现数据的完整发送,还有更复杂的发送逻辑

#region Send
    
    public static void Send(MsgBase msg)
    {
        if (socket == null || !socket.Connected)
        {
            return;
        }

        if (isConnecting)
        {
            return;
        }

        if (isClosing)
        {
            return;
        }
        //数据编码
        byte[] nameBytes = MsgBase.EncodeName(msg);
        byte[] bodyBytes = MsgBase.Encode(msg);
        int len = nameBytes.Length + bodyBytes.Length;
        byte[] sendBytes = new byte[2 + len];
        //组装长度
        sendBytes[0] = (byte)(len % 256);
        sendBytes[1] = (byte)(len / 256);
        //组装名字
        Array.Copy(nameBytes,0,sendBytes,2,nameBytes.Length);
        //组装消息体
        Array.Copy(bodyBytes,0,sendBytes,2+nameBytes.Length,bodyBytes.Length);
        //写入队列
        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);
        }

    }

    public static void SendCallback(IAsyncResult ar)
    {
        //获取state EndSend处理
        Socket socket = ar.AsyncState as Socket;
        //状态判断
        if (socket == null || !socket.Connected)
        {
            return;
        }
        //EndSend
        int count = socket.EndSend(ar);
        //获得写入队列第一条数据
        ByteArray ba;
        lock (writeQueue)
        {
            ba = writeQueue.First();
        }
        //完整发送
        ba.readIdx += count;
        if (ba.length == 0)
        {
            lock (writeQueue)
            {
                writeQueue.Dequeue();
                ba = writeQueue.First();
            }
        }
        
        //继续发送
        if (ba != null)
        {
            socket.BeginSend(ba.bytes, ba.readIdx, ba.length, 0, SendCallback, socket);
            
        }
        
        //正在关闭
        else if(isClosing)
        {
             socket.Close(9);
        }
    }
    
    #endregion

 接收来自服务器的数据 Receive

情景:因为网络的不稳定因素,对于来着服务器消息的接收,需要更复杂的实现逻辑,来应对各种多样的情况。

所以,为了让客户端可以处理更复杂的消息接受情况,使用一个列表,然后不断从上往下更新处理列表中的数据。

特点如下:

1.每次update处理多条数据、

2.处理粘包半包、大小端判断、

3.使用json协议

Receive部分
    #region 接收数据

    //消息列表
    private static List<MsgBase> msgList = new List<MsgBase>();
    //消息列表长度
    private static int msgCount = 0;
    //每一次update处理的消息量
    private readonly static int MAX_MESSAGE_FIRE = 10;
    
    //初始化状态
    private static void InitMsgListState()
    {
        msgList = new List<MsgBase>();
        msgCount = 0;
    }
    
    //Receive回调 在Connet中调用
    public static void ReceiveCallBack(IAsyncResult ar)
    {
        try
        {
            Socket socket = ar.AsyncState as Socket;
            //获取接收数据长度
            int count = socket.EndReceive(ar);
            if (count == 0)
            {
                Close();
                return;
            }

            readBuff.writeIdx += count;
            //处理二进制消息
            OnReceiveData();
            //继续接收数据
            if (readBuff.remain < 8)
            {
                readBuff.MoveBytes();
                readBuff.ReSize(readBuff.length*2);
            }

            socket.BeginReceive(readBuff.bytes, readBuff.writeIdx, readBuff.remain, 0, ReceiveCallBack, socket);
        }
        catch (SocketException ex)
        {
            Debug.Log(ex.ToString());
        }
    }
    
    //数据处理
    public static void OnReceiveData()
    {
        //消息长度
        if (readBuff.length <= 2)
        {
            return;
        }
        //获取消息体长度
        int readIdx = readBuff.readIdx;
        byte[] bytes = readBuff.bytes;
        Int16 bodyLength = (Int16)((bytes[readIdx + 1] << 8) | bytes[readIdx]);
        if (readBuff.length < bodyLength + 2)
        {
            return;
        }

        readBuff.readIdx += 2;
        //解析协议名
        int nameCount = 0;
        string protoName = MsgBase.DecodeName(readBuff.bytes, readBuff.readIdx, out nameCount);
        if (protoName == "")
        {
            Debug.Log("Fail Decode protoName");
            return;
        }

        readBuff.readIdx += nameCount;
        //解析协议体
        int bodyCount = bodyLength - nameCount;
        MsgBase msgBase = MsgBase.Decode(protoName, readBuff.bytes, readBuff.readIdx, bodyCount);
        readBuff.readIdx += bodyCount;
        readBuff.CheckAndMoveBytes();
        //添加到消息队列
        lock (msgList)
        {
            msgList.Add(msgBase);
        }

        msgCount++;
        //继续读取消息
        if (readBuff.length > 2)
        {
            OnReceiveData();
        }
    }
    #endregion
更新处理消息列表

得到的消息存储在msgList后,需要不断更新判断,是否需要处理并处理消息。

需要在另一个带有monobehavior的类中,使用Update去调用NetManager.Update启动消息处理。

1.每次update处理多条数据、

#region Update

    public static void Update()
    {
        MsgUpdate();
    }

    public static void MsgUpdate()
    {
        if (msgCount==0)
        {
            return;
        }
        //重复处理消息
        for (int i = 0; i < MAX_MESSAGE_FIRE; i++)
        {
            MsgBase msgBase = null;
            lock (msgBase)
            {
                if (msgList.Count>0)
                {
                    msgBase = msgList[0];
                    msgList.RemoveAt(0);
                    msgCount--;
                }
            }
            //分发消息
            if (msgBase != null)
            {
                FireMsg(msgBase.protoName,msgBase);
            }
            //没有消息了
            else
            {
                break;
            }
        }
    }

    #endregion

处理接收到的来自服务器的消息

首先,同样利用观察者模式,对发来的不同类型的消息,触发不同事件,从而调用不同的事件监听者,对消息进行不同逻辑的处理。

简单来说,就是根据消息名,来触发不同事件。

消息事件处理 (观察者模式)

与上文的网络事件类似,此观察者用于处理接收到的消息。

1.定义委托类型

2.定义一个事件列表,为字典类型,用于记录每种网络事件,与其对应的监听者。

3.实现添加事件与删除事件还有触发

#region 消息事件
    //消息委托类型
    public delegate void MsgListener(MsgBase msgBase);
    //消息监听列表
    public static Dictionary<string, MsgListener> MsgListeners = new Dictionary<string, MsgListener>();

    public static void AddMsgListener(string msgName, MsgListener msgListener)
    {
        //添加
        if (MsgListeners.ContainsKey(msgName))
        {
            MsgListeners[msgName] += msgListener;
        }
        //新增
        else
        {
            MsgListeners[msgName] = msgListener;
        }
    }

    public static void RemoveMsgListener(string msgName, MsgListener msgListener)
    {
        if (MsgListeners.ContainsKey(msgName))
        {
            MsgListeners[msgName] -= msgListener;
            //删除
            if (MsgListeners[msgName] == null)
            {
                MsgListeners.Remove(msgName);
            }
        }
    }

    public static void FireMsg(string msgName, MsgBase msgBase)
    {
        if (MsgListeners.ContainsKey(msgName))
        {
            MsgListeners[msgName](msgBase);
        }
    }
    #endregion

小结语

完成上面这些部分,网络管理者类就基本完成了,主要由网络事件处理消息事件处理、接收消息更新队列为基础,来实现客户端与服务器的连接发送消息接收消息处理消息关闭连接的功能。

2.协议类protocol

协议类是并不是一个类,而是多种类的统称,具体的可以有比如说json协议类,protobuf协议类。

继承关系:具体消息类 继承 某一协议基类。

功能:

协议基类封装了具体协议编码与解码的功能,

而继承的具体消息子类,以对象的方式,存储了具体消息的数据。

消息的发送处理流程

消息的发送需要经历以下步骤

对象 <--协议解析--> 字符串 <--编码/解码--> 比特数组

Json协议为例

利用unity提供的JsonUtility来实现json协议的编码解码。并以此构建json协议的消息基类。

协议格式

使用长度信息法,在消息前面加上2字节的长度,并接上协议名与协议体。防止粘包与半包

具体消息

以MsgBase为基类,派生出各种不同消息类。

    public class MsgMove : MsgBase
    {
        public MsgMove()
        {
            protoName = "MsgMove";
        }

        public int x = 0;
        public int y = 0;
        public int z = 0;
    }

协议基类(以json协议为例)

处理数据的编码与解码

下述是完整的消息基类的代码实现

public class MsgBase
{
    //协议名
    public string protoName = " ";
    //编码 对象->字符->bytes
    public static byte[] Encode(MsgBase msgBase)
    {
        string s = JsonUtility.ToJson(msgBase);
        return Encoding.UTF8.GetBytes(s); //以UTF8的格式(transform to 8 bit)转化为比特格式
    }
    
    //解码
    public static MsgBase Decode(string protoName, byte[] bytes, int offset, int count)
    {
        string s = Encoding.UTF8.GetString(bytes,offset,count);
        MsgBase msgBase = (MsgBase)JsonUtility.FromJson(s, Type.GetType(protoName));
        return msgBase;
    }

    //bytes->字符->对象
    public static byte[] EncodeName(MsgBase msgBase)
    {
        //名字与bytes长度
        byte[] nameBytes = Encoding.UTF8.GetBytes(msgBase.protoName);
        Int16 len = (Int16)nameBytes.Length;
        //申请bytes数值
        byte[] bytes = new byte[2 + len];
        //组装2字节长度信息
        bytes[0] = (byte)(len % 256); 
        bytes[1] = (byte)(len / 256);
        //组装名字bytes
        Array.Copy(nameBytes,0,bytes,2,len);
        return bytes;
    }

    public static string DecodeName(byte[] bytes, int offset, out int count)
    {
        count = 0;
        //必须大于2字节
        if (offset + 2 > bytes.Length)
        {
            return "";
        }
        
        //读取长度
        Int16 len = (Int16)((bytes[offset + 1] << 8) | bytes[offset]);
        if (len <= 0) return "";
        
        //长度必须足够
        if (offset + 2 + len > bytes.Length) return " ";
        
        //解析
        count = 2 + len;
        string name = Encoding.UTF8.GetString(bytes, offset + 2, len);
        return name;

    }
}

3.ByteArray(消息的基本数据结构,提高效率)

懒得写描述了哈哈,直接看实现吧

public class ByteArray : MonoBehaviour
{
    //默认大小
    private const int DEFAULT_SIZE = 1024;
    //初始大小
    private int iniSize = 0;
    //缓冲区
    public byte[] bytes;
    //读写位置
    public int readIdx = 0;
    public int writeIdx = 0;
    
    //容量
    private int capacity = 0;
    //剩余空间
    public int remain
    {
        get { return capacity - writeIdx; }
    }
    public int length
    {
        get { return writeIdx - readIdx; }
    }

    //构造函数
    public ByteArray(int size = DEFAULT_SIZE)
    {
        bytes = new byte[size];
        capacity = size;
        iniSize = size;
        readIdx = 0;
        writeIdx = 0;
    }
    
    //构造函数
    public ByteArray(byte[] defaultBytes)
    {
        bytes = defaultBytes;
        readIdx = 0;
        writeIdx = defaultBytes.Length;
    }

    public void ReSize(int size)
    {
        if(size<length) return;
        if(size<iniSize) return;
        int n = 1;
        while (n<size)
        {
            n *= 2;
        }

        capacity = n;
        byte[] newBytes = new byte[capacity];
        Array.Copy(bytes,readIdx,newBytes,0,writeIdx-readIdx);
        bytes = newBytes;
        writeIdx = length;
        readIdx = 0;
    }

    public void CheckAndMoveBytes()
    {
        if (length<8)
        {
            
        }
    }

    public void MoveBytes()
    {
        if (length>0)
        {
            Array.Copy(bytes,readIdx,bytes,0,length);
        }

        writeIdx = length;
        readIdx = 0;
    }

    public int Write(byte[] bs, int offset, int count)
    {
        if (remain < count)
        {
            ReSize(length+count);
        }
        Array.Copy(bs,offset,bytes,writeIdx,count);
        writeIdx += count;
        return count;
    }

    public int Read(byte[] bs, int offset, int count)
    {
        count = Math.Min(count, length);
        Array.Copy(bytes,readIdx,bs,offset,count);
        readIdx += count;
        CheckAndMoveBytes();
        return count;
    }
}

使用流程

如果说我想要和服务器通讯,要经过以下步骤。

大前提是在客户端启动的时候,客户端先根据ip和端口,建立与服务器的连接。

然后,首先,服务器约定好双发传递消息的协议,具体来说,要写一个消息类(里面约定好数据类型)。

在想要发送同步的消息的时候,先构建好准备发送的消息类,然后在需要的游戏逻辑中使用NetManager.Send(MsgBase)发送消息类。

实现接受通过服务器传来的其他客户端的同步消息,要在消息事件处理中,写好对应消息类的处理

尾声

上述是我对《Unity3D网络游戏实战》第三版第六章的个人笔记,用来梳理以下我对客户端网络模块结构的认识,更详细的可以自己买一本来学习。

后面还有一些模块没写如心跳机制,Protobuf协议。

项目的源码我会在后面几天发到我的github上,可以收藏后过几天来看看。

后续还会补上服务端部分,感兴趣的可以关注。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值