unity实现观察者模式遇到的问题

前言

观察者模式非常实用,借助c#的委托action,可以方便快速地在unity里构建起基于该模式的事件系统。之前在项目里经常会使用到,但是直到尝试实现,才发现短短一百来行代码,竟然可以没有一个地方按我的设想运行😅所以将自己踩的坑记录如下。

一、第一版代码和期望实现的效果

首先是事件系统本身,因为全局只有一个,也没有必要挂在物体上,所以是static类;action的参数是object,方便自己构造各种类型的参数进行传递。在添加观察者后,会打印当前消息有多少个观察者。

 1  public static class IntEventSystem
 2  {
 3      // key: 消息类型 value: 监听该消息的方法
 4      private static Dictionary<int, Action<object>> allListenerMap = new Dictionary<int, Action<object>>();
 5  
 6    // 添加观察者
 7      public static void AddListener(int key, Action<object>listener)
 8      {
 9          if (listener == null) return;
10          
11          Action<object> existingListeners = null;
12          allListenerMap.TryGetValue(key, out existingListeners);
13          if (existingListeners != null)
14          {
15              existingListeners += listener;
16          }
17          else
18          {
19              allListenerMap.Add(key, listener);
20              existingListeners = allListenerMap[key];
21          }
22  
23          var eventList = existingListeners.GetInvocationList();
24          Debug.Log($"[AddListener]<after> key:{key}, current listener count: {eventList.Length}");
25      }
26  
27      public static void RemoveListener(int key, Action<object>listener)
28      {
29          // 移除观察者...
30      }
31  
32       // 发送消息,触发所有观察该消息的方法
33      public static void SendMessage(int key, object obj)
34      {
35          Action<object> allListener;
36          if (allListenerMap.TryGetValue(key, out allListener))
37          {
38              // 即使某个委托异常,之后的委托继续执行
39              var eventList = allListener.GetInvocationList();
40              foreach (var @event in eventList)
41              {
42                  var eventItem = @event as Action<object>;
43                  try
44                  {
45                      eventItem(obj);
46                  }
47                  catch (Exception e)
48                  {
49                      Debug.LogException(e);
50                  }
51              }
52          }
53      }
54  }    

然后是测试类,该类根据prefab生成3个Listener实体,发送一次消息;然后Destroy掉一个Listener,再发送一次消息。

 1 public class DispatcherTest : MonoBehaviour
 2 {
 3     private List<Listener> listListeners = new List<Listener>();    // 记录存在的Listener
 4     
 5     private void Start()
 6     {
 7         GenerateListeners();
 8         StartCoroutine(SendMessage());
 9     }
10 
11     // 生成3个Listener实体
12     public void GenerateListeners()
13     {
14         for (int i = 0; i < 3; i++)
15         {
16             var newGO = Instantiate(Resources.Load("Listener")) as GameObject;
17             var listener = newGO.GetComponent<Listener>();
18             listListeners.Add(listener);
19         }
20     }
21 
22     // 发送消息
23     IEnumerator SendMessage()
24     {
25         yield return new WaitForSeconds(2f);
26 
27         var msg = new MsgType1();
28         msg.fromID = -1;
29         IntEventSystem.SendMessage((int) EventEnum.MessageType1, msg);
30         
31         Debug.Log("destroy one listener");
32         
33         // 销毁列表中最后一个Listener
34         var toDestroy = listListeners.Last();
35         listListeners.Remove(toDestroy);
36         DestroyImmediate(toDestroy);
37 
38         // 1秒后再发送一次消息
39         yield return new WaitForSeconds(1f);
40         IntEventSystem.SendMessage((int) EventEnum.MessageType1, msg);
41 
42     }
43 }

 最后是观察者Listener,在构造函数中,Listener实例将自身的OnReceiveMessage方法注册到事件系统,并且从static变量ID处获取自身的instanceID(此后ID自增)。收到消息后,Listener会报上自己的ID。

 1 public class Listener : MonoBehaviour
 2 {
 3     public static int ID;
 4     public int instanceID;
 5 
 6     public Listener()
 7     {
 8         instanceID = ID;
 9         Debug.Log($"[create] instanceID: {instanceID} ID: {ID}");
10         ID++;
11         IntEventSystem.AddListener((int)EventEnum.MessageType1, OnReceiveMessage);
12     }
13 
14 
15     public void OnReceiveMessage(object obj)
16     {
17         Debug.Log($"listener ID {instanceID} receive msg type 1");
18     }
19     
20     ~Listener()
21     {
22         Debug.Log($"listener  ID {instanceID} destroy");
23     }
24 }

按照天衣无缝的设想,这份代码应该会创建3个Listener,instanceID分别为0、1、2;代码会依次打印:他们仨注册事件系统的日志、他们仨收到消息的日志,和删除掉一个Listener后,剩下两个收到消息的日志。

然而人生不如意事十之八九,我们得到的日志是这样的:

嗯……这里面问题可太多了……一个个来看下:

1.为啥3次Instantiate,最终产生了4个Listener?

2.为啥从第一次注册成功,打印出current listener count: 1以后,之后的注册似乎都失败了?刚注册完打印日志的时候,count是2,但等到下一次注册时,count还是2

3.为啥最终只有1个Listener收到了消息,调用了方法?

4.为啥删掉1个Listener以后还是有实例收到了消息,调用了方法?

5.为啥最后有析构函数的调用,但是隔了3s?

