2016-11-30 13:21:43 qq_16763249 阅读数 9475

大家好,我是FoldCc,今天给大家分享一下自己设计并且经常用到的一个网络通信框架---客户端

经过自己的实践,发现这套框架在应对一些中小型手游还是比较稳定使用的,整体结构也比较简单,但是特别实用,唯一要注意一点的是在开发网络通信时,一定要注意多线程争用资源的问题。

下面我为大家详细介绍一下:

首先最核心的是Socket连接器 它的功能主要有4个

》向服务器发起连接请求

》一个能够返回已经连接服务器的Socket的方法(仅用于之后开启接收和发送消息线程)

》判断当前是否连接正常

》断开服务器连接

当连接器连接成功后会自动创建两个线程,分别用来接收和发送,(至于为什么要单独分开成两个,是因为在实际应用中一个线程处理这两个效率上低,容易出问题,并且理论上来说一个线程同时处理发送和接收也有点不合理)为了防止接收和发送线程在处理消息可能出现效率跟不上的问题,我设置了两个消息队列,分别用来装发送的消息和接收的消息,其中接收的消息通过消息分类器分类到不同队列中,这里需要注意的一点是,接收消息需要做粘包处理!说通俗一点就是判断消息的完整性,这里我为所有消息都加了一个特定的消息头 和消息尾 用来判断消息的完整性,至于消息头和消息尾的内容由自己设计,最好是不常使用的一些特殊字符,防止遇到和内容相同的情况。

两个线程则只负责将收到的消息往队列中存储以及将发送队列的消息按顺序发送就行了,消息分类会单独去对消息进行分类并存放到对应队列中。

下面是这一套框架的流程图,如果有用大家帮忙转载哦!大笑

2018-03-17 13:10:57 MadBam_boo 阅读数 887

Unity3d实战之Unity3d网络游戏实战篇(9):协议

学习书籍《Unity3d网络游戏实战》 罗培羽著 机械工业出版社
本文是作者在学习过程中遇到的认为值得记录的点,因此引用的代码等资源基本出资罗培羽老师的书籍,如有侵权请联系,必删。

 一套通用的服务端框架要支持不同游戏所使用的各种协议格式。所谓协议就是通信规则,例如:如果发送端直接发送一串数值数据给接收端,接收端并不知道这一串数据到底是用来做什么的,里面的数据代表了什么信息。而协议就是解决这一问题的一套规则,一套发送端和接收端都知道的规则。在要发送的数据前添加某些标志,发送端和接收端共同规定带有这个标志的数据的用途。这样,两端就可以对接收到的数据进行解析,并根据两者共同定立的规则来分辨所接收到的数据的用途。
 
 所有的协议都有相同的方法:解码、编码、获取协议名等等,以此为基础,我们可以编写一个协议的基类:
 

using System;

namespace Server
{
    public class ProtocolBase
    {
        /// <summary>
        /// Decode the readBuff from start to start+length. Package readBuff as a protocol and return it.
        /// </summary>
        /// <param name="readBuff">Read buff.</param>
        /// <param name="start">Type: int, decode begin from start.</param>
        /// <param name="length">Type: int, decode from begin to start+length.</param>
        public virtual ProtocolBase Decode(byte[] readBuff, int start, int length)
        {
            return new ProtocolBase ();
        }

        /// <summary>
        /// Encode the protocol's content to binary array.
        /// </summary>
        public virtual byte[] Encode()
        {
            return new byte[] { };
        }

        /// <summary>
        /// Return the protocol's name.
        /// </summary>
        /// <returns>The name.</returns>
        public virtual string GetName()
        {
            return "";
        }

        /// <summary>
        /// Return the protocol's description.
        /// </summary>
        /// <returns>The desc.</returns>
        public virtual string GetDesc()
        {
            return "";
        }
    }
}

 根据需要可以定义不同类型的协议,不同类型的协议的编码解码方式不同,如字符串协议会议字符串形式解析接收到的协议,字节流协议会以字节方式解析协议。下面给出字节流代码:

using System;
using System.Linq;

namespace Server
{
    /// <summary>
    /// Bytes Protocol
    /// Protocol Format:
    ///     [protocollength][protocol name][data1][data2]...
    ///     protocollength: Int32 -> byte
    ///     protocol name:  byte
    ///     data:   byte
    /// </summary>
    public class ProtocolBytes : ProtocolBase
    {
        public byte[] bytes;

        public override ProtocolBase Decode (byte[] readBuff, int start, int length)
        {
            ProtocolBytes proto = new ProtocolBytes ();
            proto.bytes = new byte[length];
            Array.Copy (readBuff, start, proto.bytes, 0, length);
            return (ProtocolBase)proto;
        }

        public override byte[] Encode ()
        {
            return bytes;
        }

        public override string GetName ()
        {
            return GetString (0);
        }

        public override string GetDesc ()
        {
            if (bytes == null)
                return "";
            string str = "";
            for (int i = 0; i < bytes.Length; i++) {
                int t = (int)bytes [i];
                str += t.ToString () + " ";
            }

            return str;
        }

