跳至内容
Welcome 🎉 Last updated date -> 2026-02-16
Unity泛型·委托及事件总线

Unity泛型·委托及事件总线

前言

学习这个的起因是我在假期没项目期间尝试从godot转unity(当然也不算是转,只能说是复习,因为很久以前我就是从unity转godot的),在学习某个RTS教程时,教程使用了事件总线EventBus来解耦

鉴于我之前开发惯用的都是我自己魔改的单例模式写法,而又苦于看不懂ai写的一大堆天花乱坠的解释,故而在此总结一下事件总线写法

本篇事件总线部分基于以下两个油管教程和我自己做的项目总结,泛型和委托则根据自己理解仅作简单解释不深入探讨:

Unity Event Bus: Simplify Messaging 🎯 - YouTube

Event Bus & Scriptable Object Event Channels | Unity Game Architecture Tutorial - YouTube

泛型·委托

一句话解释,泛型是一种占位型变量类型,可以暂时代替任意变量类型,委托是一个符合特定条件的函数的集合,可以用于将函数作为变量进行传递

(叠甲:本质上讲这样理解是错的,只是对我来讲这样理解更方便,要面八股的话这么理解会被骂,具体概念最好去问ai大人)

泛型

设想一个需求,我们要写三个用于存储不同变量类型的类,这里举例为int,float,string

通常的写法是

public class IntClass
{
    private int data;
    public void SetData(int _data) => data = _data;
    public int GetData() => return data;
}

//然后cv这一段,把里面的int改成float和string
//我偷懒不想cv了

可以,但是不好

因为可以用泛型做到简化形式

public class DataClass<T>
{
    private T data;
    public void SetData(T _data) => data = _data;
    public T GetData() => return data;
}

//假如要声明一个具体的类,就加一点点字段
private void Function()
{
    DataClass<int> intClass = new DataClass<int>();
    intClass.SetData(100);
    //或者直接写成var形式
    var floatClass = new DataClass<float>();
    floatClass.SetData(1.0f);
}

泛型会在编译期被检查,所以不用担心安全问题,它比手写类还安全些

为什么用T?这是个题外话,一个约定俗成而已,这里的T是Type的缩写,就代表任意变量类型的意思,我们完全可以使用其他的字母代替,比如I(Interface接口的约定俗成),只是可能被骂

委托

比如我们要写多个函数同时调用

public delegate void ReturnSomething();

public class Test
{
    private void ReturnA() => Debug.Log("A");
    private void ReturnB() => Debug.Log("B");

    public void Function()
    {
        ReturnSomething remote = ReturnA; 
        remote();
 
        remote = ReturnB;
        remote();
    }
}

以上是最原始的委托写法,当然,我们也可以使用Action来简化一些代码

public class Test
{
    private Action ReturnSomething;
    //这里的ReturnSomething现在是变量而不是变量类型

    private void ReturnA() => Debug.Log("A");
    private void ReturnB() => Debug.Log("B");

    public void Function()
    {
        ReturnSomething = ReturnA; 
        ReturnSomething();
 
        ReturnSomething = ReturnB; 
        ReturnSomething();
    }
}

以上是一个委托绑定一个函数的形式,假如只有这样的写法,那么委托将是一个画蛇添足的语法,但不幸的是,委托生来就是用来多播的,这使得我不得不苦逼地去学它

多播的写法如下

public class Test
{
    private Action returnSomething;

    private void ReturnA() => Debug.Log("A");
    private void ReturnB() => Debug.Log("B");

    public void Function()
    {
        returnSomething += ReturnA;
        returnSomething += ReturnB;
        
        returnSomething();
    }
}
//输出和上面的代码相同

以上就是委托的基本用法,但通常在项目中我们是不会拿委托干这么低能的事的,以下是使用委托调用多个有参数的函数的写法

public class Test
{
    private Action<int> returnSomething;
    //它对应的原始写法是
    //public delegate void ReturnSomething<int>(int num);

    private void ReturnNumber(int num) => Debug.Log($"{num}");
    private void ReturnDoubleNumber(int num) => Debug.Log($"{num * 2}");

    public void Function()
    {
        returnSomething += ReturnNumber;
        returnSomething += ReturnDoubleNumber;
        
        returnSomething(100);
    }
}

显然地,注意力强的人可能已经注意到了(我在说些什么),Action其本身就是一个泛型委托,在上面的例子中,Action就是Action的一种实现,那么相应的,也存在Action,Action的写法,Action也同时支持多个参数,也就是说,Action<T1 , T2 … Tn>是合法的

出于某些考虑,强烈建议在具体工程中不要把给委托附加函数和调用委托放在一起写,比如上面的例子中,假如我调用两次Function(),那么第二次调用Fuction时就将调用两次ReturnNumber()和两次ReturnDoubleNumber()(因为这两个函数都被两次加入委托中)

