Как избежать утечки памяти в Unity при подписке на событие c # в указанном сериализованном ScriptableObject? - PullRequest
0 голосов
/ 11 октября 2019

Во время реализации моей игры с использованием Unity я столкнулся со следующей настройкой:

  • У меня есть ScriptableObject (в качестве актива) с делегатом ac # event.
  • У меня есть MonoBehaviour, в котором есть сериализованная ссылка на ScriptableObject, в которой есть делегат.

Я хочу "подписать" MonoBehaviour на этот ScriptableObject 's событие, правильно обрабатывая событие, чтобы избежать утечек памяти. Первоначально я предполагал, что подписки на событие с помощью обратного вызова OnEnable и отказа от подписки на OnDisable было достаточно. Однако происходит утечка памяти, когда разработчик с помощью Unity Inspector меняет значение сериализованной ссылки на ScriptableObject во время воспроизведения.

Существует ли канонический способ безопасной подписки и отпискик событиям c # в сериализованной ссылке на ScriptableObject, учитывая, что я хочу, чтобы разработчики игры могли менять значение в инспекторе во время игры?


Чтобы проиллюстрировать это,Я написал простой код для этого сценария:

SubjectSO.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 (MonoBehaviour, который хочет подписаться на событие в ScriptableObject)

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 (вспомогательный MonoBehaviour, для тестирования)

using UnityEngine;

public class InvokesSubjectSOEveryUpdate : MonoBehaviour
{
    public SubjectSO subjectSO;

    public void Update()
    {
        this.subjectSO?.Invoke();
    }
}

Для тестирования я создал два ресурса типа SubjectSO с именем:

  • SubjectA
  • SubjectB

Затем я создал GameObject в сцене и прикрепил следующие компоненты:

  • ObserverMB, ссылка Тема A
  • InvokesSubjectSOEveryUpdate, ссылка Тема A
  • InvokesSubjectSOEveryUpdate, ссылка SubjectB

При нажатии на воспроизведение сообщение Callback Received! Value = SubjectA печатается в консоли при каждом обновлении, что ожидается.

Затем, когда я использую инспектор дляизмените ссылку в ObserverMB с SubjectA на SubjectB , пока игра еще продолжается, сообщение Callback Received! Value = SubjectA по-прежнему печатается.

Если я отключуи включите ObserverMB в инспекторе, оба сообщения Callback Received! Value = SubjectA и Callback Received! Value = SubjectB начинают печататься при каждом обновлении.

Начальная подписка обратного вызова все еще действует, но, как подписчик, ObserverMB потерялссылка на это событие.

Как мне избежать этой ситуации?

Я действительно считаю, что это, кажется, сценарий общего использования для использования c # event делегаты и ScriptableObjects, и мне кажется странным, что OnEnable и OnDisable неправильно обрабатываютСлучай сериализации разработчика, настраивающего инспектора.

1 Ответ

2 голосов
/ 11 октября 2019

Ну, вам нужно проверить, изменяется ли 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

, либо делаете (спасибо zambari) то же самое в 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 Editor , который делает это тольков случае, если поле изменилось. Это немного сложнее в настройке, но будет более эффективным, так как позже в сборке вам все равно не понадобится метод 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
    }
...