        #region Help Function
        #region String Operation
        /// <summary>
        /// convert the str into bytes and connect it behind the protocol.
        /// </summary>
        /// <param name="str">the connected string</param>
        public void AddString(string str)
        {
            Int32 length = str.Length;
            byte[] lenBytes = BitConverter.GetBytes (length);
            byte[] strBytes = System.Text.Encoding.UTF8.GetBytes (str);
            if (bytes == null)
                bytes = lenBytes.Concat (strBytes).ToArray ();
            else
                bytes = bytes.Concat (lenBytes).Concat (strBytes).ToArray ();
        }
        /// <summary>
        /// Get a complete string from start to end.
        /// </summary>
        /// <returns>string</returns>
        /// <param name="start">start index</param>
        /// <param name="end">end index</param>
        public string GetString(int start, ref int end)
        {
            if (bytes == null)
                return "";
            if (bytes.Length < start + sizeof(Int32))
                return "";

            Int32 strLen = BitConverter.ToInt16 (bytes, start);
            if (bytes.Length < start + sizeof(Int32) + strLen)
                return "";

            string str = System.Text.Encoding.UTF8.GetString (bytes, start + sizeof(Int32), strLen);
            end = start + sizeof(Int32) + strLen;
            return str;
        }
        /// <summary>
        /// Get a complete string begin at start.
        /// </summary>
        /// <returns>string</returns>
        /// <param name="start">start index</param>
        public string GetString(int start)
        {
            int end = 0;
            return GetString (start, ref end);
        }
        #endregion

        #region Int32 Operation
        /// <summary>
        /// convert the Int32 into bytes and connect it behind the protocol.
        /// </summary>
        /// <param name="num">Number.</param>
        public void AddInt32(Int32 num)
        {
            byte[] numBytes = BitConverter.GetBytes (num);
            if (bytes == null)
                bytes = numBytes;
            else
                bytes = bytes.Concat (numBytes).ToArray ();
        }
        /// <summary>
        /// Get a complete Int32 num from protocol's start to end.
        /// </summary>
        /// <returns>Int32</returns>
        /// <param name="start">start index</param>
        /// <param name="end">end index</param>
        public Int32 GetInt32(int start, ref int end)
        {
            if (bytes == null)
                return 0;
            if (bytes.Length < start + sizeof(Int32))
                return 0;
            end = start + sizeof(Int32);
            return BitConverter.ToInt32 (bytes, start);
        }
        /// <summary>
        /// Get a complete Int32 num from protocol's start.
        /// </summary>
        /// <returns>Int32.</returns>
        /// <param name="start">start index</param>
        public Int32 GetInt(int start)
        {
            int end = 0;
            return GetInt (start, ref end);
        }
        #endregion

        #region Float Operation
        /// <summary>
        /// convert the float into bytes and connect it behind the protocol.
        /// </summary>
        /// <param name="num">float number</param>
        public void AddFloat(float num)
        {
            byte[] numBytes = BitConverter.GetBytes (num);
            if (bytes == null)
                bytes = numBytes;
            else
                bytes = bytes.Concat (numBytes).ToArray ();
        }
        /// <summary>
        /// Get a complete float num from protocol's start to end.
        /// </summary>
        /// <returns>The float.</returns>
        /// <param name="start">start index</param>
        /// <param name="end">end index</param>
        public float GetFloat(int start, ref int end)
        {
            if (bytes == null)
                return 0;
            if (bytes.Length < start + sizeof(float))
                return 0;
            end = start + sizeof(float);
            return BitConverter.ToSingle (bytes, start);
        }
        /// <summary>
        /// Get a complete float num from protocol's start.
        /// </summary>
        /// <returns>The float.</returns>
        /// <param name="start">start index</param>
        public float GetFloat(int start)
        {
            int end = 0;
            return GetFloat (start, ref end);
        }
        #endregion
        #endregion
    }
}

 使用Help Function中的方法可以对协议进行一定的操作,如添加协议名、添加数据等。
 我一开始在这里陷入了混乱,为什么添加String时需要打包string长度,而添加int和float时就不需要在数据前添加数据长度,后来发现这是因为string的长度是不确定的,而int和float的长度都是确定的,如Int32的长度为4byte,在获取Int时,BitConverter可以直接获取start位置往后4byte的数据并将它转化为Int32类型,显然string就不行,因此它需要添加数据长度信息。

 在完成协议后,我们就可以使用协议进行通信了,在发送协议时记得要给在发送的数据包前加上整条协议的长度信息。

2018-07-03 20:00:09 aawoe 阅读数 7944

Unity框架设计

将Unity Api、.NetFramework Api(4.6)以及部分原生库和托管库封装到一个抽象层,游戏本身的业务仅依赖于该抽象层从而提高业务逻辑的独立性和可维护性。 框架部分提供项目中使用的基础设施,包括资源管理、网络通信、UI框架、消息管理、场景管理、数据解析及存取等。

1.   资源管理

资源管理模块负责按照划分场景的颗粒度将所有游戏资源均打包至AssetBundle并在游戏中动态更新与加载,打包前需要将相资源索引文件和二进制资源(AB)放在StreamingAssetsPath路径下,在游戏初次运行时将所有资源拷贝至可读写路径PersistentDataPath下,在游戏更新阶段从服务器下载更新配置文件并根据本地资源的MD5更新资源文件,场景载入阶段异步加载场景需要的AssetBundle,资源加载时根据资源索引文件加载资源,离开场景时卸载相关AssetBundle,下图是流程示意图。

除了使用AssetBundle,Unity还支持使用Resources的动态加载资源方案,对比之下使用AssetBundle的主要优势为:可以较好控制游戏资源所占内存;可以从外部可读写路径加载所以支持热更新;可以实现边下边玩减小初始安装包体大小。AssetBundle的劣势为:资源之间的依赖关系难以处理,容易造成资源冗余(自己在处理这部分时遇到很多问题,处理不当场景中引用的资源也会和AssetBundle资源重复); 需要额外的编辑器扩展支持增加复杂度。考虑到该项目为联网游戏所以选择使用AssetBundle进行游戏资源管理。

资源管理策略:

资源索引文件是一个对称加密的哈希表,作为配置文件存放在本地的某个可读写目录,内容为由资源名和资源所在AssetBundle名构成的键值对。资源管理模块初始化时会加载该资源索引文件并在整个游戏的生命周期中保持资源名和AssetBundle名的映射,资源管理模块也会一直保持已加载AssetBundle镜像的引用,但是这些对业务层均是不可见的。

