前置知识:(建议时间充裕的小伙伴可以先看前置知识,这样能更好地理解本文中的一些阐述)
C#委托
C#事件
观察者(发布-订阅)模式

阅读本文,你将学到:

  1. 如何利用发布-订阅模式的思想来编写事件管理中心,以及其他脚本如何和事件管理中心进行交互
  2. C# 的 EventHandler 和 EventArgs
  3. C# 扩展方法
  4. 硬编码传递字符串的缺点
  5. 为什么添加了事件处理器后一定要在合适的时候将它移除
  6. 使用事件管理中心的好处

😊 前言

一个游戏中可以包含很多事件。比如游戏胜利事件,游戏失败事件,触发机关事件。事件的发生过程和 C# 的事件定义也是相吻合的。这里再次回顾一下 C# 事件的概念:

一个类或者对象中的事件发生后会通知订阅了这个事件的其他类、其他对象。别的类、对象在接收到这个通知之后就会纷纷作出他们各自的响应。

那么从中可以看出,一个游戏对象的某个事件发生后,会去通知其他与之相关联的对象,然后会触发这些对象的某些行为(在代码中就是方法)。比如游戏失败事件发生后,敌人停止移动,界面上显示游戏失败的 UI;比如打开背包时相当于触发背包打开事件,这个时候要显示背包 UI,禁用玩家移动的功能。

但是直接使用 C# 的事件也会存在这么几个问题:

  1. 因为事件是隶属于某个类或对象的,那么当项目工程量大的时候,事件就会散落在各个脚本中,这样可能不便于管理。😟

  2. 而且虽然事件已经能大大地降低代码的耦合性,但事件毕竟是类的一个成员,假设类 B 要把它的一个方法注册到类 A 的事件上,那么要先在类 B 里得到类 A 的对象,之后才能获取类 A 的成员。最简单的方法就是在类 B 中定义一个类型为 A 的成员变量(换句话说就是类 B 需要持有类 A 的引用),这样才能在之后将此变量实例化,从而得到类 A 的对象。
    但是如果一个类持有另一个类的引用时,它们的关联程度就会增加,也就是代码耦合性变高,这可能会导致修改一个类会影响另一个类的代码,造成“牵一发而动全身”的窘境。所以降低类和类之间的耦合是程序开发中很有必要的一件事。😟
    不过,有的小伙伴可能会想到用静态或者单例模式来避免“一个类直接持有另一个类的引用”。那就说到点子上了!但是这样还是无法避免第一个问题,就是事件会散落在各脚本中。

为了更好地集中管理游戏中的所有事件,以及减少“获取各个类的事件前要先要持有该类的引用”这种麻烦操作,借助观察者模式(严格来说会用到发布-订阅模式)的设计思想,我们编写一个事件管理中心脚本,用于储存所有事件,并且作为第三方的调度中心,统一处理事件的注册,移除,发送等操作,将事件拥有者和事件响应者完全解耦。

【事件的需求可以有很多种实现方式,为什么选用发布-订阅模式?观察者模式和发布-订阅模式是什么?对这些知识还不怎么熟悉的小伙伴可以先看这篇文章:观察者(发布-订阅)模式
在这里插入图片描述
通过这张图可以看出,构建好事件管理中心后,我们只要在事件响应者处进行事件注册(也叫事件订阅,并且一个事件可以有多个事件响应者),然后在事件拥有者处触发存储在事件管理中心里的对应事件,就能完成一次事件的通信。事件拥有者和事件响应者可以完全是两个不同的模块,以此可实现模块间的解耦。


📕 让唯一的事件管理中心统领全局

像这种充当管理者的脚本一般要是全局且唯一存在的,总不能切换了游戏场景后脚本就失效了吧?总不能一个游戏同时出现多个作用相同管理者吧(那到底要听谁的)?
所以这为我们设计脚本结构提供了两种思路:单例模式静态类
那应该用哪一个呢?其实作用上都差不多。查阅了一些资料,发现它们各有各的优点:

  • 静态类的访问效率会比单例模式高一点,如果仅仅是提供一些全局方法的工具类,使用静态类会更合适。
  • 单例模式可以和面向对象的特性挂钩,并且想要使用的时候才会去实例化类,静态类是一开始就存在的,所以单例模式会比静态类更节约。单例模式对象可以允许被释放,静态类对象不行。如果一个类是单例类,那么我们可以显而易见地识别这个类是一个模块类(程序框架设计中的一个建议),如果一个类是静态类,那么我们可能还要花点时间去思考这个静态类是模块类,工具类还是常量类。

