Ludum Dare 49圆满结束了,我们的《绝地天通(Babel Blocks)》最终取得了1413名,放在三千多个参赛作品中也还是不错的!欢迎大家试玩我们的游戏:https://guinytime.itch.io/babel-blocks

在此我想对我在本次开发中负责的模块做一些复盘。UI部分已经是老生常谈了,我想聊聊我在开发中用到的观察者模式

什么是观察者模式?观察者模式是一种行为型设计模式,定义了一种一对多的依赖关系,即当一个对象(称为发布者被观察者)的状态发生变化时,所有依赖于它的对象(称为订阅者观察者)都会自动收到通知并更新。

在游戏开发中,这种模式通常用于处理事件广播、状态更新等场景。例如,当玩家生命值减少时,游戏中的血条、UI、音效等观察者都会随之更新。

观察者模式的关键结构由以下三部分组成:

  1. 发布者(Subject):
    • 管理观察者列表,提供添加、移除订阅者的方法。
    • 当状态发生变化时,通知所有订阅者。
  2. 订阅者(Observer):
    • 定义一个接口,所有观察者必须实现该接口中的更新方法。
    • 当主题通知时,观察者根据需要更新自己的状态。
  3. 通知机制:
    • 发布者调用观察者的更新方法,传递状态变化。

从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();
// 访问修饰符 event 委托类型 事件名;
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;

// 触发事件的方法,因为无法在外部直接触发事件(委托可以),因此需要加一层封装
// 这是一个受保护的方法,使用 ?.Invoke 语法来确保只有在有订阅者时才调用事件。
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;
}
}

/// <summary>
/// 事件中心 单例模式对象
/// 1.Dictionary
/// 2.委托
/// 3.观察者设计模式
/// 4.泛型
/// </summary>
public class EventCenter : BaseManager<EventCenter>
{
//key —— 事件的名字(比如:怪物死亡,玩家死亡,通关 等等)
//value —— 对应的是 监听这个事件 对应的委托函数们
private Dictionary<string, EventInfo> eventDic = new Dictionary<string, EventInfo>();

/// <summary>
/// 监听不需要参数传递的事件
/// </summary>
/// <param name="name">事件的名字</param>
/// <param name="action">准备用来处理事件 的委托函数</param>
public void AddEventListener(string name, UnityAction action)
{
//有没有对应的事件监听
//有的情况
if (eventDic.ContainsKey(name))
{
(eventDic[name]).actions += action;
}
//没有的情况
else
{
eventDic.Add(name, new EventInfo(action));
}
}


/// <summary>
/// 移除不需要参数的事件
/// </summary>
/// <param name="name">事件的名字</param>
/// <param name="action">对应之前添加的委托函数</param>
public void RemoveEventListener(string name, UnityAction action)
{
if (eventDic.ContainsKey(name))
(eventDic[name]).actions -= action;
}

/// <summary>
/// 事件触发(不需要参数的)
/// </summary>
/// <param name="name">哪一个名字的事件触发了</param>
public void EventTrigger(string name)
{
//有没有对应的事件监听
//有的情况
if (eventDic.ContainsKey(name))
{
if ((eventDic[name]).actions != null)
(eventDic[name]).actions.Invoke();
}
}

/// <summary>
/// 清空事件中心
/// 主要用在 场景切换时
/// </summary>
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;

//用空接口是因为要把他存到字典里,要用里式转换原则转成其他的T类型
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;
}
}

/// <summary>
/// 事件中心 单例模式对象
/// 1.Dictionary
/// 2.委托
/// 3.观察者设计模式
/// 4.泛型
/// </summary>
public class EventCenter : BaseManager<EventCenter>
{
//key —— 事件的名字(比如:怪物死亡,玩家死亡,通关 等等)
//value —— 对应的是 监听这个事件 对应的委托函数们
private Dictionary<string, IEventInfo> eventDic = new Dictionary<string, IEventInfo>();

/// <summary>
/// 添加事件监听
/// </summary>
/// <param name="name">事件的名字</param>
/// <param name="action">准备用来处理事件 的委托函数</param>
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 ));
}
}

/// <summary>
/// 移除对应的事件监听
/// </summary>
/// <param name="name">事件的名字</param>
/// <param name="action">对应之前添加的委托函数</param>
public void RemoveEventListener<T>(string name, UnityAction<T> action) //移除监听
{
if (eventDic.ContainsKey(name))
(eventDic[name] as EventInfo<T>).actions -= action;
}

/// <summary>
/// 事件触发
/// </summary>
/// <param name="name">哪一个名字的事件触发了</param>
public void EventTrigger<T>(string name, T info)
{
//有没有对应的事件监听
//有的情况
if (eventDic.ContainsKey(name))
{
//eventDic[name]();
if((eventDic[name] as EventInfo<T>).actions != null)
(eventDic[name] as EventInfo<T>).actions.Invoke(info);
//eventDic[name].Invoke(info);
}
}

/// <summary>
/// 清空事件中心
/// 主要用在 场景切换时
/// </summary>
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