考虑到AssetBundle的占用内存较大,且资源之间引用比较复杂,默认情况下,在加载AssetBundle的同时也加载其内所有资源,且均使用异步加载。 卸载AssetBundle时会一并卸载其对应的所有资源。

考虑到通用资源的使用频率较高,资源管理模块提供一种策略,在加载通用AssetBundle时可以选择同时加载其内的所有资源并在整个游戏生命周期保留其引用,之后便卸载AssetBundle镜像,这样在没有增大游戏使用内存的前提下加快这部分资源的访问速度。

该方案的缺点是不同AssetBudle中的资源不允许重名,因而带来的优势为加载资源时只需提供资源名和资源类型(因为资源名和AssetBundle名存在一对一映射关系)从而隐藏了AssetBundle的复杂性。

ResourceManager

负责资源管理模块初始化,和服务器比对资源配置文件更新本地AB,更新资源索引文件,异步加载AssetBundle,卸载AssetBundle,加载游戏资源等功能。

AssetBundleHelper

作为Editor扩展, 负责生成本地资源索引文件和资源配置文件,上传资源文件至服务器。

AssetFileInfo

记录资源文件的基本信息,包括资源名、MD5值、下载路径、文件夹路径、资源大小等。

 

不足和反思:

a.    当前框架的更新是不可配置的,也就是将各种url,文件夹路径全都写死在了代码里,这在很大程度上限制了其通用性和扩展性,理想情况下最好将各种资源的配置以文件的形式存储,然后资源管理模块通过读取该文件进行初始化。

b.    这种方案要求不同AssetBundle中不允许重名资源,这一点如果在多人协作的时候很不容易保证,就算只有一个人管理也会不小心触发(Unity资源目录不同文件夹下的资源允许重名), 所以需要一个统一的资源命名规范,甚至需要一个工具来检查。

c.    有部分资源没有纳入资源管理模块的体系,如果场景引用了某个图集中的图片,那么在打包时会在本地拷贝整个图集会造成很大的资源冗余,所以只能选择拷贝一份重复的图片在场景中加以引用,但是如果这样同样会造成一部分资源冗余,同时这部分资源也无法热更新,目前仍没找到较好的解决方案。

d.    AssetBundleHelper的Editor扩展部分比较坑,需要运行游戏才能进行上传资源文件等联网操作(因为调用了网络模块),并且该操作也是不可取消的,这一部分有较大优化空间,可以单独写一个客户端来提供这部分功能。

e.    本地化问题,当项目做大的时候本地化是一个比较棘手的问题,当前的资源管理框架完全没有考虑本地化相关问题,该模块应当提供一种友好的方式来区分不同语言文化的资源并控制它们的加载。

f.     最大的不足是没有引入脚本资源的热更新,因为当时考虑到学习成本和开发成本,没有在框架中加入这部分内容,但是对于联网游戏来说代码的热更新十分重要,比较成熟的热更框架据说有uLua,xlua,ILRuntime,后续还需要继续学习。

 

2.   网络通信

网络通信模块负责向业务层提供短连接服务(Http请求)和Tcp长连接服务。

Http请求:

网络通信模块的Http请求使用了System.Net.Http.dll库,这个托管库是微软对HttpWebRequest封装的一层Http请求接口,网络通信模块在此之上进行封装实现异步Get/Post请求、下载文件等静态方法。

当然Unity本身也提供了很多Http请求的实现方式,例如WWW, UnityWebRequest均可以进行Http请求,但这两者都是基于协程实现的并发而不是并行,网络请求操作仍然是在主线程中执行。该项目使用Unity2017(.Net 4.6)开发,对多线程的支持已经比较完备,同时支持基于任务的异步编程模型,考虑将网络和I/O放在线程池中执行可以减小主线程压力,同时充分利用多核设备性能提高执行效率,所以设计网络通信模块时没有选用WWW和UnityWebRequest而是使用System.Net.Http.HttpClient。

HttpRequestClient:

实现并重载Http请求的多种静态异步方法,包括GetAsync(Get请求)、PostAsync(Post请求)、DownloadFileAsync(下载文件请求)。

HttpResponseData:

Http请求返回的数据格式(和后端约定),包括错误码、数据、消息等。

Tcp客户端:

网络通信模块基于System.Net.Sockets.TcpClient封装了异步阻塞模式的Tcp客户端(抽象基类BaseTcpClient),并提供了Tcp客户端常用方法的默认实现,包括连接、关闭连接、自动心跳、发送消息、包序排列、粘包分包处理、断线重连、断线回调、接收消息回调等功能。子类构造方法中需要提供IP地址/域名和端口即可创建一个Tcp客户端实例,一般情况下子类应当重写断线回调方法和接收消息回调方法,同时定义了默认的通信数据类型(前后端通信数据协议默认使用json)以支持Tcp客户端实现。

每个Tcp客户端实例内部会维护两个线程,一个线程用于定时发送自定义心跳包,一个线程用于阻塞等待接收消息包。Tcp客户端基类没有继承自MonoBehaviour,这样设计的初衷是希望让Tcp客户端在于游戏流程之外作为一个更封闭更独立的模块存在,Tcp客户端的生命周期管理方式也和Unity GameObject的生命周期不同,所以没必要继承MonoBehaviour。在实例化并连接成功之后,Tcp客户端的功能就是推动游戏流程,业务层应当订阅Tcp客户端中感兴趣的事件,当Tcp客户端收到消息包时便会把该消息包的处理方法派发到主线程消息队列,在主线程中执行订阅该事件的方法(因为绝大多数Unity Api只能在主线程中调用)  因为没有继承MonoBehaviour所以设计心跳包功能时没有选择在游戏周期的使用协程定时发送,而是单独开一个线程。