确实是选哪个都行,二者也各有利弊。那这里我就根据个人喜好,选用单例模式来讲解,因为我希望这个事件管理中心成为一个模块。
首先创建一个单例基类,因为除了事件管理脚本,可能还有其他的管理器脚本也要是单例的,那么为了代码复用,就干脆写一个单例基类,让管理器脚本继承自单例基类,这样管理器脚本就拥有了单例的特性:

public class SingletonBase<T> where T:new()
{
    private static T instance;
    // 多线程安全机制
    private static readonly object locker = new object();
    public static T Instance
    {
        get
        {
            if (instance == null)
            {
                //lock写第一个if里是因为只有该类的实例还没创建时,才需要加锁,这样可以节省性能
                lock (locker)
                {
                    if (instance == null)
                        instance = new T();
                }
            }
            return instance;
        }
    }
}

想了解更多单例基类的文章可以看我这篇文章:unity 单例基类(运用单例模式)


📕 事件管理中心组成部分

然后就要编写事件管理中心脚本了。
重点是把所有事件集中存储起来。那我们怎么知道会触发哪一个事件呢?为了更快地找到我们想要触发的事件,我们用字典来存储。Key 是事件的名称, Value 呢?

一个事件发生后,会触发事件响应者注册的事件处理器(事件响应者的成员方法)。因为事件是委托的包装器,所以从底层上看是事件包装的委托能匹配与之类型兼容的方法。那我们要想办法让各种类型的方法都能绑定到我们的事件管理中心,无论多少个参数,无论返回值类型是什么(不过事件绑定的方法返回值一般都是 void,绑定有返回值的方法没什么意义,因为多播委托只会得到最后一个方法的返回值)。

可我们知道,无论是使用系统自带的委托 Action,还是用 delegate 关键字修饰的自定义委托,一次只能声明一类返回值类型和参数列表能匹配的方法,怎样才能涵盖各个类型的方法呢?

还好,C# 为我们提供了一个通用的委托,叫做 EventHandler(要想使用需引入 System 命名空间)。
同时依照 C# 的命名规范,一个提供给事件的委托最好命名成 " xxx+EventHandler " 的形式,所以 EventHandler 本身也是为事件而准备的, 来看看它的结构:

public delegate void EventHandler(object sender, EventArgs e);

第一个参数是事件源,也就是事件拥有者;第二个参数是事件触发时传递的参数,C# 为我们提供了 EventArgs 这个类,用于包装触发事件时传递的参数。来看看它的结构:

    public class EventArgs
    {
        public static readonly EventArgs Empty;
        public EventArgs();
    }

但是很明显,这个类并没有任何成员变量可用于存储我们想要传递的参数。Empty 变量是只读的,无法被修改,它代表空参数。我们如果要真正传递一个非空的参数,需要自定义一个类继承于 EventArgs,然后定义成员变量来存储需要传递的参数(后文会通过代码来演示)。

所以我们的字典 Key 是事件名, Value 是 EventHandler,这个字典存储的就是各种各样的事件,触发我们想要的事件就可以通过事件名来找到对应的 EventHandler,然后调用 EventHandler 就会间接调用此事件所绑定的所有方法。

private Dictionary<string, EventHandler> handlerDic = new Dictionary<string, EventHandler>();

注:这里说的事件可以认为是“概念”上的事件,从语法上来看我们只是去模仿 C# 事件语法的功能。因为我们所用的 EventHandler 毕竟只是委托,它并没有涉及 C# 事件的语法(添加 event 关键字)。但事件是用来包装委托的,我们的字典同样也是存储委托的,所以此时我们是把字典当作委托的包装器,去实现事件语法所做的事(事件的语法对包装的委托起到保护作用:只允许其他类添加、移除事件处理器,并且只能在类的内部触发事件)

所以类似的,我们要向外界提供对此字典的操作:

  1. 添加事件处理器到字典中;
  2. 将特定事件处理器从字典中移除;
  3. 通过事件名(可能夹带事件参数)找到字典中存储的对应事件,然后触发它。毕竟事件管理中心只是起到事件托管的作用,从事件的概念上来讲事件应属于那些事件拥有者,因此要保证事件拥有者能找到那个属于它们自己的事件,然后借助事件管理中心来触发;
  4. 清空字典的所有事件。

