前言
本篇为学习总结性质的文章,若有任何问题或错误,欢迎在评论区指出。
如果本文对您有一定帮助,也欢迎点赞、收藏、关注。
本文前置知识点:字典,委托,单例模式,万物基类object,里氏替换原则,观察者设计模式
引入
游戏中经常会有这样的逻辑需求:玩家击杀怪物,从而获得经验值,完成某些任务,同时也为一些成就增加进度等等。
试问,如果你要去实现这样的功能,你会怎么做?
可能有些人会觉得很简单,无非就是在怪物死亡逻辑中添加对应的需求罢了,例如:
public class Monster
{
int HP;
int ATK;
int DEF;
//略……
public void Atack() { }
public void Skill_1() { }
//略……
public void Dead()
{
Debug.Log("死亡动画和销毁");
//略……
Debug.Log("调用玩家增加经验的对应函数");
Debug.Log("调用对应任务累计进度的函数");
Debug.Log("调用对应成就累计进度的函数");
Debug.Log("其他逻辑");
}
}
这样一写出来大家可能就会意识到问题所在——程序的耦合度太高。
如果作为GameJam制作的小游戏还好,但一旦项目大了,尤其是游戏拥有多种系统、策划增加各种需求后,整个游戏各个模块间会疯狂交织,变得异常复杂,修改和维护的成本就会急剧攀升。
如何解决这个问题?
我们可以建立一个“中转模块”。每次某些事件发生时,可以仅通知“中转模块”,再令其去处理该事件发生所导致的相应结果。
这样不仅降低了程序耦合度,还使逻辑更加清晰明了,日后维护与修改的难度大大降低。
并且,因为有了这样的一个中转模块,协同开发时可以各自分离,效率可以大幅提高。
这种集中管理和处理事件的模块,我们可以将其称为“事件中心”。
思路
一、监听一个事件
如何写一个“事件中心”呢?
如上文所述,我们需要做的,是建立一个“中转站”。一些事件发生时(如“怪物死亡“),我们需要告诉“中转站”,让它去处理该事件所引发的所有结果(如“玩家获得经验值”、“累计任务进度”等等)。
当然,程序是不可能知道“怪物死亡”等事件发生时需要处理哪些“结果”的,所以我们需要在最开始便为一个事件设定好其所有结果。换句话说,就是每个结果都去监听对应事件的发生。
说到这里,大家可能就会有思路了:定义一个委托,在游戏开始时将所有“结果”要处理的逻辑函数放入委托中,在事件真正发生时再执行这个委托,从而结算所有结果。
大致思路如下图:
代码较为简单,就不列出了。
二、整合多个事件
如上,我们就可以简单地通过委托来低耦合度地处理事件。
但是我们真正要实现的是“事件中心”,是可以处理多种事件的。我们也不希望每有一个事件就申明一个对应的委托,毕竟就算是一个简单的小游戏,其中的事件也是非常非常多的。
我们想要一个事件对应一个委托函数,并且集中地将它们存储起来。说到这里我们需要的东西就很明显了——字典。
我们可以用字符串作键,用Unity自带委托作值。事件中心也只会存在一个,可以写成单例模式。
代码如下:
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.Events; //为了使用Unity自带委托,必须要引用
public class EventCenter
{
//单例模式
private static EventCenter instance;
public static EventCenter GetInstance()
{
if (instance == null)
instance = new EventCenter();
return instance;
}
//key对应事件名,value对应“结果”(需要监听对应事件的委托函数)
private Dictionary<string, UnityAction> eventDic = new Dictionary<string,UnityAction>();
/// <summary>
///添加事件监听
/// </summary>
/// <param name="name">事件名</param>
/// <param name="action">“结果”(需要监听对应事件的委托函数)</param>
public void AddEventListener(string name, UnityAction action)
{
//判断字典里是否已有对应事件和委托
if (eventDic.ContainsKey(name))
{
//已有则直接添加
eventDic[name] += action;
}
else
{
//没有则新加键值对
eventDic.Add(name, action);
}
}
/// <summary>
/// 事件触发
/// </summary>
/// <param name="name">需要触发的事件名</param>
public void EventTrigger(string name)
{
if (eventDic.ContainsKey(name))
{
eventDic[name]();
//eventDic[name].Invoke(); //另一种执行方法
}
}
}
以上,我们就完成了“事件中心”最核心的逻辑。
我们可以同时处理多个事件,使用AddEventListener()为不同事件添加监听,并在其发生时调用EventTrigger()使对应事件的所有“结果”得到结算。
三、完善
如果直接使用以上代码作各种事件的处理还会有些许问题。
关于Remove方法
首先,仍然以“玩家打怪”这个事件为例。
我们如果使用以上事件中心,在玩家管理脚本Player的Awake或Start生命周期中,调用AddEventListener()监听了怪物死亡。
然后,玩家正常游玩游戏,在此过程中玩家角色死亡,Player这个对象被销毁了。但直到此时,事件中心的“怪物死亡”这个委托仍然和Player中的某个“结果”函数建立着联系。
我们如果在Player被销毁的那一刻没有去取消它对“怪物死亡”这个事件的监听,那么这种引用关系就会一直存在。在往后GC时,由于有这个引用关系的存在,Player这个对象也就永远不会被释放,导致内存泄漏。
因此,与AddEventListener相对应的,事件中心也应该有一个RemoveEventListener方法,用于移除事件监听。
逻辑其实很简单,就是查找字典中对应的委托函数,从中移除不需要监听的部分:
//移除事件监听
public void RemoveEventListener(string name,UnityAction action)
{
if (eventDic.ContainsKey(name))
{
eventDic[name] -= action;
}
}
关于Clear方法
同理,既然游戏中可能导致单个事件监听需要被移除,那自然会有需要移除所有事件监听的情况。
比如,当我们切换场景时,因为原场景的所有对象都被移除掉了,原有的事件监听自然会全部失去意义。
所以,除了移除单个监听的RemoveEventListener方法,我们也需要一个方法来清空事件中心。
具体逻辑也就是清空事件中心的字典:
//清空事件中心
public void Clear()
{
eventDic.Clear();
}
关于传参
到此,我们这个“事件中心”还有问题吗?还有。
实际去使用,会发现:每次我们要处理对象不同但逻辑相同的“结果”时,不断地复制、粘贴、替换就会显得很蠢且繁琐。
所以要写一个好用的事件中心,我们需要其所带委托能够传参。
使用什么参数呢?
显然,使用万物基类object是通用性最高的。并且就算有不止一个参数,我们也可以往object里装载数组,实现多参数的传递。
完善后的代码如下:
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.Events;
public class EventCenter
{
//单例模式
private static EventCenter instance;
public static EventCenter GetInstance()
{
if (instance == null)
instance = new EventCenter();
return instance;
}
//使用参数为object的Unity自带委托作字典的值
private Dictionary<string, UnityAction<object>> eventDic = new Dictionary<string, UnityAction<object>>();
/// <summary>
///添加事件监听
/// </summary>
/// <param name="name">事件名</param>
/// <param name="action">“结果”(需要监听对应事件的委托函数)</param>
public void AddEventListener(string name, UnityAction<object> action)
{
if (eventDic.ContainsKey(name))
{
eventDic[name] += action;
}
else
{
eventDic.Add(name, action);
}
}
//移除事件监听
public void RemoveEventListener(string name,UnityAction<object> action)
{
if (eventDic.ContainsKey(name))
{
eventDic[name] -= action;
}
}
/// <summary>
/// 事件触发
/// </summary>
/// <param name="name">需要触发的事件名</param>
/// <param name="info">参数</param>
public void EventTrigger(string name,object info)
{
if (eventDic.ContainsKey(name))
{
eventDic[name](info);
//或使用:
//eventDic[name].Invoke(info);
}
}
//清空事件中心
public void Clear()
{
eventDic.Clear();
}
}
四、优化
其实完成了以上完善步骤后,该“事件中心”可以说是没有明显缺陷了,也能基本满足各种需求。
但我们还能对其进行进一步的优化。
首先,使用object作为委托的参数,增加了可适用性,但如果用其装载值类型(虽然概率较小),无疑会有object装箱拆箱的额外开销。这是一个我们可以优化的点。
如何优化?
答:使用泛型。
但其实并不能直接把字典的值从UnityAction<object> 直接变为 UnityAction<T>, 这样的话拥有泛型成员的EventCenter不得不成为一个泛型类,最后变成只能存储一种有参委托的事件中心,与我们最初的想法背道而驰。
正确且高效的做法是,定义一个空接口,使其作为一个再次封装后的“UnityAction<T>”的父类,这样由里氏替换原则,就可以用该空接口作为字典的值,存储T为各种类型的UnityAction<T>。
详见下:
//空接口
public interface IEventInfo
{
}
//对UnityAction<T>进行封装,继承空接口
public class EventInfo<T> : IEventInfo
{
public UnityAction<T> actions;
public EventInfo( UnityAction<T> action)
{
actions += action;
}
}
//因为有些事件确实不需要参数,可以封装一个无参委托来继承空接口
//这样也能让事件中心更好用
public class EventInfo : IEventInfo
{
public UnityAction actions;
public EventInfo(UnityAction action)
{
actions += action;
}
}
这样,事件中心中的字典就可以写为:
private Dictionary<string, IEventInfo> eventDic = new Dictionary<string, IEventInfo>();
源码
经过以上优化,修改代码后的最终版本:
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.Events; //为了使用Unity自带委托,必须要引用
public interface IEventInfo
{
}
public class EventInfo<T> : IEventInfo
{
public UnityAction<T> actions;
public EventInfo( UnityAction<T> action)
{
actions += action;
}
}
public class EventInfo : IEventInfo
{
public UnityAction actions;
public EventInfo(UnityAction action)
{
actions += action;
}
}
/// <summary>
/// 事件中心
/// </summary>
public class EventCenter
{
//单例模式
private static EventCenter instance;
public static EventCenter GetInstance()
{
if (instance == null)
instance = new EventCenter();
return instance;
}
private Dictionary<string, IEventInfo> eventDic = new Dictionary<string, IEventInfo>();
//添加事件监听
public void AddEventListener<T>(string name, UnityAction<T> action)
{
if( eventDic.ContainsKey(name) )
{
(eventDic[name] as EventInfo<T>).actions += action;
}
else
{
eventDic.Add(name, new EventInfo<T>( action ));
}
}
//监听不需要参数传递的事件
public void AddEventListener(string name, UnityAction action)
{
if (eventDic.ContainsKey(name))
{
(eventDic[name] as EventInfo).actions += action;
}
else
{
eventDic.Add(name, new EventInfo(action));
}
}
//移除对应的事件监听
public void RemoveEventListener<T>(string name, UnityAction<T> action)
{
if (eventDic.ContainsKey(name))
(eventDic[name] as EventInfo<T>).actions -= action;
}
//移除不需要参数的事件
public void RemoveEventListener(string name, UnityAction action)
{
if (eventDic.ContainsKey(name))
(eventDic[name] as EventInfo).actions -= action;
}
//事件触发
public void EventTrigger<T>(string name, T info)
{
if (eventDic.ContainsKey(name))
{
if((eventDic[name] as EventInfo<T>).actions != null)
(eventDic[name] as EventInfo<T>).actions.Invoke(info);
}
}
//事件触发(不需要参数的)
public void EventTrigger(string name)
{
if (eventDic.ContainsKey(name))
{
if ((eventDic[name] as EventInfo).actions != null)
(eventDic[name] as EventInfo).actions.Invoke();
}
}
//清空事件中心
public void Clear()
{
eventDic.Clear();
}
}
自此,这样的“事件中心”已能基本满足各种需求。
如果各位有其他补充,欢迎在评论区讨论。