在处理粘包分包问题上,网络通信模块使用自定义包格式(包头+包体)的方式来处理。前后端通信的每个包在发送之前都会在原有数据之前添加4个字节的包头表示包体大小,客户端通过计算包头来获取每个包体,不完整的包则临时存储在缓冲区。

BaseTcpClient:

Tcp客户端基类,实现了常用方法,包括ConnectAsync(异步连接)、Close(关闭连接)、Reconnect(端线重连)、Send(发送消息)、OnReceived(接收消息回调)、OnLoseConnect(断线回调)等。

TcpRecvData:

默认实现的接收消息包的数据类型(和后端约定),包括包序、类型、数据(object,业务层解析)、错误码、消息。

TcpSendData:

默认实现的发送消息包的数据类型(和后端约定),包括用户ID、类型、数据(object,业务层解析)。

 

不足和反思:

a.    下载文件需要断点续传,这部分功能还需要进行优化,尤其是在更新资源包大小较大的时候,如果出现网络波动导致下载的进度丢失会给玩家造成较差的游戏体验。

b.    很多大佬都在建议使用Protobuf进行数据的结构化存储,但是该框架整体的数据存储和通信均使用json,Protobuf有更优的性能,占用更小的内存,所以将整个框架中的json数据存储方案替换为Protobuf存储方案会提高一些性能。

c.    目前仍没有实现UDP客户端,当前的网络通信模块没有实现一个UDP客户端基类,不排除项目之后有使用UDP协议通信的可能。

d.    异步方法的取消,整个框架中的大部分异步方法都是不支持取消的,尤其是网络和I/O部分有大量的异步方法,对于玩家来讲等待时间过久而又无法取消的体验是极不友好的,参照网上的方案可以在一些类型中实现接口以支持取消操作。

e.    关于自动发送心跳包和业务层发送消息引发的线程同步问题,这个问题目前还不是很清晰,因为业务层发送消息在主线程,自动发送心跳包在线程池,二者有可能同时执行发送消息的操作,而且网络流Netw orkStream好像不是线程安全的(不确定),在实践中后端反应过出过问题,但是把所有心跳操作都派发到主线程执行又违背了设计的初衷(封闭和独立),降低了这部分模块的扩展性(没法拿到别处用)。

 

3.   UI框架

UI框架基于UGUI简单封装了UI窗体的各种属性和方法,同时提供UI管理器作为业务层调用的接口。

把游戏中所有的2D可视元素均可以看作为窗体或窗体的一部分,例如一个设置界面、一个消息确认界面、一条吐司通知都可以抽象为一个窗体Form。但是在Unity中设计这样一个界面需要很多游戏物体组合,所以为了方便管理,将该窗体本身的显示元素均置于一个空的游戏物体StaticLayout。然后还要引入父窗体、子窗体、兄弟窗体的概念,因为考虑一个窗体在交互时会很可能产生隶属于该窗体的其它窗体,如果生成的窗体的生命周期应当小于等于原窗体的生命周期,那么它们就有逻辑上的父子关系,此时生成的窗体是原窗体的子窗体,原窗体是生成窗体的父窗体,拥有同一父窗体的窗体互为兄弟窗体,单独拿出每一个子窗体同样是一个独立的窗体整体。为了方便管理子窗体,所有子窗体都根据显示类型置于StackNode或NormalNode下。这三个节点(StaticLayout, StakcNode, NormalNode)统一挂在同一游戏物体下,并将Form组件添加到该游戏物体上,此时这个游戏物体在逻辑上就相当于一个窗体,将该游戏物体做成预制体资源,可以方便的在Inspector面板配置该窗体的属性(如显示类型, 预制名称 预制路径)和特性(如支持拖动, 模态显示, 固定显示, 全局唯一, 等),而窗体名称则需要在实例化该窗体时指定,下图是窗体层次结构示意图:

根据该层次结构也引入一个概念“虚拟层级路径”,是一个字符串,通过拼接根窗体和该窗体之间所有层次的窗体名称来作为唯一标识该窗体的索引。窗体在创建的时候有两种显示模式:堆栈和普通,任何时候堆栈模式的所有子窗体里只有一个子窗体是具有焦点的(可操作),而除此之外的其它子窗体均处在冻结状态,堆栈模式的子窗体任何时候只有栈顶的窗体具有焦点,当点击任一窗体或创建了新的子窗体时对应窗体移至栈顶。而普通模式的子窗体均是可操作的。

UIBaseForm:

是所有自定义窗体的基类,实现了一些窗体的特性和方法并提供了默认实现,子类可以重写InitContent(初始化窗体)、Show(显示窗体)、Freezed(冻结窗体)、Destroy(销毁窗体)、Recovery(从冻结状态恢复)等方法以便在UI窗体状态改变时更新其界面。

UIManager:

UI管理器,其内部有多个数据结构维护所有存在窗体的父子/兄弟关系,对外公开了CreateForm(创建窗体)、CloseForm(销毁窗体)等方法。

 

不足和反思:

a.    层级路径允许使用一个字符串来直接获取某个窗体的引用,虽然这样设计可以为获取一个窗体的引用提供了更灵活的方式。但是实际上父窗体直接拥有其所有子窗体的引用和控制权限而不必通过虚拟层级路径来获取,做同样一件事却有超过一种的方法,这在一定程度上违背了设计原则。本来这个UI框架中是没有虚拟层级路径的,但是个人能力有限,在实际开发的过程中发现很多时候业务层不易获取到UI窗体的引用,所以引入了虚拟层级路径这个概念,算一定意义的委曲求全。