顺着这个思路,我们可以编写事件管理中脚本的代码啦!


📕 脚本代码讲解

/// <summary>
/// 事件管理器
/// </summary>
public class EventManager:SingletonBase<EventManager>
{
    private Dictionary<string, EventHandler> handlerDic = new Dictionary<string, EventHandler>();
    /// <summary>
    /// 添加一个事件的监听者
    /// </summary>
    /// <param name="eventName">事件名</param>
    /// <param name="handler">事件处理函数</param>
    public void AddListener(string eventName,EventHandler handler)
    {
        if (handlerDic.ContainsKey(eventName))
            handlerDic[eventName] += handler;
        else
            handlerDic.Add(eventName, handler);
    }
    /// <summary>
    /// 移除一个事件的监听者
    /// </summary>
    /// <param name="eventName">事件名</param>
    /// <param name="handler">事件处理函数</param>
    public void RemoveListener(string eventName, EventHandler handler)
    {
        if (handlerDic.ContainsKey(eventName))
            handlerDic[eventName] -= handler;
    }
    /// <summary>
    /// 触发事件(无参数)
    /// </summary>
    /// <param name="eventName">事件名</param>
    /// <param name="sender">触发源</param>
    public void TriggerEvent(string eventName,object sender)
    {
        if (handlerDic.ContainsKey(eventName))
            handlerDic[eventName]?.Invoke(sender, EventArgs.Empty);            
    }
    /// <summary>
    /// 触发事件(有参数)
    /// </summary>
    /// <param name="eventName">事件名</param>
    /// <param name="sender">触发源</param>
    /// <param name="args">事件参数</param>
    public void TriggerEvent(string eventName,object sender,EventArgs args)
    {
        if (handlerDic.ContainsKey(eventName))
            handlerDic[eventName]?.Invoke(sender, args);
    }
    /// <summary>
    /// 清空所有事件
    /// </summary>
    public void Clear()
    {
        handlerDic.Clear();
    }
}

那么要想在其他类调用此脚本中的方法,先通过 EventManager.Instance 获取此脚本的对象,这时也就创建了全局唯一的实例,之后再次调用 EventManager.Instance 返回的就是之前已经创建的 EventManager 实例了。然后就能调用方法,比如我想调用触发事件方法,就用:

EventManager.Instance.TriggerEvent("PlayerDead",this);
//"PlayerDead"是事件名,this是事件拥有者,也就是当前的类

不过我觉得这样触发事件还不够爽,为了更加贴合事件的概念,我要让代码看起来更像是事件拥有者有触发事件的方法,也就是事件拥有者主动触发了事件。这里我用上扩展方法来简化调用。
扩展方法其实很简单,它的定义是:
1)声明扩展方法的类必须为 static 类
2)扩展方法本身也必须声明为 static
3)扩展方法必须包含关键字 this 作为第一个参数类型,并在后面跟着它所扩展的类型的名称(也就是定义哪一个类能够拥有此扩展方法)。
所以我在事件管理中心脚本中额外定义一个扩展类(我把它定义在了 EventManager 类的上方),用来提供扩展方法:

/// <summary>
/// 便于触发事件的扩展类
/// </summary>
public static class EventTriggerExt
{
    /// <summary>
    /// 触发事件(无参数)
    /// </summary>
    /// <param name="sender">触发源</param>
    /// <param name="eventName">事件名</param>
    public static void TriggerEvent(this object sender, string eventName)
    {
        EventManager.Instance.TriggerEvent(eventName, sender);
    }
    /// <summary>
    /// 触发事件(有参数)
    /// </summary>
    /// <param name="sender">触发源</param>
    /// <param name="eventName">事件名</param>
    /// <param name="args">事件参数</param>
    public static void TriggerEvent(this object sender, string eventName, EventArgs args)
    {
        EventManager.Instance.TriggerEvent(eventName, sender, args);
    }
        
}

this object 使得一个 Object 类能够获取 TriggerEvent 扩展方法,虽然我们定义这个扩展方法时声明成 static,但是神奇之处就在于 EventTriggerExt 类的 TriggerEvent 方法会成为 Object 类的一个实例(非静态)方法。因为 Object 类是所有类的父类,所以任何一个类(但是除了静态类)都会拥有这个方法。这个时候触发事件就更简单了,直接写成:

this.TriggerEvent("PlayerDead");

TriggerEvent 就成了当前类的扩展方法。


