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://www.cnblogs.com/timeObjserver/p/11024148.html
2.运行开始时会有析构函数的调用。
经此一役,以后写观察者模式时,应当心里有底了(毕竟虽然这些错很简单,但却排查了老久)。胆大心细,多加注意!
知识点总结
本文来自博客园,作者:柴木鱼,转载请注明原文链接:https://www.cnblogs.com/chopwoodfish/p/16850575.html

浙公网安备 33010602011771号