b.    界面的显示逻辑和交互逻辑(也就是View层和Controller层)没有分开,因为毕竟当前该UI框架只是进行较简单的封装,继承BaseUIForm的自定义窗体类中,既有处理交互的代码,也有处理显示的代码,处理输入的部分和处理显示的部分耦合性较强。重新屡了下代码觉得这部分可以做如下优化:

l  任何一个继承自BaseUIForm的自定义窗体类都需要再实现一个对应的FormController类,并且均作为组件挂在窗体顶层上。以便取消窗体类的任何主动性,仅接收交互逻辑和业务逻辑的通知来更新UI,这样窗体类更轻了仅仅负责显示的功能,其处理交互部分的逻辑均在FormController类中实现。

c.    关于UI窗体的复用。复用重复创建的UI窗体可以提高性能, 虽然目前的UI框架支持使用对象池,但是和对象池系统整合得比较松散,理想情况下业务层对于创建UI窗体的复用过程应该是不可感知的,但是目前每个自定义窗体都需要实现对象池定义的接口,并且创建UI窗体之前需要手动判断在对象池中是否存在可复用对象显得很繁琐,可以想办法将这部分整合到UI管理器内部。

d.    关于UI根节点。该UI框架有个硬伤就是一定要存在某个根节点,也就是所有UI窗体的祖宗节点一定要是这个根节点,甚至需要单独为根结点挂载特殊的脚本,在处理UI逻辑中存在这样一个特例引入了一定的复杂性,在切换场景之后也要重新初始化根节点,可以优化取消根节点,只需在游戏载入阶段初始化一次UI框架即可。

 

4.   消息管理

为了降低模块之间的耦合性,使用一套消息通信机制很有必要,消息管理模块基于观察者模式提供了订阅、注销、发送消息等功能,同时实现了一个线程安全的类型来维护派发至主线程的消息队列。

虽然Unity本身提供了消息机制,如SendMessage、BroadcastMessage等方法,但是经过了解这种方法局限性较大:首先这样发送消息严重依赖字符串而无法实现编译阶段的类型安全,它也可以调用私有方法破坏类型的封装性,并且只有继承MonoBehaviour的类型才可以调用,这些应该都是该机制内部使用反射而带来的问题。所以有必要自己实现一套消息管理机制。

消息管理模块的核心消息管理器(MessageCenter),这是一个泛型静态类型,类型参数是一类枚举用来划分不同模块的消息,不同的类型参数有各自的消息队列,维护各自内部的消息处理。比如为了传递系统类型消息,设计一个SystemMessage枚举,用来枚举所有系统消息。这样设计主要是考虑通过区分枚举类型将不同类型的消息根据模块区分开便于维护和扩展,不同模块维护不同的消息队列,添加模块只需增加新的枚举类型即可。

MessageCenter:

消息管理器,对外公开了AddListener(订阅)、RemoveListener(注销)、Sendmessage(发送消息给监听者)等方法提供了基础的消息通信机制。

MainThreadMessageHandler:

内部维护一个委托队列,用来缓存派发至主线程的待执行方法,在主线程的Update周期中轮询来依次执行这些方法;对外公开了RegisterAction(派发主线程消息)方法。

 

不足和反思:

a.    在实现消息管理器时其实有两种方案:单例或者静态类,这部分的取舍很纠结,我当时是认为单例的生命周期和初始化顺序不容易控制,但是静态类不能继承也不面向对象,看网上相关资料推荐使用单例方案的较多,关于这个类型的设计还有不足。

b.    关于消息类型的基类。为了图方便,目前消息管理模块所使用消息的基类为System.EventArgs,但是这个类型几乎没有提供任何功能,目前该框架需要在EventArgs的基础上封装一个消息类型的基类,提供配合消息管理器的相关功能。

c.    发现消息管理器内部维护的字段类型是字典Dictionary<TMessage,EventHandler>,当时应该忽略了不同类型参数维护的不同静态字段,改为Queue<EventHandler>或者直接改为EventHandler应该都可以。

d.    还有一个问题一直存在但是没处理,就是派发出的消息抛出异常时,函数调用栈比较长,如果在Unity编辑器双击错误会直接定位到消息管理器内部,只能查看异常内部的调用栈来找到出错代码费时费力。可以把消息管理器部分编译为动态链接库作为插件引入项目,这样报错时可以直接定位到抛出异常的代码。

 

5.   场景管理

场景管理模块主封装了场景管理器和一个处理场景的通用基类。

该模块主要是为了提供唯一的场景入口和场景出口(包括游戏入口和游戏出口),并在入口处进行资源的加载和业务逻辑的初始化,在出口处进行资源的释放和相关业务逻辑的处理。这样做有利于控制正确的游戏流程顺序,避免初始化逻辑的分散。

每个新场景都需要一个实例添加继承自Scene基类的组件,并重写场景入口和场景出口方法来进行场景管理,同时提供一个全局的App组件,统一在App类中实现游戏入口和游戏出口方法,场景管理的流程如下图所示。

Scene:

自定义场景类的基类,提供虚方法SceneEntrance(场景入口)和SceneExport(场景出口)配合场景管理器进行场景切换

SceneManager:

场景管理器,封装了UnityEngine.SceneManagement.SceneManager的部分方法,公开了SceneConvertAsync(异步切换场景)供业务层调用。

App:

存在于整个游戏生命周期的组件,在其GameEntrance(游戏入口)方法中进行全局框架和全局模块的初始化,在其OnApplicationQuit(游戏出口)方法中进行相关操作。也提供了部分游戏周期事件供外部没有继承自MonoBehaviour的类型订阅(如Pause, Update, FixUpdate),这些消息被SystemMessage所枚举。

 

不足和反思:

a.    场景切换的进度汇报显示的问题,目前只是进行简单的遮挡处理,当游戏场景较大时没有进度提醒会造成玩家误解。

b.    关于“异步加载”的问题。在该框架中,场景和资源的加载是使用Unity的协程异步加载的,而网络和I/O部分则是使用多线程实现异步请求的,二者在同一个方法中使用会导致只能等待其中一个的结果,这十分棘手。因为一个方法要么返回IEnumertor,要么返回Task/Task<TResult>/void,也就是await和yield return 不能同时存在于一个方法中,毕竟这两种实现异步的机制不同。这个缺陷在切换场景时尤为放大,因为有时会需要在场景切换时进行网络请求和文件读写,目前没有想到较好的解决方案,只能在业务层尽可能避免同时使用两者。

 

6.   数据解析与存取

该模块主要向业务层提供文件存取的方法(包括异步和同步),并提供字符串的加密和解密服务,数据的序列化和反序列化通过调用第三方库Newtonsoft.Json实现。

文件存取部分封装了System.IO命名空间下的文件流操作方法并以较友好的方式公开;加密和解密使用DES对称加密方案,封装了System.Security.Cryptography命名空间下的相关类型。业务层进行文件存储的流程一般是先将相关对象序列化为字符串,进行加密之后存储到本地可写路径;进行文件读取的流程是先从本地可读路径读取字节流转化为字符串,进行解密后再反序列化为相应对象供业务层使用。

FileHelper:

提供文件存取的相关功能并重载大量方法方便调用,包括Write(写)、Read(读)、WriteAsync(异步写)、ReadAsync(异步读)、SelectFile(选取文件)等方法。

SecurityFactory:

基于DES对称加密方案提供对称加密相关功能;基于MD5加密方案提供不可逆加密相关功能,包括Encrypt(加密)和Decrypt(解密)等方法。

 

不足和反思:

a.    对称加密的安全性仍没有得到保证。为了简化加密解密过程,在设计该模块时直接将密钥直接置于代码里了,由于托管语言的特性,游戏逻辑编译过后的托管库(Assembly-CSharp.dll)在不经过处理的情况下是可以反编译的,密钥很容易会被获取。考虑应该有如下几种解决方案:使用工具进行代码混淆提高破解难度;使用IL2CPP方案将托管语言编译为原生语言提高安全性;将密钥置于服务器在游戏运行时联网获取。


2016-08-25 19:44:40 linshuhe1 阅读数 24635

前言:

        protobuf是google的一个开源项目,主要的用途是:

1.数据存储(序列化和反序列化),这个功能类似xml和json等;

2.制作网络通信协议;


一、资源下载:

1.github源码地址:https://github.com/mgravell/protobuf-net

2.google项目源码下载地址(访问需翻墙):https://code.google.com/p/protobuf-net/


二、数据存储:

        C#语言方式的导表和解析过程,在之前的篇章中已经有详细的阐述:Unity —— protobuf 导excel表格数据,建议在看后续的操作之前先看一下这篇文档,因为后面设计到得一些操作与导表中是一致的,而且在理解了导表过程之后,能够快速地理解协议数据序列化反序列化的过程。


三、网络协议:

1.设计思想:

        有两个必要的数据:协议号协议类型,将这两个数据分别存储起来

  • 当客户端向服务器发送数据时,会根据协议类型加上协议号,然后使用protobuf序列化之后再发送给服务器;
  • 当服务器发送数据给客户端时,根据协议号,用protobuf根据协议类型反序列化数据,并调用相应回调方法。

        由于数据在传输过程中,都是以数据流的形式存在的,而进行解析时无法单从protobuf数据中得知使用哪个解析类进行数据反序列化,这就要求我们在传输protobuf数据的同时,携带一个协议号,通过协议号和协议类型(解析类)之间的对应关系来确定进行数据反序列化的解析类。

       

        此处协议号的作用就是用来确定用于解析数据的解析类,所以也可能称之为协议类型名,可以是stringint类型的数据。


2.特点分析:

       使用protobuf作为网络通信的数据载体,具有几个优点:

  • 通过序列化之后数据量比较小
  • 而且以key-value的方式存储数据,这对于消息的版本兼容比较强;
  • 此外,由于protobuf提供的多语言支持,所以使用protobuf作为数据载体定制的网络协议具有很强的跨语言特性

四、样例实现:

1.协议定义:

        在之前导表的时候,我们得到了.proto的解析类,这是protobuf提供的一种特殊的脚本,具有格式简单、可读性强和方便拓展的特点,所以接下来我们就是使用proto脚本来定义我们的协议。例如:

// 物品
message Item
{
    required int32 Type 	= 1;	//游戏物品大类
    optional int32 SubType 	= 2;	//游戏物品小类
    required int32 num 		= 3;	//游戏物品数量
}

// 物品列表
message ItemList
{
    repeated Item item 	= 1;	//物品列表
}
        上述例子中,Item相当于定义了一个数据结构或者是类,而ItemList是一个列表,列表中的每个元素都是一个Item对象。注意结构关键词:

  • required:必有的属性
  • optional:可选属性
  • repeated:数组
        其实protobuf在这里只是提供了一个数据载体,通过在.proto中定义数据结构之后,需要使用与导表时一样的操作,步骤为:

  • 使用protoc.exe将.proto文件转化为.protodesc中间格式;
  • 使用protogen.exe将中间格式为.protodesc生成指定的高级语言类,我们在Unity中使用的是C#,所以结果是.cs类
        经过上述步骤之后,我们得到了协议类型对应的C#反序列化类,当我们收到服务器数据时,根据协议号找到协议类型,从而使用对应的反序列化的类对数据进行反序列化,得到最终的服务器数据内容。

        在这里,我们以登录为例,首先要清楚登录需要几个数据,正常情况下至少包含两个数据,即账号和密码,都是字符串类型,即定义cs_login.proto协议脚本,内容如下:
package cs;

message CSLoginInfo
{
	required string UserName = 1;//账号
	required string Password = 2;//密码
}

//发送登录请求
message CSLoginReq
{
	required CSLoginInfo LoginInfo = 1; 
}
//登录请求回包数据
message CSLoginRes
{
	required uint32 result_code = 1; 
}
       package关键字后面的名称为.proto转为.cs之后的命名空间namespace的值,用message可以定义类,这里定义了一个CSLoginInfo的数据类,该类包含了账号和密码两个字符串类型的属性。然后定义了两个消息结构:
  • CSLoginReq登录请求消息,携带的数据是一个CSLoginInfo类型的对象数据;
  • CSLoginRes登录请求服务器返回的数据类型,返回结果是一个uint32无符号的整型数据,即结果码。
        上面定义的是协议类型,除此之外我们还需要为每一个协议类型定义一个协议号,这里可以用一个枚举脚本cs_enum.proto来保存,脚本内容为:
package cs;

enum EnmCmdID
{
	CS_LOGIN_REQ = 10001;//登录请求协议号
	CS_LOGIN_RES = 10002;//登录请求回包协议号
}
        使用protoc.exe和protogen.exe将这两个protobuf脚本得到C#类,具体步骤参考导表使用的操作,这里我直接给出自动化导表使用的批处理文件general_all.bat内容,具体文件目录可以根据自己放置情况进行调整:
::---------------------------------------------------
::第二步:把proto翻译成protodesc
::---------------------------------------------------
call proto2cs\protoc protos\cs_login.proto --descriptor_set_out=cs_login.protodesc
call proto2cs\protoc protos\cs_enum.proto --descriptor_set_out=cs_enum.protodesc
::---------------------------------------------------
::第二步:把protodesc翻译成cs
::---------------------------------------------------
call proto2cs\ProtoGen\protogen -i:cs_login.protodesc -o:cs_login.cs
call proto2cs\ProtoGen\protogen -i:cs_enum.protodesc -o:cs_enum.cs
::---------------------------------------------------
::第二步:把protodesc文件删除
::---------------------------------------------------
del *.protodesc

pause
        转换结束后,我们的得到了两个.cs文件分别是:cs_enum.cs和cs_login.cs,将其放入到我们的Unity项目中,以便于接下来序列化和反序列化数据的使用。


2.协议数据构建:

        直接在项目代码中通过usingcs引入协议解析类的命名空间,然后创建消息对象,并对对象的属性进行赋值,即可得到协议数据对象,例如登录请求对象的创建如下:

        CSLoginInfo mLoginInfo = new CSLoginInfo();
        mLoginInfo.UserName = "linshuhe";
        mLoginInfo.Password = "123456";
        CSLoginReq mReq = new CSLoginReq();
        mReq.LoginInfo = mLoginInfo;
        从上述代码,可以得到登录请求对象mReq,里面包含了一个CSLoginInfo对象mLoginInfo,再次枚举对象中找到与此协议类型对应的协议号,即:EnmCmdID.CS_LOGIN_REQ


3.数据的序列化和反序列化:

        数据发送的时候必须以数据流的形式进行,所以这里我们需要考虑如何将要发送的protobuf对象数据进行序列化,转化为byte[]字节数组,这就需要借助ProtoBuf库为我们提供的Serializer类的Serialize方法来完成,而反序列化则需借助Deserialize方法,将这两个方法封装到PackCodec类中:

using UnityEngine;
using System.Collections;
using System.IO;
using System;
using ProtoBuf;

/// <summary>
/// 网络协议数据打包和解包类
/// </summary>
public class PackCodec{
    /// <summary>
    /// 序列化
    /// </summary>
    /// <typeparam name="T"></typeparam>
    /// <param name="msg"></param>
    /// <returns></returns>
    static public byte[] Serialize<T>(T msg)
    {
        byte[] result = null;
        if (msg != null)
        {
            using (var stream = new MemoryStream())
            {
                Serializer.Serialize<T>(stream, msg);
                result = stream.ToArray();
            }
        }
        return result;
    }

    /// <summary>
    /// 反序列化
    /// </summary>
    /// <typeparam name="T"></typeparam>
    /// <param name="message"></param>
    /// <returns></returns>
    static public T Deserialize<T>(byte[] message)
    {
        T result = default(T);
        if (message != null)
        {
            using (var stream = new MemoryStream(message))
            {
                result = Serializer.Deserialize<T>(stream);
            }
        }
        return result;
    }
}
        使用方法很简单,直接传入一个数据对象即可得到字节数组:

        byte[] buf = PackCodec.Serialize(mReq);

        为了检验打包和解包是否匹配,我们可以直接做一次本地测试:将打包后的数据直接解包,看看数据是否与原来的一致:

using UnityEngine;
using System.Collections;
using System;
using cs;
using ProtoBuf;
using System.IO;

public class TestProtoNet : MonoBehaviour {

	// Use this for initialization
	void Start () {
        CSLoginInfo mLoginInfo = new CSLoginInfo();
        mLoginInfo.UserName = "linshuhe";
        mLoginInfo.Password = "123456";
        CSLoginReq mReq = new CSLoginReq();
        mReq.LoginInfo = mLoginInfo;

        byte[] pbdata = PackCodec.Serialize(mReq);
        CSLoginReq pReq = PackCodec.Deserialize<CSLoginReq>(pbdata);
        Debug.Log("UserName = " + pReq.LoginInfo.UserName + ", Password = " + pReq.LoginInfo.Password);
	}