📕 EventManager.cs 完整代码

/// <summary>
/// 便于触发事件的扩展类
/// </summary>
public static class EventTriggerExt
{
    /// <summary>
    /// 触发事件(无参数)
    /// </summary>
    /// <param name="sender">触发源</param>
    /// <param name="eventName">事件名</param>
    public static void TriggerEvent(this object sender, string eventName)
    {
        EventManager.Instance.TriggerEvent(eventName, sender);
    }
    /// <summary>
    /// 触发事件(有参数)
    /// </summary>
    /// <param name="sender">触发源</param>
    /// <param name="eventName">事件名</param>
    /// <param name="args">事件参数</param>
    public static void TriggerEvent(this object sender, string eventName, EventArgs args)
    {
        EventManager.Instance.TriggerEvent(eventName, sender, args);
    }

}
/// <summary>
/// 事件管理器
/// </summary>
public class EventManager : SingletonBase<EventManager>
{
    private Dictionary<string, EventHandler> handlerDic = new Dictionary<string, EventHandler>();

    /// <summary>
    /// 添加一个事件的监听者
    /// </summary>
    /// <param name="eventName">事件名</param>
    /// <param name="handler">事件处理函数</param>
    public void AddListener(string eventName, EventHandler handler)
    {
        if (handlerDic.ContainsKey(eventName))
            handlerDic[eventName] += handler;
        else
            handlerDic.Add(eventName, handler);
    }
    /// <summary>
    /// 移除一个事件的监听者
    /// </summary>
    /// <param name="eventName">事件名</param>
    /// <param name="handler">事件处理函数</param>
    public void RemoveListener(string eventName, EventHandler handler)
    {
        if (handlerDic.ContainsKey(eventName))
            handlerDic[eventName] -= handler;
    }
    /// <summary>
    /// 触发事件(无参数)
    /// </summary>
    /// <param name="eventName">事件名</param>
    /// <param name="sender">触发源</param>
    public void TriggerEvent(string eventName, object sender)
    {
        if (handlerDic.ContainsKey(eventName))
            handlerDic[eventName]?.Invoke(sender, EventArgs.Empty);
    }
    /// <summary>
    /// 触发事件(有参数)
    /// </summary>
    /// <param name="eventName">事件名</param>
    /// <param name="sender">触发源</param>
    /// <param name="args">事件参数</param>
    public void TriggerEvent(string eventName, object sender, EventArgs args)
    {
        if (handlerDic.ContainsKey(eventName))
            handlerDic[eventName]?.Invoke(sender, args);
    }
    /// <summary>
    /// 清空所有事件
    /// </summary>
    public void Clear()
    {
        handlerDic.Clear();
    }
}

📕 使用指南

🔍 封装一些全局的脚本

刚刚调用触发事件的方法时,我们是直接传入一个字符串"PlayerDead"作为事件名。但是这样写属于硬编码,把参数写死了,那么会存在几个缺点:
1)我们在注册事件和触发事件时都要传入事件名,如果这两处地方都是使用 “xxx” 硬编码的形式,很容易出现因疏忽导致拼错的情况,这种 bug 往往是不容易发现的。
2)硬编码没有代码补全,写得不爽
3)如果事件名发生改变,需要找到每一处传入此事件名的地方,也是非常不容易。
因此我们可以创建一个脚本存放这些全局的事件名:

public static class EventName 
{
    public const string PlayerDead = "PlayerDead";

}

如果变量名和我们定义的事件名称字符串是相同的,我们甚至可以用 nameof 表达式加快编码速度(因为有代码自动补全):

public static class EventName 
{
    public const string PlayerDead = nameof(PlayerDead);

}

(nameof 表达式可以将变量、类型或成员的名称作为字符串常量)

那么之后就可以把游戏中的所有事件名统一写在这个脚本,事件名定义成常量即可。

接下来要实现事件参数类的管理,因为我们在传递事件参数时,通常是自定义一个继承自 EventArgs 的类,然后把数据类型封装在这个类中。那么我们也可以创建一个脚本,将所有的事件参数类统一写在这个脚本。我把它命名为了 CustomEventArgs 。
不过,额外创建一个脚本来存放自定义的事件参数类是我的个人习惯,主要是为了集中管理。当然,也可以将事件参数类定义在和事件拥有者同一个脚本里,这样可能会使脚本功能的可读性更高一点。

