本网站基于Next.js开发

事件系统

Cover Image for 事件系统
QinMo
QinMo

事件系统定义

事件系统是一种广泛应用的编程范式,用于实现对象间的解耦通信。它允许对象在特定情况下广播事件,而其他对象则可以侦听和响应这些事件。这种机制为构建灵活、可扩展和可维护的软件系统奠定了基础。

该系统应当有一下几个概念定义:

  1. 事件(Event):特定状态或标记,由事件订阅者和发布者达成的信号约定。
  2. 事件发布者(Event Publisher/Broadcaster):引发或触发事件的对象,事件的来源。
  3. 事件订阅者(Event Subscriber/Listener):订阅感兴趣的事件,并在事件发生时执行响应逻辑。
  4. 事件处理程序(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);

不唯一的多个事件集合容易产生事件冲突,这意味着不应存在像 Events1Event2 这样的多个集合类,不同的类中的相同名称成员会导致问题的难以定位(事件分组除外)。

事件类型

仔细观察这段代码 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, //结束游戏
}

对比于类型成员的定义方式我本人更倾向于使用枚举类型定义,使用枚举类型确实带来了以下一些优势:

  1. 内存占用更小:相比字符串,枚举值占用的内存空间更小。
  2. 类型安全:编译器可以检查传入的事件类型参数是否为有效值,避免无效的事件类型。
  3. 可读性更好:枚举值的命名可以很好地体现事件的semantics,代码更加易读易理解。
public void Register(Events key, EventHandler handler)

消息事件

当前的事件管理器尚不支持事件消息的传递,我们需要对其进行扩展以实现此功能。一种较为简单的方法是使用 object 作为委托类型的定义 public delegate void EventHandler(object message)。由于 object 是所有类型的基类,它能够支持调用时传递任何类型的参数。但是,当传递值类型参数时会出现装箱和拆箱的操作,大量事件消息的传递可能会导致系统频繁GC,进而造成性能损失。因此,使用泛型委托实现是更优的选择。

由于泛型委托无法直接指定为字典的键值对类型,我们必须进行封装设计,将其抽象为一个明确的接口类型 - 事件管理类(Event Class)。该接口不仅包含了事件的核心功能,还在内部丰富了 CountClear 两个方法,用于获取当前事件注册的处理程序数量和清空所有注册的处理程序。另外,在事件触发时还传入了 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();
    }
}

为不同的角色提供不同事件组的订阅,从而限制他们可访问事件类型,拥有更好的事件隔离以及权限控制。如果在事件分组上设计出更好的分组层级关系,一次性启用或禁用整个事件组而不必逐个处理每个事件,为系统提供更高级别的事件管理功能。