[Unity] C#使用委托和事件实现Unity消息中心(观察者模式)

前言:

        最近在学习ue的gameplay框架设计和设计模式,再回过头看一下自己过去一年写的unity项目框架(屎山)代码,感慨良多。体会到做游戏写脚本,语言语法只是表层功夫,学会它可以让游戏跑起来。走多远的关键,还是在数据结构/算法/设计模式的内功。

        过去一年,自己也看了很多unity教程,国内unity开发社区还是挺活跃的,新手阶段能在网上找到各种各样的教程。但学会教程≠会做项目,实践才是检验真理的第一出路。我记得刚学unity时去自己参加游戏比赛,是一个简单的横板解密游戏,策划案中会用到物品、对话系统、角色控制。当时自己先去看了b站MStudioM的视频,学习了各个怎么实现背包系统,对话系统,2D中的角色移动啊,看完后感觉:嗯我会了,就是新建UI组件,调用以下API嘛。等做起来的时候傻眼了。游戏剧情文案策划直接发了几千字过来,还需要根据demo情况频繁更新添加。一些背包物品可能今天只有使用功能,明天就要实现拖拽,双击功能。

        在设计过程中,会遇到很多需要组件(脚本)互相通信情况:比如对话开始时暂停玩家移动,玩家移动需要播放脚步声。这里面对的问题是:我在编写一个类(组件)的方法逻辑时,如何去调用另一个类使用某种方法呢?

        这些问题背后的本质是面对对象编程中的经典问题:如何寻找对象的依赖?当时年轻的自己(好吧其实现在也是菜鸡)直接在对应的脚本中调用单例引用。比如在DialogueManager中调用PlayerControl.Instance.StopMove。PlayerControl中调用AudioManager.Instance.Play(脚本声)。单例模式确实是一个非常方便的东西,只要直接类名+.instance就能直接找到类的实例,调用自己想要的方法。这样写确实可以实现功能,游戏也做完了然后提交了比赛。但等自己写完回头看框架,各个类的调用关系几乎变成了蜘蛛网。写好的单个功能根本无法沿用到下一个项目中,因为只需要删除其中一个类,所有系统都会缺少引用报错。

        回过头来看这种情况时,问题在于项目中各个系统发生了耦合,单例模式的直接方法调用导致两个类之间形成了强依赖关系。其实写成这样背后的原因是,很多教程更聚焦于如何实现一个功能,但很少去这教些系统应该如何组织起来,怎么放在合适的地方方便维护。当实现的系统一多便会发生嵌套。写完一个项目,发现每个脚本都到处引用,很难从其中单独分离出某个功能。良好的框架每个组件应该相对比较独立的。对于程序中间的某个模具哎,可以简单的复制到下一个项目中使用,而不用去删除一大堆之前项目独有的引用,才是优秀的设计。

正文

        今天介绍的是C#中的观察者模式,消息中心框架的实现。这个框架非常经典。比如今天要实现的功能是:玩家死亡时触发一系列事件。可能很多组件都在监听玩家死亡事件的发生,比如显示死亡UI,播放死亡音乐啥的。直接在Player.Die()函数中直接调用对应组件自然是大咩的。原因前面已经举例过了。这时利用观察者模式就可以做到解耦,我们需要一个“消息中心”作为中介,负责转发事件,结构如下图。

        可以看到,图中主要有两个依赖关系,一个是行为的注册关系,一个是事件的通知和广播关系。使用一个消息中心作为媒介。这样图上缺少了任意一方,程序都能正在运营下去,也很方便拓展。

        如何实现消息中心的功能呢?我们可以认为指出调用什么事件可以是一个Key(String),而注册行为可以是运行一个函数(委托),可能有很多个行为需要执行,很自然就想到了UnityEvent。实现这种键值对的对应关系,那么很清楚,字典就出来了。

public class EventManager
{
private static Dictionary<string, UnityEvent> eventDictionary = new Dictionary<string, UnityEvent>();

//这里的callBacks主要是解决匿名方法中会发生闭包的问题,用一个id标注该委托方便注销
private static Dictionary<string, UnityAction> callBacks = new Dictionary<string, UnityAction>();
}

        何实现注册事件的功能,只需要维护字典就行了,将对应的key和event的关系存储到字典中。