到这里,我们的事件系统有 3 个脚本文件:
在这里插入图片描述
其中 CustomEventArgs 脚本是可选的。

🔍 在别的类使用事件管理中心

设想一个玩家阵亡的游戏事件,当此事件触发后,界面显示游戏失败 UI,并且要显示玩家的名字(当然,玩家阵亡事件可能还会伴随其他事情的发生,我这里主要向大家展示事件拥有者和事件响应者如何交互,就只用游戏失败 UI 来举例)。
为了表示方便,我直接输出一句话来代表失败 UI 的事件触发方法,当我按下键盘 J 键时触发玩家阵亡事件。
那么之前写好了事件管理中心,我们剩余要做的只是在“事件拥有者”(玩家)和“事件响应者” (UI) 中编写相应逻辑。

这里需要传递事件参数,即玩家的名字(为了表示方便,我直接用游戏物体的名字来替代)。于是我先在 CustomEventArgs 脚本里新建一个传参类:

public class PlayerDeadEventArgs : EventArgs
{
    public string playerName;
}

在 EventName 脚本中定义事件名:

public static class EventName 
{
    public const string PlayerDead = nameof(PlayerDead);

}

玩家脚本:

public class Player : MonoBehaviour
{

    void Update()
    {
        if (Input.GetKeyDown(KeyCode.J))
        {
            this.TriggerEvent(EventName.PlayerDead,
            	new PlayerDeadEventArgs { playerName=gameObject.name});//运用到了扩展方法
        }
    }  
}

游戏失败 UI 脚本:

public class GameoverUI : MonoBehaviour
{
    private void Awake()
    {
        EventManager.Instance.AddListener(EventName.PlayerDead, ShowGameOver);
    }
    private void OnDestroy()
    {
        EventManager.Instance.RemoveListener(EventName.PlayerDead, ShowGameOver);
    }
    private void ShowGameOver(object sender, EventArgs e)
    {
        var data = e as PlayerDeadEventArgs;
        if (data != null)
        {
            print($"游戏结束,{data.playerName}阵亡");
        }
        
    }  
}

注:
1)AddListener 方法要传入一个 EventHandler 类型的参数,因为委托本身就是绑定方法的,所以直接传递方法就是代表传递了一个委托,也可以这么理解:通过把方法名直接用“=”的方式赋值,可实现委托的实例化。那么这里直接传入方法相当于 new EventHandler(ShowGameOver)
2) 事件触发方法的参数必须要保证第一个是 object,第二个是 EventArgs,因为要与 EventHandler 的声明类型兼容
3)ShowGameOver 方法接收到参数时,可先进行类型转换,把作为父类的 EventArgs 转换成作为子类的 PlayerDeadEventArgs,然后就可以取出封装在 PlayerDeadEventArgs 的参数
4)向事件管理中心注册了事件处理器后,一定要在合适的地方把事件处理器从事件管理中心的字典里移除!!!
设想一下,在当前的场景向事件管理中心注册了一个事件处理器,之后跳转到了新场景。这时候事件字典中仍然存储了前一个场景注册的那个事件处理器,但是此事件处理器所隶属的事件响应者很可能随着切换场景被销毁了,虽然此时触发事件确实无法调用被销毁物体的事件处理器,但是这个物体只是在引擎层面被销毁(被标记了 null 标志,我们无法在 Hierarchy 和 场景中看到被销毁的对象) ,而物体的脚本对象仍然存在于内存中,虽然正常来说在物体被销毁一段时间过后系统检测到这个对象没有任何引用(也就是没有地方使用到它)时就会把它从内存中释放掉(也叫垃圾回收机制https://www.cnblogs.com/timeObjserver/p/7575035.html)。
但是!!!!!因为事件字典的委托与对象的方法仍建立着联系,导致这个事件处理器所属的对象无法被释放,造成内存泄漏(这也是在委托篇章中提到的委托的缺点)。那这不就是“幽灵事件”吗?😰

或者,比如游戏重新开始,在重新加载原来的场景时由于之前的事件处理器没被移除,在脚本初始化后又会把相同的事件处理器注册一次!那么触发事件时就会触发两次事件处理器。

所以一般来说推荐的组合是:
1)在 Awake/Start 方法中把事件处理器添加到字典中,在 OnDestroy 方法中把事件处理器从字典中移除。
2)在 OnEnable 方法中把事件处理器添加到字典中,在 OnDisable 方法中把事件处理器从字典中移除。
这个要根据需求决定用哪种组合。

