在引用的序列化脚本对象中订阅 c# 事件时,如何避免 Unity 中的内存泄漏?



在使用Unity实现游戏的过程中,我遇到了以下设置:

  • 我有一个具有 c#event委托的ScriptableObject(作为资产(。
  • 我有一个MonoBehaviour,它对具有委托ScriptableObject的序列化引用。

我想"订阅"该ScriptableObject事件的MonoBehaviour,正确处理事件以避免内存泄漏。最初,我认为在OnEnable回调上订阅事件并在OnDisable上取消订阅就足够了。但是,当开发人员使用 Unity 检查器在播放过程中将序列化引用的值交换到ScriptableObject时,会发生内存泄漏。

鉴于我希望游戏的开发人员能够在游戏过程中在检查器中交换值,是否有一种规范的方法可以在对ScriptableObject的序列化引用中安全地订阅和取消订阅 c# 事件?


为了说明这一点,我为该场景编写了一个简单的代码:

主题SO.cs(事件ScriptableObject(

using UnityEngine;
using System;
[CreateAssetMenu]
public class SubjectSO : ScriptableObject
{
public event Action<string> OnTrigger;
public void Invoke()
{
this.OnTrigger?.Invoke(this.name);
}
}

ObserverMB .cs(想要订阅ScriptableObject事件MonoBehaviour(

using UnityEngine;
public class ObserverMB : MonoBehaviour
{
public SubjectSO subjectSO;
public void OnEnable()
{
if(this.subjectSO != null)
{
this.subjectSO.OnTrigger += this.OnTriggerCallback;
}
}
public void OnDisable()
{
if(this.subjectSO != null)
{
this.subjectSO.OnTrigger -= this.OnTriggerCallback;
}
}
public void OnTriggerCallback(string value)
{
Debug.Log("Callback Received! Value = " + value);
}
}

InvokesSubjectSOEveryUpdate .cs(AuxiliaryMonoBehaviour, for testing(

using UnityEngine;
public class InvokesSubjectSOEveryUpdate : MonoBehaviour
{
public SubjectSO subjectSO;
public void Update()
{
this.subjectSO?.Invoke();
}
}

为了进行测试,我创建了两个类型SubjectSO的资产,名为:

  • 主题A
  • 主题B

然后,我在场景中创建了一个GameObject,并附加了以下组件:

  • ObserverMB,引用主题A
  • InvokesSubjectSOEveryUpdate,引用主题A
  • InvokesSubjectSOEveryUpdate,引用主题B

点击播放时,消息Callback Received! Value = SubjectA会在每次更新中打印在控制台中,这是预期的。

然后,当我使用检查器将ObserverMB中的引用从主题 A更改为主题 B时,当游戏仍在进行时,消息Callback Received! Value = SubjectA仍然会继续打印。

如果我在检查器中禁用和启用ObserverMB,则每次更新都会开始打印Callback Received! Value = SubjectACallback Received! Value = SubjectB消息。

初始回调订阅仍然有效,但作为订阅者,ObserverMB丢失了对该事件的引用。

我怎样才能避免这种情况?

我真的相信这似乎是使用 c#event委托和ScriptableObjects的常见使用场景,对我来说,OnEnableOnDisable没有正确处理开发人员调整检查器的序列化情况似乎很奇怪。

在这种情况下,您必须检查subjectSO是否正在更改并取消订阅。

通过检查器切换值后,您的类无法再取消订阅以前的值。因此,无论开始时订阅了什么,都将保持订阅状态。

用于检查运行时

例如,我会使用这样的属性来做到这一点

// Make it private so no other script can directly change this
[SerializedField] private SubjectSO _currentSubjectSO;
// The value can only be changed using this property
// automatically calling HandleSubjectChange
public SubjectSO subjectSO
{
get { return _currentSubjectSO; }
set 
{
HandleSubjectChange(this._currentSubjectSO, value);
}
}
private void HandleSubjectChange(SubjectSO oldSubject, SubjectSO newSubject)
{
if (!this.isActiveAndEnabled) return;
// If not null unsubscribe from the current subject
if(oldSubject) oldSubject.OnTrigger -= this.OnTriggerCallback;
// If not null subscribe to the new subject
if(newSubject) 
{
newSubject.OnTrigger -= this.OnTriggerCallback;
newSubject.OnTrigger += this.OnTriggerCallback;
}
// make the change
_currentSubjectSO = newSubject;
}

所以每次其他脚本使用

observerMBReference.subject = XY;

它首先自动取消订阅当前主题,然后订阅新主题。


用于通过检查器检查更改

有两种选择:

要么通过Update方法,要么通过另一个支持字段,例如

#if UNITY_EDITOR
private SubjectSO _previousSubjectSO;
private void Update()
{
if(_previousSubjectSO != _currentSubjectSO)
{
HandleSubjectChange(_previousSubjectSO, _currentSubjectSO);
_previousSubjectSO = _currentSubjectSO;
}
}
#endif

或者在OnValidate中做同样的事情(感谢赞巴里(

#if UNITY_EDITOR
private SubjectSO _previousSubjectSO;
// called when the component is created or changed via the Inspector
private void OnValidate()
{
if(!Apllication.isPlaying) return;
if(_previousSubjectSO != _currentSubjectSO)
{
HandleSubjectChange(_previousSubjectSO, _currentSubjectSO);
_previousSubjectSO = _currentSubjectSO;
}
}
#endif

或者 - 由于这只会在通过检查器更改字段的情况下发生 - 您可以实现一个 Cutsom 编辑器,该编辑器仅在字段更改的情况下执行此操作。这设置起来有点复杂,但会更有效,因为在构建的后期,您无论如何都不需要Update方法。

通常您将编辑器脚本放在一个名为Editor的单独文件夹中,但我个人认为将其实现到相应的类本身中是一种很好的做法。

优点是这样您也可以访问private方法。这样,您就会自动知道检查器还有一些其他行为。

#if UNITY_EDITOR
using UnityEditor;
#endif
...
public class ObserverMB : MonoBehaviour
{
[SerializeField] private SubjectSO _currentSubjectSO;
public SubjectSO subjectSO
{
get { return _currentSubjectSO; }
set 
{
HandleSubjectChange(_currentSubjectSO, value);
}
}
private void HandleSubjectChange(Subject oldSubject, SubjectSO newSubject)
{
// If not null unsubscribe from the current subject
if(oldSubject) oldSubject.OnTrigger -= this.OnTriggerCallback;
// If not null subscribe to the new subject
if(newSubject) newSubject.OnTrigger += this.OnTriggerCallback;
// make the change
_currentSubjectSO = newSubject;
}
public void OnEnable()
{
if(subjectSO) 
{
// I recommend to always use -= before using +=
// This is allowed even if the callback wasn't added before
// but makes sure it is added only exactly once!
subjectSO.OnTrigger -= this.OnTriggerCallback;
subjectSO.OnTrigger += this.OnTriggerCallback;
}
}
public void OnDisable()
{
if(this.subjectSO != null)
{
this.subjectSO.OnTrigger -= this.OnTriggerCallback;
}
}
public void OnTriggerCallback(string value)
{
Debug.Log("Callback Received! Value = " + value);
}
#if UNITY_EDITOR
[CustomEditor(typeof(ObserverMB))]
private class ObserverMBEditor : Editor 
{ 
private ObserverMB observerMB;
private SerializedProperty subject;
private Object currentValue;
private void OnEnable()
{
observerMB = (ObserverMB)target;
subject = serializedObject.FindProperty("_currentSubjectSO");
}
// This is kind of the update method for Inspector scripts
public override void OnInspectorGUI()
{
// fetches the values from the real target class into the serialized one
serializedObject.Update();
EditorGUI.BeginChangeCheck();
{
EditorGUILayout.PropertyField(subject);
}
if(EditorGUI.EndChangeCheck() && EditorApplication.isPlaying)
{
// compare and eventually call the handle method
if(subject.objectReferenceValue != currentValue) observerMB.HandleSubjectChange(currentValue, (SubjectSO)subject.objectReferenceValue);
}
}
}
#endif
}

最新更新