在Unity中,给委托附加函数(或者说在委托中注册函数)通常会在Awake()里面写好,且在OnDestroy()中退订这些函数(退订用-=)

提一嘴,使用delegate声明和使用Action声明在返回值上有本质的区别:前者可以返回值,而后者不可(毕竟我们也能看到,使用Action声明的时候没有写返回值类型的地方)

事件总线

现在假设需要做这么一个需求,当某个函数A触发时,场景中的某些gameObject BCD将会相应地触发某些函数EFG,这些伴随触发的函数需要内含不同的代码块

传统的写法可能是,在函数A触发时直接调用这些函数,如下

public void A()
{
    B.E();
    C.F();
    D.G();
}

能让函数A这样做的前提条件是,包含函数A的gameObject中需要存储对BCD的引用,或者说向场景中存储这些对象的单例获取它们(当然也有其他办法),且这些函数也必须是public

这样做还有扩展性上的问题,倘若场景中需要有更多的gameObject触发伴随的函数,那么每次加入伴随函数时,就要在包含函数A的gameObject中注册新的对象并调用相应函数,这显然是不优雅的

这就是事件总线发挥作用的时候了,这个需求中,函数A其实没有必要知道它要调用什么函数,这些函数也没有必要像露出癖一样把它们的函数public出去

我们将让函数A向场景中的所有物体发送广播,再仅让BCD物体接收这些广播(假如需要增加更多gameObject,就让它们再订阅这个广播,这样可以保证函数A不需要增加新的代码块,也不需要让包含函数A的gameObject存储对新gameObject的引用),这就是事件总线,事件总线就是这么用的

(提一嘴,我好像在学习UE的通讯时也学到过这些东西…)

第一个教程中给出了两种事件总线架构,这里的第一种就是第一个教程中的第一种,第二种是第二个教程中讲的同时使用泛型和委托的写法

enum-based

首先是事件总线脚本,它不需要挂载在任何gameObject上且只有一个,所以我们可以让它转为静态static且不需要让它继承MonoBehaviour

EventBus结构

namespace EventBus
{
    public static class EventBus
    {
        
        public static void Raise(EventType eventType)
        {
            //发出广播
            //其中,eventType是要发出的广播
        }
        
        public static void Subscribe(EventType eventType , Action action)
        {
            //订阅广播
            //其中,eventType是要订阅的广播
            //action是接收消息时触发的函数
        }
        public static void Unsubscribe(EventType eventType , Action action)
        {
            //取消订阅广播
            //参数含义同上
        }
    }
    
    public enum EventType {CustomEvent}
    //这里将包含所有可以发送的广播,声明为一个枚举,当要发送广播时,就Raise这其中的枚举值
}
Raise

接下来一一填充以上的函数,对于Raise函数,我们需要根据订阅的广播消息调用对应的函数,那么就有

private static Dictionary<EventType , Action> assignedActions = new();
//这个字典将存储广播和其对应的函数

public static void Raise(EventType eventType)
{
    if (assignedActions.TryGetValue(eventType , out Action existingAction))
    //尝试查找字典中是否含有订阅了该广播的函数委托
    {
        existingAction?.Invoke();
        //如果有,执行委托中的所有函数
    }
}

这其中一点简单的细节,在此一并给出解释(其实是因为我有点忘了)

if (assignedActions.TryGetValue(eventType , out Action existingAction))

该段代码的作用是尝试在assignedActions中寻找是否有eventType类型的键,如果有,则返回Action类型的existingAction

existingAction?.Invoke();

这段代码的作用是调用existingAction委托中所有的函数,其中?用于检查是否不为null,Invoke()是调用该委托下的所有函数,当然,前面讲委托的时候直接action()也是对的,这是C#中的语法糖

订阅

然后就是简单的订阅和取消订阅了

public static void Subscribe(EventType eventType , Action action)
{
    if (assignedActions.ContainKey(eventType))
    {
        assignedActions[eventType] += action;
    }
    else
    {
        assignedActions[eventType] = action;
    }
}

订阅部分也完全可以不加条件判断直接+=,但为了得到某些心理安慰,还是建议把代码写得更健壮些

public static void Unsubscribe(EventType eventType , Action action)
{
    if (assignedActions.ContainKey(eventType))
    {
        assignedActions[eventType] -= action;
    }
}

发送广播

首先需要保证using事件总线的命名空间,干脆让发送广播的脚本与事件总线位于同一命名空间(后者的方案通常不会被采用,但教程里是这么写的),即

using EventBus;

或者

namespace EventBus
{
    //Code Block
}

要发送广播,只需要一行

EventBus.Raise(EventType.CustomEvent);

订阅广播

同上发送广播,也需要保证命名空间一致

