事件系统
事件系统定义
事件系统是一种广泛应用的编程范式,用于实现对象间的解耦通信。它允许对象在特定情况下广播事件,而其他对象则可以侦听和响应这些事件。这种机制为构建灵活、可扩展和可维护的软件系统奠定了基础。
该系统应当有一下几个概念定义:
- 事件(Event):特定状态或标记,由事件订阅者和发布者达成的信号约定。
- 事件发布者(Event Publisher/Broadcaster):引发或触发事件的对象,事件的来源。
- 事件订阅者(Event Subscriber/Listener):订阅感兴趣的事件,并在事件发生时执行响应逻辑。
- 事件处理程序(Event Handler):事件订阅者在事件发生时执行的响应逻辑。
这里还要明确三个容易混淆的名词,通常这些名词不被注意并且混为一谈,事件 (Event)、消息事件 (Message Event) 、事件消息 (Event Message):
- 事件通常代表最简单的信号,他是不带任何数据的,有时候也被称为无参事件,而传递时附带消息数据的特殊事件称作消息事件,消息事件在事件的基础上增加了数据传递的功能,可以将相关数据从发布者传递给订阅者,有时被称为有参事件。很多地方不区分两者,本文对两者概念做出严格区分。
- 消息事件中所传递的数据通常被称为消息、消息体或者事件消息。
本文对上述三个概念将严格区分。仅支持事件或消息事件的系统,以及同时支持这两者的系统,都统称为事件系统。
事件系统实现方式
事件系统的实现方式有多,中这里主要介绍两种基于 C Sharp 标准库的实现,关于 Unity 消息系统、事件可视化以及第三方事件系统解决方案这里都不做过描述。
委托和事件
这是 Unity 中最常用的事件系统实现方式。可以定义自己的委托类型,然后定义一个事件来保存这种委托类型的函数引用。其他脚本可以订阅和取消订阅这个事件。通过调用事件时,会执行所有订阅的委托方法。
观察者模式
这种模式定义了对象之间的一对多依赖,这样依赖对象的状态改变时,所有观察者对象都会自动得到通知并自动更新。可以自己实现,也可以使用 C# 中的内置观察者模式解决方案。
系统实现
现在演示一个简单的基于委托的事件系统实现:
public class EventManager:SingletonInstance<EventManager>
{
//事件委托
public delegate void EventHandler();
//事件和事件执行程序映射关系
private Dictionary<string, EventHandler> _events = new Dictionary<string, EventHandler>();
}
public abstract class SingletonInstance<T> where T:class,new()
{
private static T _instance;
public static T Instance
{
get
{
if (_instance == null)
{
_instance = new T();
}
return _instance;
}
}
}
SingletonInstance<EventManager>
代表了 EventManager
是事件系统的中的一个单例管理类,位系统提供一个独立的全局访问模块。
了解更多有关单例类的实现请查询单例模式,这里仅给出一个懒汉式泛型单例基类实现
EventManager
中由 _events
字典类型的字段成员存储事件和事件执行程序的对应关系。这里使用字符串类型定义事件,这是一种简单的表示方法,事件的字符串值清晰的反映了事件所代表的含义。
接下来需要为管理器实现几个基础方法以供外部使用。
/// <summary>
/// 注册事件
/// </summary>
/// <param name="key">事件</param>
/// <param name="handler">事件处理程序</param>
public void Register(string key, EventHandler handler)
{
if (!_events.ContainsKey(key))
{
_events[key] = null;
}
_events[key] += handler;
}
/// <summary>
/// 注销事件
/// </summary>
/// <param name="key">事件</param>
/// <param name="handler">事件处理程序</param>
public void UnRegister(string key, EventHandler handler)
{
if (_events.ContainsKey(key))
{
_events[key] -= handler;
}
}
/// <summary>
/// 触发事件
/// </summary>
/// <param name="key">事件</param>
public void TriggerEvent(string key)
{
if (_events.TryGetValue(key, out EventHandler handlers))
{
handlers?.Invoke();
}
}
注册事件从外部调用时,传递代表事件的字符串以及委托方法作为参数,向管理器添加该信息的记录。注销和触发事件调用时查询该条目并从中移除或调用,这一切实现的如此简单完全得益于 CSharp 中的委托类型。
有关 CSharp 委托类型请查阅委托 - C# 编程指南 | Microsoft Learn
使用时我们只需要编辑如下简单的代码
EventManager.Instance.Register("EnterGameEvent",OnEnterGame); //订阅事件
private void OnEnterGame() //事件处理程序
{
Debug.Log("Trigger OnEenterGame");
}
EventManager.Instance.TriggerEvent("EnterGameEvent"); //触发事件
EventManager.Instance.UnRegister("EnterGameEvent", OnEnterGame); //取消订阅事件
一个基于字符串的简单事件管理器就此完成,我们可以为此附加更多的方法以助于使用:
ClearRegister(string key)
清除某一种事件订阅的方法ClearAllRegister
清除全部事件订阅的方法- ......
为了便于订阅和触发时的代码编辑和管理,我们会定义一个事件集合类收集程序中所用到的事件信号标识。
// 定义一个静态类存储所有的事件名称,方便管理和防止拼写错误
public static class EventNames
{
public const string EnterGameEvent = "EnterGame";
public const string ExitGameEvent = "ExitGame";
// 其他事件...
}
建议使用静态类而不是实例类来存储事件名称,防止无意间创建了多个实例。使用常量而不是字段,可以防止意外修改事件名称。这样在调用时只需要当作类型成员这样调用即可,能够很好的增加开发,并保证订阅和触发时事件的一致。
EventManager.Instance.Register(Events.EndGameEvent,OnEnterGame);
EventManager.Instance.TriggerEvent(Events.EndGameEvent);
EventManager.Instance.UnRegister(Events.EndGameEvent, OnEnterGame);
不唯一的多个事件集合容易产生事件冲突,这意味着不应存在像 Events1
、Event2
这样的多个集合类,不同的类中的相同名称成员会导致问题的难以定位(事件分组除外)。
事件类型
仔细观察这段代码 string EnterGameEvent = "EnterGameEvent"
可以发现到事件信号定义上存在很大的优化空间,既然变量名已经包含了事件的含义,使用这种方式定义事件是一种浪费内存的行为,为此应当使用整形来定义。
public class Events
{
public const int EnterGameEvent = 1; //进入游戏
public const int EndGameEvent = 2; //结束游戏
}
另外使用 const
修饰减少内存占用,常量不占用运行时数据区的内存空间,编译器会直接将它们的值替换到代码中,而不会为它们分配内存空间。、
变更事件的声明类型只需要修改事件管理器的部分代码,将 string
类型都替换成 int
就好, 就像这样:
private Dictionary<int, EventHandler> _events = new Dictionary<int, EventHandler>();
或者将事件类型定义枚举类型,既可以减少了内存的占用同时,同时能在调用管理方法时传参避免传入未经定义的值。
public enum Events
{
None = 0,
EnterGameEvent = 1, //进入游戏
EndGameEvent = 2, //结束游戏
}
对比于类型成员的定义方式我本人更倾向于使用枚举类型定义,使用枚举类型确实带来了以下一些优势:
- 内存占用更小:相比字符串,枚举值占用的内存空间更小。
- 类型安全:编译器可以检查传入的事件类型参数是否为有效值,避免无效的事件类型。
- 可读性更好:枚举值的命名可以很好地体现事件的semantics,代码更加易读易理解。
public void Register(Events key, EventHandler handler)
消息事件
当前的事件管理器尚不支持事件消息的传递,我们需要对其进行扩展以实现此功能。一种较为简单的方法是使用 object
作为委托类型的定义 public delegate void EventHandler(object message)
。由于 object
是所有类型的基类,它能够支持调用时传递任何类型的参数。但是,当传递值类型参数时会出现装箱和拆箱的操作,大量事件消息的传递可能会导致系统频繁GC,进而造成性能损失。因此,使用泛型委托实现是更优的选择。
由于泛型委托无法直接指定为字典的键值对类型,我们必须进行封装设计,将其抽象为一个明确的接口类型 - 事件管理类(Event Class)。该接口不仅包含了事件的核心功能,还在内部丰富了 Count
和 Clear
两个方法,用于获取当前事件注册的处理程序数量和清空所有注册的处理程序。另外,在事件触发时还传入了 sender
参数,以便在某些场景下获取事件发布者的相关信息。
public interface IEvent
{
//事件数量
int Count { get; }
//清空事件
void Clear();
}
EventBase
将作为事件管理器中事件处理程序类型的封装类,而管理器中使用的类型将指定为 IEvent
。接下来,我们需要创建一个使用泛型参数的 EventBase<T>
类,以支持事件消息的传递。你可能会好奇为什么不将事件触发方法也声明在 IEvent
接口中?这是因为我们希望该接口同时能够通用到无参事件上,遵守接口隔离原则,确保同类只依赖于最小接口。
/// <summary>
/// 单一参数泛型事件
/// </summary>
/// <typeparam name="T"></typeparam>
public class EventBase<T> : IEvent
{
private event UltraEventHandler<T> m_OnEvent;
public void Register(UltraEventHandler<T> onEvent)
{
m_OnEvent += onEvent;
}
public void UnRegister(UltraEventHandler<T> onEvent)
{
m_OnEvent -= onEvent;
}
public void Trigger(object sender,T e)
{
m_OnEvent?.Invoke(sender, e);
}
public int Count
{
get => m_OnEvent != null ? m_OnEvent.GetInvocationList().Length : 0;
}
public void Clear()
{
m_OnEvent = null;
}
}
现在我们回到事件管理器,继续完成代码的改造。在原有的代码中,我们加入一个中间层,使得事件管理器管理所有的事件类(EventBase),而只有事件类具备直接调用事件的权限。
public class EventManager:SingletonInstance<EventManager>
{
//事件和事件执行程序映射关系
private Dictionary<Events, IEvent> _events = new Dictionary<Events, IEvent>();
public void Register<T>(Events key, UltraEventHandler<T> handler)
{
IEvent e;
if (!_events.TryGetValue(key, out e))
{
e = new EventBase<T>();
_events.Add(key, e);
}
((EventBase<T>)e).Register(handler);
}
public void UnRegister<T>(Events key, UltraEventHandler<T> handler)
{
if (_events.TryGetValue(key, out IEvent e))
{
((EventBase<T>)e).UnRegister(handler);
}
}
/// <summary>
/// 触发事件
/// </summary>
/// <param name="key">事件</param>
public void TriggerEvent<T>(object sender,Events key,T message)
{
if (_events.TryGetValue(key, out IEvent e))
{
((EventBase<T>)e).Trigger(sender, message);
}
}
}
更多扩展
权限隔离
单例模式下的事件管理类可以被任意调用,没有清晰发布者和订阅者并没有清晰的界限划分,通过接口我们可以对事件发布者和事件订阅者进行一定的权力划分,从类的定义上便能够识别在事件传递中扮演的职责。
public interface IEventRegistrant{}
public interface IEventSender{}
通过定义不同的接口,可以明确地控制不同角色或模块对系统功能的访问权限。每个角色或模块只能访问特定的接口,从而实现了功能级别的权限隔离。这有助于提高系统的安全性,防止未经授权的访问或操作。 接下来使用扩展方法为接口赋予执行能力,以下系统能力调用仅作演示,在最终的代码中最好事件系统的关闭全局调用能力,改用内部方法。
public class EventManager : Singleton<EventManager>
{
private Dictionary<EventType, IEvent> events = new Dictionary<EventType, IEvent>();
public void RegisterEvent<T>(EventType eventType, EventHandler<T> handler)
{
// 实现详细代码...
}
public void UnregisterEvent<T>(EventType eventType, EventHandler<T> handler)
{
// 实现详细代码...
}
public void TriggerEvent<T>(EventType eventType, object sender, T arg)
{
// 实现详细代码...
}
}
这样的方式不单可以在此使用,同样适用于其他系统调用能力的职责划分,
事件分组
事件分组让事件系统更好组织和管理,相关的事件被放置在一个组中,方便查找、维护和理解。通过事件分组,订阅者可以更精细化地控制订阅哪些事件。订阅者可以一次性订阅一个组内的所有事件,而不必逐个订阅每个事件。这简化了订阅过程,并提供了更好的灵活性。在订阅者较多的情况下还能够提高触发性能。
public abstract class EventBasis : IDisposable
{
private readonly Dictionary<Type, IEvent> _typeEventBasis = new Dictionary<Type, IEvent>();
/// <summary>
/// 添加指定类型的事件实例
/// </summary>
/// <typeparam name="T">事件类型,需继承自IEvent并含有无参构造函数</typeparam>
public void AddEvent<T>() where T : IEvent, new()
{
_typeEventBasis[typeof(T)] = new T();
}
/// <summary>
/// 获取指定类型的事件实例
/// </summary>
/// <typeparam name="T">事件类型,需继承自IEvent</typeparam>
/// <returns>事件实例,若不存在则返回null</returns>
public T GetEvent<T>() where T : IEvent
{
return _typeEventBasis.TryGetValue(typeof(T), out var @event) ? (@event as T) : null;
}
/// <summary>
/// 获取指定类型的事件实例,若不存在则先添加
/// </summary>
/// <typeparam name="T">事件类型,需继承自IEvent并含有无参构造函数</typeparam>
/// <returns>事件实例</returns>
public T GetOrAddEvent<T>() where T : IEvent, new()
{
if (_typeEventBasis.TryGetValue(typeof(T), out var @event))
return @event as T;
var instance = new T();
_typeEventBasis[typeof(T)] = instance;
return instance;
}
/// <summary>
/// 当前存在的事件类型数量
/// </summary>
public int Count => _typeEventBasis.Count;
/// <summary>
/// 是否存在指定类型的事件实例
/// </summary>
/// <typeparam name="T">事件类型,需继承自IEvent</typeparam>
/// <returns>是否存在</returns>
public bool Contains<T>() where T : IEvent
{
return _typeEventBasis.ContainsKey(typeof(T));
}
public void Dispose()
{
_typeEventBasis.Clear();
}
}
为不同的角色提供不同事件组的订阅,从而限制他们可访问事件类型,拥有更好的事件隔离以及权限控制。如果在事件分组上设计出更好的分组层级关系,一次性启用或禁用整个事件组而不必逐个处理每个事件,为系统提供更高级别的事件管理功能。