观察者模式和事件中心的关系
事件中心是观察者模式的中心化类型,观察者模式比事件中心更广义,不使用事件中心,两个类,类B监听类A的事件,也是观察者模式。
观察者模式比调用好在哪?
监听比调用并没有减少耦合数量,只是改变引用方向,从主动者引用被动者变成被动者引用主动者。因为人的直观逻辑是原因不必关心结果,结果必须关心原因,没有因就没有果,反映在程序里没有主动者提供的数据,被动者不知道怎么处理。监听符合了这个逻辑。
这也是为什么监听会有一种"降低耦合"的错觉,尽管引用并没有少。
适用观察者模式的情景特征
- 首先一定是跨类调用。
- 被调用类较难访问到。比如管理器更新玩家血量,要更新HUD显示,HUD是面板,由UI管理器管理,要让UI管理器返回HUD。
- 被调用类生命周期比调用类短。比如一个面板,它在关卡里有时不存在,管理器直接调用它要判定是不是空。
- 一个操作要if判断在不同情景下要引起不同类的响应时,观察者模式可以省去这个if。举例:在玩家可以步行、开汽车、开坦克、开飞机的游戏里,玩家步行时根本不存在汽车、坦克对象,输入模块直接调用还要判定玩家有没有载具?还要每一帧判定?改成观察者之后,玩家开车,步行移动方法解除监听,汽车移动方法添加监听。
适用观察者模式的情景
- 玩家输入的灵活绑定、解绑(人物上车,WASD解绑人物移动,绑定汽车移动);
- 音量控制、多语言设置、红点系统(声源、文本、按钮散落在软件各处,且随时可能出现、销毁,音量、语言管理器很难获得所有控件的引用和出现、销毁时机);
为什么要用事件中心?
我觉得在Unity里最大的原因还是脚本的生命周期不适合两两的观察者模式。
写一个不用事件中心的观察者模式,类B监听类A的事件,A和B都继承MonoBehaviour,马上会发现,如果A先于B创建(需要在脚本执行顺序里设置脚本优先级),那么也会先于B销毁,B试图取消监听时,A已经销毁。
这样还算能用,假如现在A也要监听B呢??(假设A是UI,B是管理器,A收到输入通知B处理,B处理完要通知A刷新显示)任意一方先创建,它都会找不到自己要监听的对象。
那么解决方法就是搞一个生命周期比所有MonoBehaviour都长的对象。
直接调用、监听委托、事件中心,如何选用?
看要调用的函数获取到的难度,或者说调用链长度。
- 要执行的函数就是此函数的参数的成员方法时,直接调用。
- 要执行的函数只在这个类的部分对象要执行,毫不犹豫使用观察者模式。比如背包数据类,玩家和敌人都有背包,只有玩家那个需要触发面板刷新,那背包数据类如果引用面板还要判断自己是不是玩家的背包。用事件中心也会导致所有背包更新都通知ui,所以适合监听委托。
- 被监听者是很容易访问的管理器类,用监听委托。被监听者和监听者都难访问到,用事件中心;
事件中心要满足什么功能
- 支持无参和多种参数的事件,最好能避免装箱拆箱;
- 能防止重复添加同一个监听方法;
- 一个类能发布多种事件,也能监听多种事件;
事件中心实现方案:接口集合式和它的问题
接口列表式:所有观察者继承一个接口,被观察者持有接口的HashSet/List,分发事件时把集合里的接口方法调用一遍。可以知道List有没有重复添加观察者,HashSet更可以直接杜绝重复添加。
然后事件分发者可以通过调用事件中心分发,也可以继承事件分发者接口......然后我们发现事件分发者都要有一个HashSet<IListener>,有了字段就不能是接口了,只能是抽象类,那么分发事件的类就不能继承其他类了......所以还是用事件中心。
然后又发现一个对象可能要监听多个事件,有多个响应函数,继承接口、实现方法只有一个响应函数?!
事件中心实现方案:多播委托式
多播委托式,观察者方法在里面通过链表管理,通过GetInvocationList().Contains(action)防止重复添加。
事件中心实现方案:HashSet<委托>式和它的问题
我们决定使用HashSet<委托>装载监听者。HashSet避免了重复注册,委托保证一个监听者可以用自己的多个函数监听多个事件。
但是还要处理有参数的情景。想到用各种参数类型委托的基类HashSet装,就用HashSet<Delegate>.
cs
using System;
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.Events;
namespace MyFarm
{
public class MyEventCenter2:MySingleton<MyEventCenter2>
{
Dictionary<string, HashSet<Delegate>> eventDic = new();
public void Add(string eventName, UnityAction action)
{
if (!eventDic.ContainsKey(eventName))
{
eventDic[eventName] = new HashSet<Delegate>();
}
eventDic[eventName].Add(action);
}
public void Remove(string eventName, UnityAction action)
{
if (eventDic.ContainsKey(eventName))
{
eventDic[eventName].Remove(action);
}
}
public void Trigger(string eventName)
{
if (eventDic.ContainsKey(eventName)) {
foreach (var del in eventDic[eventName])
{
if (del is UnityAction action)
{
action?.Invoke();
}
}
}
}
public void Add<T>(string eventName, UnityAction<T> action)
{
if (!eventDic.ContainsKey(eventName))
{
eventDic[eventName] = new HashSet<Delegate>();
}
eventDic[eventName].Add(action);
}
public void Remove<T>(string eventName, UnityAction<T> action)
{
if (eventDic.ContainsKey(eventName))
{
eventDic[eventName].Remove(action);
}
}
public void Trigger<T>(string eventName,T t)
{
if (eventDic.ContainsKey(eventName))
{
foreach (var del in eventDic[eventName])
{
if (del is UnityAction<T> action)
{
action?.Invoke(t);
}
}
}
}
}
}
这里支持了无参和一个参数。多个参数用结构体或ValueTuple包装即可。
支持同一个事件名多种不同参数类型,触发的时候is会过滤出和Trigger参数类型相同的委托执行。
事件的键用字符串常量还是枚举
一开始我感觉都一样,但是事件达到几十个上百个后字符串常量可能会定义到之前已有的字符串,如果常量名不一样,但是常量值一样。枚举没有这个问题。但是如果保持常量值和常量名一样,且定义在一个类,也能自动检查出重名。
事件名多了后可能和事件中心单例的首字母一样,造成自动补全不变。
但是枚举有一个致命问题是对于动态配置的,没有事先登记的事件名,只能写数字,事件意义不明,还极易重名。此问题主要出在任务系统。可以考虑任务系统另用一个事件中心,使用字符串。
Unity中如何保证添加监听时发布者一定存在
这就是Awake、Start的用途之一,Awake里保证单例存在,Start里添加监听,和脚本执行顺序无关。
如果是非MonoBehavior成员,则除了构造函数再弄一个Init,在Init里监听别人,在Awake里new构造,Start里Init。总之就是双初始化机制。
但是销毁时没有全部脚本执行一个生命周期函数后执行下一个,导致我们现在都无法保证在OnDisable、OnDestroy里解除监听时发布者还存在。