按下 J 键后的结果:
在这里插入图片描述
现在我们增添一个需求:
玩家触发机关时敌人停止移动。为了表示方便,当我按下空格键时表示触发机关,敌人停止移动用一句输出语句表示。
首先在 EventName 中定义事件名:

public static class EventName 
{
    public const string PlayerDead = nameof(PlayerDead);
    public const string TriggerStop = nameof(TriggerStop);
}

扩展玩家脚本:

public class Player : MonoBehaviour
{

    void Update()
    {
        if (Input.GetKeyDown(KeyCode.J))
        {
            this.TriggerEvent(EventName.PlayerDead,new PlayerDeadEventArgs { playerName=gameObject.name});//运用到了扩展方法
        }
        if (Input.GetKeyDown(KeyCode.Space))
        {
            this.TriggerEvent(EventName.TriggerStop);
        }
    }  
}

敌人脚本:

public class Enemy : MonoBehaviour
{
    private void Awake()
    {
        EventManager.Instance.AddListener(EventName.TriggerStop, StopMove);
    }
    private void OnDestroy()
    {
        EventManager.Instance.RemoveListener(EventName.TriggerStop, StopMove);
    }
    private void StopMove(object sender, EventArgs e)
    {
        print($"{gameObject.name}停止移动");
    }  
}

这里就不需要在触发事件时传参了。
运行结果:
在这里插入图片描述
可以发现,有了事件管理中心后,我只需要在事件拥有者处编写触发事件的代码;在事件响应者处编写事件处理器,向事件管理中心注册事件处理器,一般来说还要在隐藏或者销毁时将事件处理器从事件管理中心移除掉。这样就可以实现不同对象的交互通信。

事件响应者或事件拥有者的代码改动不会影响另一方,事件的处理在底层上全交给了事件管理中心,这样对跨模块的交互起到很好的解耦作用。


😟目前存在的缺点:
因为事件拥有者和事件响应者完全解耦,导致不好通过代码一眼看出事件拥有者和事件响应者的对应关系。有时候你在代码中看到了一处 this.TriggerEvent ,可能会忘了这个事件所绑定的事件处理器是在哪些脚本注册的。也有可能看到一处事件的注册,但是忘了该事件在什么地方触发。
这个时候可能要借助 IDE 的功能,比如我用的是 VS,可以先选中一个变量,再通过 Shift+F12 来查找一个变量的所有引用位置
在这里插入图片描述
在这里插入图片描述
这也体现了事件名不用硬编码而是用一个常量来表示的好处。不过,事件管理中心的字典 Key 只是为了标识哪个事件,不用字符串而用枚举来作为 Key 也是可以的,就像这样:

//原来的静态事件名类可以改为枚举
public enum EventName
{
    PlayerDead
}
private Dictionary<EventName, EventHandler> handlerDic = new Dictionary<EventName, EventHandler>();

相比之前的事件名类少写了一些代码。也限定了事件名不会以硬编码的形式出现。
然后对于之前所有和字典 Key 有关的部分,把原来的 string 改成我们自定义的枚举就好了。
不过经查阅资料,枚举作为字典 Key 的时候会在查询时产生装箱的问题,在性能上可能会有一点点损耗,具体可以参考这些文章:

https://blog.csdn.net/zhaogenzi/article/details/9361349
https://blog.csdn.net/qq_41596891/article/details/107862044
https://ayende.com/blog/3885/dictionary-enum-t-puzzler(英文)

所以我个人还是会用 string 作为字典的 Key。

总的来说,合理地使用事件管理中心还是利大于弊的,能使模块之间分工明确,降低耦合。

这里再提供另一种事件管理中心的写法:Unity 事件番外篇:事件管理中心(另一种版本) 核心思想是类似的,只是实现方式各有利弊,大家可以选择适合自己的版本使用。


委托与事件系列:
C#委托(结合 Unity)
C#事件(结合 Unity)
观察者模式(结合C# Unity)
Unity 事件管理中心
事件番外篇:UnityEvent
Unity 事件番外篇:事件管理中心(另一种版本)

Logo

开放原子开发者工作坊旨在鼓励更多人参与开源活动,与志同道合的开发者们相互交流开发经验、分享开发心得、获取前沿技术趋势。工作坊有多种形式的开发者活动,如meetup、训练营等,主打技术交流,干货满满,真诚地邀请各位开发者共同参与!

更多推荐