public static void StartListening(string eventName, UnityAction callBack, string callBackID = "")
{
    if (eventDictionary.TryGetValue(eventName, out UnityEvent thisEvent))
    {
        thisEvent.AddListener(callBack);
    }
    else
    {
        thisEvent = new UnityEvent();
        thisEvent.AddListener(callBack);
        eventDictionary.Add(eventName, thisEvent);
    }

    if (callBackID != "") callBacks.Add(eventName + "_" + callBackID, callBack);
}

        同样的道理,我们在实现注销事件的功能,这里有两个函数原型:直接使用方法名委托注销或使用当时注册事件时标记的id注销。

public static void StopListening(string eventName, string callBackID)
{
    if (eventDictionary.TryGetValue(eventName, out UnityEvent thisEvent))
    {
        if (callBackID != "")
        {
            if (callBacks.ContainsKey(eventName + "_" + callBackID))
            {
                thisEvent.RemoveListener(callBacks[eventName + "_" + callBackID]);
            }
        }
    }
}
public static void StopListening(string eventName, UnityAction callBack)
{
    if (eventDictionary.TryGetValue(eventName, out UnityEvent thisEvent))
    {
        thisEvent.RemoveListener(callBack);
    }
}

        这里不只是用callBack(UnityAction)作为事件标识,也同样使用了callBackID(string),主要原因是为了解决使用lambda表达式出现的标识唯一问题(即当使用Lambda注册事件时,需要表明callBackID,否者就再也不能单独找到这个事件去注销它,因为Lambda的每次出现编译器的闭包都是唯一的)

        接着是发送事件功能,在字典中直接找到根据key找到UnityEvent.Invoke就好了。

public static void EmitEvent(string eventName)
{
    if (isPaused(eventName)) return;

    if (eventDictionary.TryGetValue(eventName, out UnityEvent thisEvent))
    {
        thisEvent.Invoke();
    }
}

        一般来说,我们会使用一个枚举约束以下所有事件的类型。

//根据每个项目的实际需求需要单独添加
public enum EventEnum {
    None,
    		
	PlayerDie,	
}

        现在回到我们前言中的问题:如何在玩家死亡时事件发生时,合理调用一系列事件。以下格式可作为参考,注意注册的事件一定要记得注销,不然方法会一直存在消息中心,当GameObject被Destroy时会报错:

public class PlayerControl : MonoBehaviour
{
    void PlayerDie()
    {
        //发送事件消息
        EventManager.Emit(EventEnum.PlayerDie.ToString());
        //...
    }
}

public class UIManager : MonoBehaviour
{
    //生成死亡UI
    void CreateDieUI()
    {
        //...
    }
    //在Start中监听
    void Start()
    {
        EventManager.StartListening(EventEnum.PlayerDie.ToString(),CreateDieUI);
    }
    //注意注册的事件一定要记得注销
    void Destroy()
    {
        EventManager.StopListening(EventEnum.PlayerDie.ToString(),CreateDieUI);
    }
    
}

public class AudioManager : MonoBehaviour
{
    //播放音乐
    void Play(string audioName)
    {
        //...
    }
    
    //如果使用了lambda表达式,需要用ID标注以下,不然注销不了
    //推荐可以使用GetHashCode()作为ID
    void Start()
    {
        EventManager.StartListening(EventEnum.PlayerDie.ToString(),
        delegate(){ Play("脚步声"); }  ,GetHashCode());
    }
    //注意注册的事件一定要记得注销
    void Destroy()
    {
        EventManager.StopListening(EventEnum.PlayerDie.ToString(),GetHashCode());
    }
    
}

        观察者模式当然也是有缺点的。从运行机制上来看,观察者模式将需要触发的事件用一条委托链表示,当触发时一条一条执行下去。当某一个节点出现问题报错时,剩下的所有节点都无法执行;从软件工程的角度,观察者虽然解耦了事件调用者和接收者的关系,但使用字符串做事件索引,也带来了触发关系不明确的缺点,在后期程序修改时一定程度上提高了维护成本。

        这个事件脚本完整版的源码比较长,已放在GitHub了。这个事件脚本也有其他功能,比如定义事件的发送者,暂时禁用事件啥的,读者可以自己阅读源码和注释探索。

        https://github.com/sugarzo/UnityEventManager

        对于框架设计的进一步运用,可参考这篇文档

[Unity] 状态机事件流程框架 (一)(C#观察者模式 事件系统,Trigger与Action)_Sugarzo的博客-CSDN博客_unity 事件框架

        本文内容部分启发于了以下文章,作者写的很好,在此也分享下:

Unity中Find问题的本质 - 知乎

猜你喜欢

转载自blog.csdn.net/m0_51776409/article/details/126150120