Unity3D游戏框架设计

  • Post author:
  • Post category:其他



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方案将托管语言编译为原生语言提高安全性;将密钥置于服务器在游戏运行时联网获取。



版权声明:本文为aawoe原创文章,遵循 CC 4.0 BY-SA 版权协议,转载请附上原文出处链接和本声明。