要订阅广播,也只需要一行

private void Fuction()
{
    EventBus.Subscribe(EventType.CustomEvent,AdjointFunction);
}

private void AdjointFunction()
{
    //Code Block
}

它的意思是,当接收到值为CustomEvent的广播时,就触发AdjointFunction()伴随函数

better

EventBus结构

这里一次性给出EventBus的完整代码,可能会让人费解,但无妨

namespace EventBus
{
    public static class Bus<T> where T : IEvent
    {
        public delegate void Event(T evt);
        
        public static event Event OnEvent;
        
        public static void Raise(T evt) => OnEvent?.Invoke(evt);
    }
}

当然,这其中也可以加入上面enum-based写法中的Subscribe和Unsubscribe,但这不是重点

where T : IEvent

这段代码起泛型约束的作用,简单说,就是需要保证传入的泛型是实现了IEvent接口的类

这里的IEvent接口是这个架构中新加入的东西,不过它在此其实起的是标识的作用,当某个gameObject需要参与事件总线时,就实现IEvent接口,IEvent中不需要有任何内容,它长这样:

public interface IEvent 
{
    //No Code Block
}

接下来是声明委托

public delegate void Event(T evt);
//声明传入参数为一个泛型参数,返回值为空的委托类型
        
public static event Event OnEvent;
//根据上面定义的Event委托,声明一个静态的成员OnEvent

这两行可以使用以下一行完全代替

public static event Action<T> OnEvent;
//声明传入一个泛型参数且无返回值的委托OnEvent

接下来是触发委托的函数

public static void Raise(T evt) => OnEvent?.Invoke(evt);

这就是老熟人的,在前面的委托里详细讲过,这里就不讲了

Events结构

在这种写法中,我们将自定义一个Events结构用于定义消息类型(对应enum-based中的枚举),即

using EventBus
//同上,注意引用

namespace Events
{
    public class CustomEvent : IEvent
    {
        //Code Block
    }
}

这么写或许有些抽象,毕竟我们只能看到架构,那么接下来就举个简单的例子

举例

设想一下这样的需求,编辑器中有一类Unit,场景中的UnitA触发函数Function()时将自己作为参数广播出去,场景中的UnitB也进行与A相同的操作,场景中的UnitC则会订阅广播,在收到上述消息时,将获得的参数记录在某个列表中

EventBus和IEvent仍可使用上方模板,在此复制下来

namespace EventBus
{
    public static class Bus<T> where T : IEvent
    {
        public delegate void Event(T evt);
        
        public static event Event OnEvent;
        
        public static void Raise(T evt) => OnEvent?.Invoke(evt);
    }
}
public interface IEvent 
{
    //No Code Block
}

那么就有事件

using EventBus;

namespace Events
{
    public class SendSelf : IEvent
    {
        public Unit unit { get; private set; }
        //定义一个只读的Unit,保证安全,当然要是不想保证安全可以把getset都去掉
        
        public SendSelf(Unit _unit)
        {
            unit = _unit;
        }
        //这是该事件的构造函数,配合上方的getset是用于保证安全的,也是可以去掉的
    }
}

依上方注释所说,该事件的最简化形式为

using EventBus;

namespace Events
{
  public class SendSelf : IEvent
  {
    public Unit unit;
  }
}

对于UnitA和UnitB,它们都将创建一个SendSelf事件实例,随后将自身this作为参数发送广播,即

using EventBus;
using Events;

public class UnitAB : Unit
{
    private void Function()
    {
        var evt = new SendSelf(this);
        Bus<SendSelf>.Raise(evt);
        //当然,也可以直接写成一行
        //Bus<SendSelf>.Raise(new SendSelf(this));
    }
}

对于UnitC,它需要声明一个存储收到的参数的列表,在Awake()中订阅广播,在OnDestroy()中退订广播,并定义一个操作收到的参数的函数

using EventBus;
using Events;

public class UnitC : Unit
{
    private List<Unit> units = new List<Unit>();
    
    private void Function(SendSelf evt)
    {
        units.Add(evt.unit);
    }
    
    private void Awake()
    {
        Bus<SendSelf>.OnEvent += Function;
    }
    
    private void OnDestroy()
    {
        Bus<SendSelf>.OnEvent -= Function;
    }
    
}

总结

依上方例子,很明显地,SendSelf完全可以替换成任意其他实现了IEvent的事件类,这依托于强大的泛型,而Bus.OnEvent也可以同时触发更多的函数,这依赖于伟大的委托(我在说些什么)

当场景中有更多Unit需要根据SendSelf触发反应时,也无需获取UnitAB的引用,只需要using EventBus和Events,绑定相应的函数即可,这就是强大又伟大的事件总线!

(已经不知所云了,手动狗头)


以上

最后更新于