unityGameDev

游戏开发 - 事件系统的使用原则

2023-03-22  本文已影响0人  vectorZ

1. 什么是事件系统?

事件系统是游戏开发中最常用的基础模块,通常采用订阅发布模式实现。通过事件系统,我们可以在多个不同的模块在互不引用的情况下,实现模块间的交互。

所以事件系统是用来处理模块间解耦的主要手段

一个基础的事件系统主要提供3个功能,注册注销发送消息。注册:在事件中心中添加对某个消息的监听;注销:在事件中心中取消掉对某个消息的监听;发送消息:在需要的时机发送某个消息,触发所有对其的监听的回调。

下面是一个事件系统的伪代码范例:

// 添加事件监听
EventCtrl.Instance.AddListener("MyEventName", EventCallBack);
// 注销事件监听
EventCtrl.Instance.RemoveListener("MyEventName", EventCallBack);
// 派发事件
EventCtrl.Instance.Send("MyEventName", SomeArgs);


2. 被滥用的事件系统

事件系统是游戏开发中最常用的基础模块,但同时也是最多被滥用的。

2.1 被滥用的表现

如果发现项目中的代码有这种情况,表示事件系统可能不被正确的使用了。

  1. 同一条事件在项目内被很多地方派发和很多地方注册;

  2. 同一条事件用来处理不同的业务逻辑;

  3. 多个地方派发的同一条事件的参数不同;

  4. 一个函数方法的片段内,包含很多次事件派发;

  5. 当代码出bug时,不是只专注于在业务代码逻辑中查找bug,还需要去检查事件消息的注册/注销逻辑是否正确;

  6. 某一条事件触发频率过高,导致其响应占用性能过高;

  7. 同一条事件的多个响应会相互影响,多个响应必须保持一定的顺序触发才可以正常运行;

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种类型的事件:

  1. 开始数据的写入的事件

  2. 数据修改后通知的事件

3. 常见的事件范例

  1. 开始数据的写入的事件

常见的范例:

Event_Begin_LoadScene: 开始切换新场景的事件;

Event_Begin_GetLoginRewrad: 开始领取登录奖励的事件;

  1. 数据修改后通知的事件

常见的范例:

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. 事件发送/响应的几条原则

那么事件发送/响应的原则是:

  1. 同一事件的多个响应之间不应该有时域耦合;

  2. 事件响应不能与事件派发后的逻辑有时域耦合;


原创声明
作者:vectorZ
出处:https://www.jianshu.com/u/01450ce9ecbf
版权:本文版权归作者所有
转载:欢迎转载,但未经作者同意,必须保留此段声明;必须在文章中给出原文连接;否则必究法律责任

上一篇下一篇

猜你喜欢

热点阅读