Ludum Dare 49圆满结束了,我们的《绝地天通(Babel Blocks)》最终取得了1413名,放在三千多个参赛作品中也还是不错的!欢迎大家试玩我们的游戏:https://guinytime.itch.io/babel-blocks
在此我想对我在本次开发中负责的模块做一些复盘。UI部分已经是老生常谈了,我想聊聊我在开发中用到的观察者模式。
什么是观察者模式?观察者模式是一种行为型设计模式,定义了一种一对多的依赖关系,即当一个对象(称为发布者或被观察者)的状态发生变化时,所有依赖于它的对象(称为订阅者或观察者)都会自动收到通知并更新。
在游戏开发中,这种模式通常用于处理事件广播、状态更新等场景。例如,当玩家生命值减少时,游戏中的血条、UI、音效等观察者都会随之更新。
观察者模式的关键结构由以下三部分组成:
- 发布者(Subject):
- 管理观察者列表,提供添加、移除订阅者的方法。
- 当状态发生变化时,通知所有订阅者。
- 订阅者(Observer):
- 定义一个接口,所有观察者必须实现该接口中的更新方法。
- 当主题通知时,观察者根据需要更新自己的状态。
- 通知机制:
从C#的event关键字说起
前文中的 UML 图展现了观察者模式最基本的结构。在实际应用中,我们通常通过多播委托结合哈希表来实现观察者模式。委托(delegate)类似于C++中的函数指针,是类型化了的函数指针(一种可以存放特定参数/返回值类型方法的容器);多播委托(multicast delegate)则代表一个委托存放了多个函数。通过委托可以实现事件和回调的功能。
题外话:多播委托
多播委托的底层是一个链表,我们称之为“委托链”,因此多播委托也可以被称为链式委托。当两个及以上的委托被链接到一个委托链时,调用头部的委托将导致该链上的所有委托方法都被执行。通过 System.MulticastDelegate 中的 GetInvocationList() 方法,可以以数组的形式获得整个链式委托中的所有委托。
如果在某个委托方法中修改了委托链(例如+=了新的方法),新委托链是不会被执行的,因为本次执行的委托引用指向的是旧的链。委托是一种引用类型(类似于字符串一般具有不变的特性),在增加或移除方法时实际上创建了一个新的委托实例并把它赋给当前的委托变量。在多播委托执行时,C# 会先将当前委托链拷贝出来,然后按这个拷贝的顺序依次调用。这保证了即使在回调中修改了委托链,拷贝的链条也不会被改变。
需要注意的是,对于有返回值的多播委托,如果没有手动调用委托链上的每个方法,只能得到委托链上最后被调用的方法的返回值。
C# 将观察者模式集成到了 event上 。event 是对于委托(delegate)的一种更安全的封装方式(只能作为成员存在于类/接口或结构体中),程序员无法在类外部赋值或调用事件,这使得委托的使用更加安全。C# event 是一种成员,用于将特定的事件通知发送给订阅者。事件通常用于实现观察者模式,它允许一个对象将状态的变化通知其他对象,而不需要知道这些对象的细节。
事件基本上说是一个用户操作,如按键、点击、鼠标移动等等,或者是一些提示信息,如系统生成的通知。应用程序需要在事件发生时响应事件。
使用event实现一些基本功能
事件通过以下方式进行声明:
1 2 3 4
| public delegate void onPlayerHurt();
public event onPlayerHurt playerHurtEvent;
|
事件必须位于类/接口/结构体中,例如声明一个用于处理事件业务逻辑的发布者类PlayerHurtEvent:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19
| public class PlayerHurtEvent{ public delegate void onPlayerHurt(); public event onPlayerHurt playerHurtEvent;
protected virtual void OnPlayerHurtEvent() { playerHurtEvent?.Invoke(); }
public void StartProcess() { Console.WriteLine("触发事件"); OnPlayerHurtEvent(); } }
|
观察者(订阅者)类:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
| public class EventSubscriber { public void Subscribe(PlayerHurtEvent process) { process.playerHurtEvent += Process_ProcessCompleted; } private void Process_ProcessCompleted() { Console.WriteLine("订阅者收到事件触发,处理相关逻辑"); } }
|
在具体的应用场景中,可能是:
1 2 3 4 5 6 7 8 9 10 11 12 13 14
| class Program { static void Main(string[] args) { PlayerHurtEvent process = new PlayerHurtEvent(); EventSubscriber subscriber = new EventSubscriber();
subscriber.Subscribe(process);
process.StartProcess(); } }
|
优缺点
优点
- 解耦
- 观察者与主题是松耦合的,便于扩展。
- 增加新的观察者无需修改发布者。
- 扩展性好
- 灵活性高
缺点
- 复杂性增加
- 对于大量观察者的管理和通知机制需要谨慎,可能增加复杂度。
- 性能问题
- 当观察者数量较多时,通知过程可能影响性能,因此一个可行的方向是将订阅者分成更小的组,减少需要通知的订阅者的数量。
- 可能产生循环依赖
- 如果观察者和主题之间的关系处理不当,可能出现循环依赖,导致难以调试。
在实际项目中的应用
这次的Ludum Dare 49我们开发的游戏《Bable Block》中我使用了hll学长的代码,其中 EventCenter 的部分被我用来实现了底部广播的功能。底部广播由一个定时器触发,每隔10秒钟更新广播的内容,实现新闻联播中底部新闻的效果(我认为这样可以使游戏更幽默一些,因为LD49的评分维度有幽默一项)。
EventCenter 脚本实现了 UML 图中 Subject 部分的功能,支持有参/无参类型事件的监听,以下是它的源码:
EventCenter
EventCenter 通过字典来存储事件和事件对应的需要触发的订阅,基本功能包括:
- 增加事件监听
- 移除事件监听
- 触发事件
- 清空事件和订阅
无参事件
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82
| using System.Collections.Generic; using UnityEngine.Events;
public class EventInfo { public UnityAction actions;
public EventInfo(UnityAction action) { actions += action; } }
public class EventCenter : BaseManager<EventCenter> { private Dictionary<string, EventInfo> eventDic = new Dictionary<string, EventInfo>();
public void AddEventListener(string name, UnityAction action) { if (eventDic.ContainsKey(name)) { (eventDic[name]).actions += action; } else { eventDic.Add(name, new EventInfo(action)); } }
public void RemoveEventListener(string name, UnityAction action) { if (eventDic.ContainsKey(name)) (eventDic[name]).actions -= action; }
public void EventTrigger(string name) { if (eventDic.ContainsKey(name)) { if ((eventDic[name]).actions != null) (eventDic[name]).actions.Invoke(); } }
public void Clear() { eventDic.Clear(); } }
|
引入有参事件
如果我们希望事件携带参数该怎么办呢?可以通过一个空接口结合里氏替换原则实现多态存入字典中,改写如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99
| using System.Collections.Generic; using UnityEngine.Events;
public interface IEventInfo {
}
public class EventInfo : IEventInfo { public UnityAction actions;
public EventInfo(UnityAction action) { actions += action; } }
public class EventInfo<T> : IEventInfo { public UnityAction<T> actions;
public EventInfo( UnityAction<T> action) { actions += action; } }
public class EventCenter : BaseManager<EventCenter> { private Dictionary<string, IEventInfo> eventDic = new Dictionary<string, IEventInfo>();
public void AddEventListener<T>(string name, UnityAction<T> action) { if( eventDic.ContainsKey(name) ) { (eventDic[name] as EventInfo<T>).actions += action; } else { eventDic.Add(name, new EventInfo<T>( action )); } }
public void RemoveEventListener<T>(string name, UnityAction<T> action) { if (eventDic.ContainsKey(name)) (eventDic[name] as EventInfo<T>).actions -= action; }
public void EventTrigger<T>(string name, T info) { if (eventDic.ContainsKey(name)) { if((eventDic[name] as EventInfo<T>).actions != null) (eventDic[name] as EventInfo<T>).actions.Invoke(info); } }
public void Clear() { eventDic.Clear(); } }
|
最后,需要强调的是在使用观察者模式时需要留意内存泄漏风险。我们需要确保所有添加的委托都能正确移除,特别是在对象销毁时,避免事件中心持有不再使用的对象造成内存泄漏。当对象仍被实践中心持有时它将不会被GC。
参考资料
https://zh.wikipedia.org/wiki/观察者模式
https://www.dongchuanmin.com/csharp/2359.html
https://www.dongchuanmin.com/csharp/2360.html
https://www.dongchuanmin.com/csharp/2361.html
https://www.dongchuanmin.com/csharp/2362.html