好吧,我们一个个来分析这些问题。

二、问题分析

1. 3次Instantiate产生4个实例

这个比较好猜:挂在prefab上的Listener组件也被实例化了,也就是那个多出来的0号Listener。想想也有道理,Instantiate是以现有的对象为基础,复制出新的对象,但总得先有一个“现有的对象”啊。所以不要以为prefab存放在文件夹中而不是场景中,就不会被实例化了。

但这只是我观察到的现象,并不太清楚背后的原理。如果把这段脚本挂到一个空物体上,然后把空物体做成prefab,并从场景中删掉,还是能看到它悄咪咪地构造和析构,而且是在运行开始时先析构、再构造。

 1     public class PrefabTest : MonoBehaviour
 2     {
 3         public PrefabTest()
 4         {
 5             Debug.Log("PrefabTest create");
 6         }
 7         
 8         ~PrefabTest()
 9         {
10             Debug.Log("PrefabTest destroy");
11         }
12     }

那如果不想要这个多出来的Listener注册进事件系统呢?有两种方法:1.别把注册事件系统的语句放在构造函数里,放在start()里;2.别让Listener继承MonoBehaviour,这样就不需要prefab和Instantiate,而是直接用new实例化,但这样它就不是可挂载的脚本了,后面也没法使用Destroy了。

插入一个知识点:不要用New来创建继承于MonoBehaviour的对象,这里也提到了unity对构造和析构函数的调用比较飘忽,所以尽量不要在构造和析构函数里面做事情。

这次我们选择第一种方法,把注册语句放进start()里:

1     private void Start()
2     {
3         IntEventSystem.AddListener((int)EventEnum.MessageType1, OnReceiveMessage);
4     }

可以看到,现在只有3个Listener注册事件系统的记录了(虽然接收消息的实例个数不对、ID不对的问题仍然没有解决)。

2. 似乎只有第一个Listener成功注册了事件系统,后面的都没有成功

其产生原因也很简单:通过out关键字取到的值,在其上进行改动,是不会对原值产生影响的……

 1         Action<object> existingListeners = null;
 2         allListenerMap.TryGetValue(key, out existingListeners);    // 使用了out关键字来获取allListenerMap[key]的值
 3         if (existingListeners != null)
 4         {
 5             existingListeners += listener;    // 修改existingListeners是不会动到allListenerMap的,无效修改
 6         }
 7         else
 8         {
 9             allListenerMap.Add(key, listener);    // 只有这里真正动到了allListenerMap
10             existingListeners = allListenerMap[key];
11         }

 插入知识点:c#中in,out,ref的作用与区别 和一个扩充知识点:【C#】Dictionary的TryGetValue和Contains方法使用,里面提到了TryGetValue的效率更高。

直接对allListenerMap进行改动就好了。现在current listener count的数目是对了,3个listener也收到消息了,但是,呃,它们怎么都说自己的ID是0啊,不应该是1、2、3吗?而且destroy掉1个listener后,还是有3个方法被调用了。

3. 构造函数中对ID的修改失效

这个也好猜,还是Instantiate的锅,看一下手册里它的介绍:

克隆 GameObject 或 Component 时,也将克隆所有子对象和组件,它们的属性设置与原始对象相同

好么,我们在构造函数里辛辛苦苦赋值的instanceID,原来在构造函数之后,让Instantiate给悄悄改回和原始对象一样了(再次映证了最好不要在构造函数里做任何事)。解决方法就是把赋值instanceID的语句也挪到start()里面去。

现在ID也正常了。但为啥destroy掉1个listener后,发送消息还是调用了3个方法,甚至还访问得到instanceID?

4.实例Destroy之后,还可以通过action调用其方法,并访问其成员变量

这个是真不知道为啥了。Destroy之后,通过调试器查看action,能看到该Listener实例确实有大部分东西已经被清掉了,但唯独instanceID还在,方法还能调用。

请教了一下大哥,似乎因为unity的部分东西储存在c#层,部分储存在c++层,Destroy只是释放了部分资源,而剩下的一部分需要等待c#的垃圾回收机制进行释放,所以在一段时间内,虽然实例的指针已经被置为null了,但还能访问和调用。

看来比较保险的方法,是在Destroy一个实例时,手动地将其在列表中移除。

插入知识点:Destroy和DestroyImmediate的区别

三、疑点

除了以上问题,还观察到部分很可疑,但不清楚原理的地方如下:

1.有时static变量中的list不会随着运行结束/重新开始而清空,所以每次运行都向list中添加元素的话,会越来越大。不清楚复现条件,几乎一样的代码,我在家里电脑是这种情况,在公司就怎么也出现不了。保险的做法是每次运行都手动初始化static变量。

似乎与此问题有关的帖子:

https://forum.unity.com/threads/static-fields-in-a-class-dont-seem-to-be-destroyed-after-play-mode.1347152/

https://www.cnblogs.com/timeObjserver/p/11024148.html

2.运行开始时会有析构函数的调用。

经此一役,以后写观察者模式时,应当心里有底了(毕竟虽然这些错很简单,但却排查了老久)。胆大心细,多加注意!

知识点总结

不要用New来创建继承于MonoBehaviour的对象

c#中in,out,ref的作用与区别

【C#】Dictionary的TryGetValue和Contains方法使用

Destroy和DestroyImmediate的区别

posted @ 2022-11-02 17:26  柴木鱼  阅读(142)  评论(0)    收藏  举报