第二章
逻辑的设计模式讨论
11. 分层设计
在我们项目开发中,下到底层核心代码,上到逻辑层应用,都离不开分层思想。
分层思想旨在将不同的依赖关系分离开来,并减少代码的耦合度,使得代码的维护更加方便,也更加合理。我们先讨论一个分层框架或者说写法,MVC。在很多地方,都会使用到mvc模式,尤其是web中。我们在这边讨论游戏中是否应该使用MVC。如果是在游戏中使用,那么我们会怎么写一个ui视图的代码呢?
M->负责数据
V->负责显示
C->负责响应
看起来是可行的,很清晰。那么它对比MV会怎么样?
1. 需要建立3个文件, 在control层面没办法很快的找到V里面对应的定义(如果编辑器不支持快速跳转的话)。
2. 游戏的V(视图)往往只有一种呈现,没有变化的V,VC合并在一起更有利于开发速度。
在知乎上面也有一个文章讨论游戏是否需要MVC架构:
https://zhuanlan.zhihu.com/p/38280972
那么我们在游戏中是否需要使用MVC模式,老A的结论是:用也不用。
用在哪里:在网络的消息往来中,收到一条信息,我们需要修改本地的模块数据,我们需要更新界面,那么网络消息处理的handle模块,扮演的就是C,C的作用就是更新M和V。
不用在哪里:不用在ui界面的逻辑中,因为ui界面经常整个换或者调整,它需要快速的开发效率。把单个ui的逻辑写在自己的一个view文件中,在这个文件中使用M,这样是最快的方式。
MVC带给我们的更多是分层的思考,那么我们就要聊聊分层的一些原则。
第一个原则:在分层设计中,事件的派发遵从从下往上。
什么意思?越上层的逻辑通过监听下层的事件来实现自己的需求。比如我们说ui逻辑上的事件点击是上层,引擎提供的事件派发就是下层。那么一个界面v和数据层m之间是什么关系呢?v经常使用到m,所以我们把m定义为v的下层,也就是m更加底层。v通过监听m层的事件来实现自身的变更。比如说v里面有个等级level,在等级变更的时候m层会发出事件,监听它的v就会做出响应。下面我们再讨论几个场景,来试图理清楚我们到底是如何分层以及层与层之间到底有什么关系。
第一个场景,在一个游戏里面, 主界面的更新与玩家的移动是什么关系。
我们经常看到的写法是:主界面有个摇杆逻辑,在摇杆逻辑中触发了玩家的移动。摇杆逻辑触发事件,玩家响应。或者一般大家就直接调用玩家类的移动方法。而在玩家类中,当有玩家的等级发生变更,这时候主界面的等级又需要改变了。可能很多人就在玩家类里面就直接调用主界面的接口去修改主界面的表现了。
首先我们说一下这样调用存在的一些问题。主界面和玩家数据因为异步的关系,它们很可能是其中一者优先初始化好的。当一方需要调用另外一方的接口的时候,很可能需要去判断它的就绪状态,这是很烦人的事情。正如第一章节中的配置一样,如果我们每次使用配置前都需要去判断一下配置是否存在,那么我们的代码将掺杂着很多判断代码,也就不容易读懂了。在小C的成长过程中,他就是这么写代码的。他的想法是:在某些情况下,高的耦合意味着更好的效率和逻辑的实现。只要这个耦合能控制在一定范围内,这个耦合是可以适当被允许的。
那么这个真的是正确的设计吗?我们常常听到的词是高内聚,低耦合。与这个建议似乎是相违背的。在这个场景下,玩家类和主界面的逻辑就不该掺和在一起。但是我们明明看到玩家类依赖于摇杆事件,主界面依赖于玩家数据,他们看似就该是一体才能比较互相依赖。
首先大部分玩家类是含有玩家数据的,我们按照上面的mv的层次划分,我们简单的把他们拆开。因为m是数据本身,v是视图,视图是经常变的,我们暂时认为m更加底层,一会我们会更加详细的说明这个认识的本身。那如果这样划分,摇杆怎么办?我们把摇杆再拆出一层,这边变成了3层,摇杆,主界面,玩家数据。我们把顺序从上到下理一下,主界面,玩家数据,摇杆。
通过拆出额外的一层,解开了主界面与玩家数据的耦合。摇杆的响应是可以作为一个单独的模块移动处理的。主界面根据不同的摇杆状态显示不同的界面表现,玩家根据不同的摇杆状态进行移动,释放了玩家和主界面。我们还有另外一个判断摇杆是否可以独立一层的逻辑,想象你把摇杆逻辑平移到了另外项目,是否需要拖家带口把其他文件复制过去,如果不需要,那么恭喜你,你的摇杆逻辑的分层是对的,且逻辑是内聚的。在上面的案例中,也牵扯出了我们的另一个原则。
第二个原则:层的上下层次的划分根据依赖性进行划分。越重要的,越被依赖的层位于越下面。玩家移动依赖于摇杆,那么这个依赖导致了摇杆更加的底层。
而玩家数据经常会被主界面或者其他界面进行依赖,所以它位于界面层的下面。这边我们可能会产生一些问题,就是摇杆的层次是否应该在玩家类的下面。我们还可以再拆,把玩家类中的移动和玩家数据拆分开。那么这时候,玩家类的移动(表现为界面人物的移动)和玩家的数据(等级,vip等)是分开的。这边的层次关系就更加清晰了,人物表现变成了单独一个层,下面是摇杆层。玩家数据层在ui层(主界面等)的下面。玩家数据和摇杆层之间没有任何关系,它们是独立的关系,存在在独立的分支上面。当有一天它们需要依赖的时候,我们才会给他们的层次做个简单的排序。
第二个场景,在玩家的数据中,存在3个模块。基础属性模块(含等级,属性等),vip模块以及战场玩法模块。其中,
vip模块以及战场模块依赖于基础属性的等级进行开启
基础属性中的经验获取依赖于vip的种类与等级
vip模块与战场模块都会对属性模块中的属性进行加成,属性模块的属性是算所有模块的总属性
这样的几个关系似乎也导致了它们之间的强依赖。小C对它们进行的层级的排序,认为它们互相依赖,应该是平级。有点工作经验的小B不这么认为,它觉得从直观上就是战场模块在最上面,vip在中间,基础属性模块在最下面,毕竟都叫基础模块了。那么我们首先看看战场模块是不是应该在最上面。在前面的例子中,基础属性模块的属性计算依赖于战场模块。我们考虑将属性相关的接口抽象成一个AddAttr(加属性)以及ClearAttr(清除属性)接口,需要的模块自行调用这2个接口进行属性的添加与清除。这么一来,基础属性模块就不依赖于战场模块了。
接下来的部分会比较难拆了,基础模块和vip模块都相互依赖于对方。vip模块依赖于等级的变更,而基础模块依赖于vip种类与等级,是依赖一个状态。依赖等级可以用事件的方式解耦,依赖状态可以使用跟战场一样的方式进行解耦。所以无论我们是依赖一个状态还是一个状态的变更,我们都可以用一些手段进行解耦。这就得出了我们对分层的另外一个认知:层与层之间是可以独立存在的,不一定需要建立层次顺序。
那么我们前面说到的分层顺序是否还有意义?层与层之间互相独立是否是最佳的设计?如果你按照层与层完全独立的写法写代码,代码之间的耦合度是非常低的,但是写的过程会发现,并没有那么有效率。为什么,因为你没办法随便用别的模块的一些接口了。如果你的经验倍率依赖于vip等级,那么你提供的是经验添加倍率接口AddExpFactor,在你的经验计算中,使用这个倍率进行计算。而vip模块调用添加倍率接口影响倍率。它们之间只依赖于行为,而不依赖于具体的某个属性。本来可以直接GetVipLevel获取vip等级的,不能用,只能设计一个机制给其他模块调用。
那么进化的设计就是:如果某个属性,比如经验只依赖于vip等级,那么我们还是想直接调用,为了效率。而当出现多个依赖时,我们再将原来的依赖改为某种机制,解除依赖。但是,如果依赖的是战场模块里面的某个属性,我们就不能直接去调用了。因为战场模块是玩法模块,是高度变化的。今天代码没写完,明天功能可能就改了。所以对于它的依赖,只能是通过建立机制来解耦。
第三个原则:越容易变化的层位于越上面。就像战场模块这样的玩法层面,变化很大。而我们把基础属性模块,vip模块,邮件模块等相对稳定的模块放在下面。这种划分有利于我们把易变的和不易变的划分开。
这3个原则不是一定要强制遵循的,而是为应对游戏行业行业而生。如果没有一定的限制条件,我们依然是可以保持层与层之间的独立。那么这些限制有:
1. 开发节奏快,程序经常需要加班,开发效率成为开发过程中不可忽略的事情。
2. 需求的变动是频繁的,今天在开发的系统,明天可能就要推倒重来了。
3. 即使是大面积的改动,也要保证后续的开发效率
所以代码既要耦合低,也要速度快。于是老A定了一个新的项目代码分层规范:基础属性模块,vip,邮件,任务等模块变更较少且经常被其他模块依赖的,作为基础层。其他逻辑模块作为逻辑层。
基础层之间的模块允许自由的使用对方的接口,或者依赖对方的接口。
基础层位于逻辑层之下,只能给逻辑层派发事件或者提供行为接口而不允许依赖于任何的逻辑层状态。
逻辑层之间也不允许互相依赖,只能保持独立。
这样的分层保证了开发效率以及代码的可维护性。同时,因为逻辑层的低耦合,我们很容易的将一个不需要的模块删除,而不用担心其他地方依赖到它导致脚本崩溃。
再聊一聊层与层之间的关系,还是拿主界面和一个玩家角色来说明,我们这边假设这里的玩家角色只是一个显示对象,主界面的更新部分依赖于玩家角色的在场景里面的行为。那么对于这样的2个界面,我们可以把它们归属成不同的层。比如说玩家角色位于场景的显示层(里面可能有地图建筑等),主界面等ui一般出现的时候都是显示在场景上面的,它们单独为ui层。下面我们来看2种对主界面和玩家角色的关系理解:
1. 玩家角色位于主界面对象的里面,主界面对象拥有一个玩家角色对象。
2. 玩家角色对象和主界面对象完全独立,由更高一层的逻辑,比如场景逻辑负责创建它们,并建立消息通知机制。
根据我们上面的一些结论,我们很自然的可以使用低耦合的方式,也就是b的方式来建立它们的关系。但是a的关系设计也是会经常看到的,就是在小游戏中。因为在小游戏中主界面和玩家角色的创建都是非常快的,也就是说少了很多异步创建等情况。这使得我们可以使用a的方式来建立关系。总结下,当我们满足以下情况时:
1. 并发异步创建的情况少
2. 创建的速度足够快,基本是阻塞的
3. 项目规模小,预判未来的需求变更是小的
我们可以采用更加灵活的层关系来表达一个游戏内的依赖关系,因为分层的优势在于代码清晰,很好的应对需求变更。而在足够小的游戏方面,这些优点无法完全的发挥效用,所以我们可以采用其他的层关系。注意了,当一个小游戏的核心不变,但是在不同的环境(h5,微信小游戏)有不同的表现的时候,我们依然要用回分层思想来应对不同的环境。