    // Update is called once per frame
    void Update () {
	
	}
}

        将此脚本绑到场景中的相机上,运行得到以下结果,则说明打包和解包完全匹配:
        


4.数据发送和接收:

        这里我们使用的网络通信方式是Socket的强联网方式,关于如何在Unity中使用Socket进行通信,可以参考我之前的文章:Unity —— Socket通信(C#),Unity客户端需要复制此项目的ClientSocket.csByteBuffer.cs两个类到当前项目中。

        此外,服务器可以参照之前的方式搭建,唯一不同的是RecieveMessage(object clientSocket)方法解析数据的过程需要进行修改,因为需要使用protobuf-net.dll进行数据解包,所以需要参考客户端的做法,把protobuf-net.dll复制到服务器项目中的Protobuf_net目录下:

        
        假如由于直接使用源码而不用.dll会出现不安全保存,需要在Visual Studio中设置允许不安全代码,具体步骤为:在“解决方案”中选中工程,右键“数据”,选择“生成”页签,勾选“允许不安全代码”:

         

          当然,解析数据所用的解析类和协议号两个脚本cs_login.cs和cs_enum.cs也应该添加到服务器项目中,保证客户端和服务器一直,此外PackCodec.cs也需要添加到服务器代码中但是要把其中的using UnityEngine给去掉防止报错,最终服务器目录结构如下:

         


5.完整协议数据的封装:

        从之前说过的设计思路分析,我们在发送数据的时候除了要发送关键的protobuf数据之外,还需要带上两个附件的数据:协议头(用于进行通信检验)和协议号(用于确定解析类)。假设我们的是:

       协议头:用于表示后面数据的长度一个short类型的数据:

        /// <summary>
        /// 数据转换,网络发送需要两部分数据,一是数据长度,二是主体数据
        /// </summary>
        /// <param name="message"></param>
        /// <returns></returns>
        private static byte[] WriteMessage(byte[] message)
        {
            MemoryStream ms = null;
            using (ms = new MemoryStream())
            {
                ms.Position = 0;
                BinaryWriter writer = new BinaryWriter(ms);
                ushort msglen = (ushort)message.Length;
                writer.Write(msglen);
                writer.Write(message);
                writer.Flush();
                return ms.ToArray();
            }
        }

       协议号:用于对应解析类,这里我们使用的是int类型的数据:

        private byte[] CreateData(int typeId,IExtensible pbuf)
    {
        byte[] pbdata = PackCodec.Serialize(pbuf);
        ByteBuffer buff = new ByteBuffer();
        buff.WriteInt(typeId);
        buff.WriteBytes(pbdata);
        return buff.ToBytes();
    }
        客户端发送登录数据时测试脚本TestProtoNet如下,测试需要将此脚本绑定到当前场景的相机上:

using UnityEngine;
using System.Collections;
using System;
using cs;
using Net;
using ProtoBuf;
using System.IO;

public class TestProtoNet : MonoBehaviour {

	// Use this for initialization
	void Start () {


        CSLoginInfo mLoginInfo = new CSLoginInfo();
        mLoginInfo.UserName = "linshuhe";
        mLoginInfo.Password = "123456";
        CSLoginReq mReq = new CSLoginReq();
        mReq.LoginInfo = mLoginInfo;

        byte[] data = CreateData((int)EnmCmdID.CS_LOGIN_REQ, mReq);
        ClientSocket mSocket = new ClientSocket();
        mSocket.ConnectServer("127.0.0.1", 8088);
        mSocket.SendMessage(data);
    }

    private byte[] CreateData(int typeId,IExtensible pbuf)
    {
        byte[] pbdata = PackCodec.Serialize(pbuf);
        ByteBuffer buff = new ByteBuffer();
        buff.WriteInt(typeId);
        buff.WriteBytes(pbdata);
        return WriteMessage(buff.ToBytes());
    }

    /// <summary>
    /// 数据转换,网络发送需要两部分数据,一是数据长度,二是主体数据
    /// </summary>
    /// <param name="message"></param>
    /// <returns></returns>
    private static byte[] WriteMessage(byte[] message)
    {
        MemoryStream ms = null;
        using (ms = new MemoryStream())
        {
            ms.Position = 0;
            BinaryWriter writer = new BinaryWriter(ms);
            ushort msglen = (ushort)message.Length;
            writer.Write(msglen);
            writer.Write(message);
            writer.Flush();
            return ms.ToArray();
        }
    }

    // Update is called once per frame
    void Update () {
	
	}
}
        服务器接受数据解包过程参考打包数据的格式,在RecieveMessage(object clientSocket)中,解析数据的核心代码如下:

        ByteBuffer buff = new ByteBuffer(result);
        int datalength = buff.ReadShort();
        int typeId = buff.ReadInt();
        byte[] pbdata = buff.ReadBytes();
        //通过协议号判断选择的解析类
        if(typeId == (int)EnmCmdID.CS_LOGIN_REQ)
        {
                CSLoginReq clientReq = PackCodec.Deserialize<CSLoginReq>(pbdata);
                string user_name = clientReq.LoginInfo.UserName;
                string pass_word = clientReq.LoginInfo.Password;
                Console.WriteLine("数据内容:UserName={0},Password={1}", user_name, pass_word);
                }
        }
        上面通过typeId来找到匹配的数据解析类,协议少的时候可以使用这种简单的使用if语句分支判断来实现,但是假如协议类型多了,则需要进一步封装查找方法,常用的方法有:定义一个Dictionary<int,Type>字典来存放协议号(int)和协议类型(Type)的对应关系。


6.运行结果:

        启动服务器,然后运行Unity中的客户端,得到正确的结果应该如下:

        

        项目服务器和客户端的完整代码可以前往此处下载:protobuf-net网络协议的定制


没有更多推荐了,返回首页