1. 什么是事件系统?
事件系统是游戏开发中最常用的基础模块,通常采用订阅发布模式实现。通过事件系统,我们可以在多个不同的模块在互不引用的情况下,实现模块间的交互。
所以事件系统是用来处理模块间解耦的主要手段。
一个基础的事件系统主要提供3个功能,注册,注销,发送消息。注册:在事件中心中添加对某个消息的监听;注销:在事件中心中取消掉对某个消息的监听;发送消息:在需要的时机发送某个消息,触发所有对其的监听的回调。
下面是一个事件系统的伪代码范例:
// 添加事件监听
EventCtrl.Instance.AddListener("MyEventName", EventCallBack);
// 注销事件监听
EventCtrl.Instance.RemoveListener("MyEventName", EventCallBack);
// 派发事件
EventCtrl.Instance.Send("MyEventName", SomeArgs);
2. 被滥用的事件系统
事件系统是游戏开发中最常用的基础模块,但同时也是最多被滥用的。
2.1 被滥用的表现
如果发现项目中的代码有这种情况,表示事件系统可能不被正确的使用了。
-
同一条事件在项目内被很多地方派发和很多地方注册;
-
同一条事件用来处理不同的业务逻辑;
-
多个地方派发的同一条事件的参数不同;
-
一个函数方法的片段内,包含很多次事件派发;
-
当代码出bug时,不是只专注于在业务代码逻辑中查找bug,还需要去检查事件消息的注册/注销逻辑是否正确;
-
某一条事件触发频率过高,导致其响应占用性能过高;
-
同一条事件的多个响应会相互影响,多个响应必须保持一定的顺序触发才可以正常运行;
2.2 为什么会被滥用?
首先是未能梳理好各个独立业务模块的关系,未做好各模块的接口设计。
工作中接手过太多代码混乱的项目,也算有点心得。这些混乱的项目大部分未作好模块独立,各模块间的调用直来直去混为一团。同时事件系统作为解耦的得力助手也无能为力。要么是完全没人用,所有地方都是不考虑解耦直接调用。要么是每个地方都在用,不论是模块内还是模块外。
其次是没有做好事件定义的规范,错误得把所有模块间的相互调用都定义为事件。
例如在A和B两个独立的模块中,有很多相互直接调用。为了解耦,A模块中不能直接调用B模块的方法,那么很简单,把原来A中对B的所有直接调用换成事件。
这种方式是未经思考的乱用事件系统,不仅解耦的效果仅仅浮于形式,代码还会非常丑陋,事件调用事件的循环链会导致逻辑混乱,很难捋清楚。
对于如何正确定义事件和派发事件,在第3,5节中具体讨论。
最后是注册/注销事件的时机不统一,在业务逻辑中根据其他判断条件进行注册和注销。
这种方式下,一旦出了bug很难排查。如果注册的事件内的逻辑有问题,需要去排查3种情况:1.派发事件时该回调没有注册;2.派发事件时该回调已经注销了;3.回调内部代码有bug。
对于如何正确注册和注销事件,在第4节中具体讨论。
3. 事件定义的原则
1. MVC框架下的事件定义
游戏开发常用的MVC架构,包括很多基于MVC的变种和拓展如MVVM,MVP等等。无论怎么变化,其中的Model(数据层)和View(视图层)都是其中不变的核心,其他变化的都是对M和V交互的不同处理方式。
数据(Model)是所有软件程序的基础。所有软件究其本源,最终都是对数据的读取和写入。
我们点击网页上的一个链接,打开微信查看好友的讯息,打开抖音观看搞笑视频,所有这些操作都是对数据的读取,只不过是通过不同的媒介呈现。
我们在某宝上买了一台电脑,编辑早安消息发送给朋友,发布一条跳舞视频,所有这些操作都是对数据的写入。
注意:上面举例子中的数据大多指存放在持久性数据,如服务器的数据库中的数据,本地文件夹下保存的数据等。但是我们在考虑事件系统与MVC的关系时,讨论的数据是泛指所有系统的状态而非仅仅指持久化数据,系统的状态有变化也代表着数据的变化。这个对于理解下文中<u>3.2事件定义的2种类型</u> 非常重要。
例如向下滚动网页这个操作,使用者并未实际上修改了数据库或本地的网页数据,但是其对应的显示区域状态发生了变化,也算是数据的变化。
视图(View)依托于数据,是数据变化的呈现方式。所有的视图都是为了2个作用:读取数据并呈现,触发对数据的写入;
下面以一次淘宝购物的流程来说明,视图层在过程中的这2个作用:
点击搜索商品,展示商品列表=》读取商品列表数据并呈现;
点击某个商品,进入商品详情页=》读取商品详情数据并呈现;
点击加入购物车并跳转=》触发对购物车数据的写入,然后读取购物车数据并呈现;
在购物车内支付购买=》触发对购物车/购物数据的写入,读取购物车/购物数据并呈现;
综上所述,M数据是核心和基础,V视图是依托于数据的呈现,C控制是M和V交互的方式。所有的软件都是基于数据,提供数据修改的方式和对数据的呈现。
2. 事件定义的2种类型
是当思考清晰Model和View的关系后,我们就可以对如何定义事件下结论了:
image.png我们只需要定义2种类型的事件:
-
开始数据的写入的事件;
-
数据修改后通知的事件;
3. 常见的事件范例
- 开始数据的写入的事件;
常见的范例:
Event_Begin_LoadScene: 开始切换新场景的事件;
Event_Begin_GetLoginRewrad: 开始领取登录奖励的事件;
- 数据修改后通知的事件;
常见的范例:
Event_After_PropsChange: 金币/钻石/道具数量变化的事件;
Event_After_GetLoginReward: 登录奖励领取成功后的事件;
Event_After_PurchaseSuccess: 购买商品成功后的事件;
4. 事件注册/注销的原则
1. 注册/注销的逻辑应当保持一致,即在注册对象的生命周期开始阶段注册,注册对象的生命周期结束阶段注销。
例如在MonoBehavior的Awake中注册,OnDestroy中注销。
在类的构造函数中注册,在类的析构函数中注销。
大家可能有疑问,我如果要在某些情况下才触发响应,而某些情况下不触发,也需要在整个生命周期开始注册结束注销吗?
答案:是的,”某些条件下才触发,某些情况下不触发“这种逻辑应当放在事件响应中,事件响应后检测判断条件即可。为了保证代码逻辑统一性,多消耗一点检测的性能是可以接受的。如果事件触发太过频繁导致检测消耗过大,则需要考虑事件派发逻辑和检测逻辑是否可以优化。
当然这个答案很有争议,仅代表个人之言。在很多项目中,如果非常有必要去动态注册/注销也是可以的,当然前提必须是代码逻辑清晰。
2. 以添加事件次数最少的方式开发;
在一个模块中,应该是在主模块注册事件而非每个子模块单独注册;在一个有很多重复item的界面中,应当是在主界面注册事件,而非每个item单独注册事件;
常见的不好的使用方式是:一个展示很多格子的背包ui,里面每个格子都注册了事件。
5. 事件发送/响应的原则
1. 先谈谈耦合
常说的耦合更多是指代码层面的不同模块的相互调用,即内容耦合。
耦合度的高低可以查看A模块中对于B模块的代码引用得到,我们通过查看代码很容易发现问题。
这种耦合可以理解为显性的耦合。
相对于显性的耦合,那么便是隐性的耦合。隐形的耦合常见于代码在时间维度的执行顺序上的耦合。
例如下面一个关于饥饿的人吃食物的代码例子:
class HungryMan{
SomeFood food = new SomeFood();
public void FuncA(){
Cook(food);
....
}
public void FuncB(){
Eat(food);
}
}
上面代码中,SomeFood类需要先烹饪(调用Cook)后才可以吃(调用Eat),否则会导致吃了生的食物拉肚子(出bug)。
在这种代码书写下,FuncB和FuncA的调用顺序必须固定:先调用FuncA再调用FuncB。这种情况下我们可以说FuncA和FuncB产生了隐形的耦合:在代码之间没有直接引用时,却在时间调用顺序上受到其影响的耦合,称其为时域耦合。
2. 事件发送/响应的几条原则
那么事件发送/响应的原则是:
-
同一事件的多个响应之间不应该有时域耦合;
-
事件响应不能与事件派发后的逻辑有时域耦合;
原创声明
作者:vectorZ
出处:https://www.jianshu.com/u/01450ce9ecbf
版权:本文版权归作者所有
转载:欢迎转载,但未经作者同意,必须保留此段声明;必须在文章中给出原文连接;否则必究法律责任
网友评论