0

What I'm trying to do:

A ScriptableObject class to hold a single variable that can be subscribed to in an observer pattern to receive notifications when the value changes.

My intent is to have things like a UI display update when whatever they display changes, without having to manually trigger an event on every change.

Additionally, I want my class to have three features:

  1. Use try/catch in order to really decouple things and not make all listeners fail just because one did
  2. Have the option to log stuff for debugging
  3. Show the list of currently active observers in the inspector

I thought that's a few lines of code with Delegates, but it turns out nope, that simply doesn't work.

My first naive iteration was this:

using System;
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.Events;


[CreateAssetMenu(fileName = "New Observable Float", menuName = "Observables/Float")]
public class ObservableFloat : ScriptableObject {

    public event Action<float> get;

    [SerializeField] private float m_Value;
    public float Value {
        get {
            return m_Value;
        }
        set {
            m_Value = value;
            get?.Invoke(value);
        }
    }
}

My second iteration, which works functionally, but doesn't show me the list of observers in the inspector, was this:

using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.Events;


[CreateAssetMenu(fileName = "New Observable Float", menuName = "Observables/Float")]
public class ObservableFloat : ScriptableObject {

    [SerializeField] List<UnityAction<float>> listeners = new List<UnityAction<float>>();

    [SerializeField] private float m_Value;
    public float Value {
        get {
            return m_Value;
        }
        set {
            m_Value = value;
            foreach (UnityAction<float> action in listeners) {
                action.Invoke(value);
            }
        }
    }

    public void AddListener(UnityAction<float> func) => listeners.Add(func);
    public void RemoveListener(UnityAction<float> func) => listeners.Remove(func);
}

My third iteration, replacing UnityAction with UnityEvents, appears to work at first glance (the list shows up in the Inspector), but it never updates the list and it's always shown as empty, even though again functionally it works:

using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.Events;
using Sirenix.OdinInspector;


[CreateAssetMenu(fileName = "New Observable Float", menuName = "Observables/Float")]
public class ObservableFloat : ScriptableObject {

    public UnityEvent<float> listeners = new UnityEvent<float>();

    [SerializeField] private float m_Value;
    public float Value {
        get {
            return m_Value;
        }
        set {
            m_Value = value;
            listeners?.Invoke(value);
        }
    }
}
Tom
  • 2,688
  • 3
  • 29
  • 53
  • Properties (setters and getters) are this, but why not for this? – Confused Dec 22 '21 at 08:36
  • @Confused I don't get the question. With properties, you have tightly coupled components. That's something I want to avoid. – Tom Dec 22 '21 at 10:58
  • Have the setter store a list or array of subscribed objects. Faster, lighter and infinitely better than delegates, which create garbage. – Confused Dec 22 '21 at 11:49
  • @Confused still don't get it. Maybe you can expand it into a short answer? – Tom Dec 22 '21 at 14:02

2 Answers2

1

In general I think what you are looking for would be UnityEvent

[SerializeField] private UnityEvent<float> listeners;

public void AddListener(Action<float> action) => listeners.AddListener(action);

public void  RemoveListener(Action<float> action) => listeners.RemoveListener(action);

[SerializeField] private float m_Value;
public float Value {
    get {
        return m_Value;
    }
    set {
        m_Value = value;
        listeners?.Invoke(value);
    }
}

Unfortunately these will always only show the persistent listeners in the Inspector. There is no simple built-in way to also display runtime callbacks, and if you want to do this I guess there is no way around Reflection and/or a very complex special Inspector implementation.

You could e.g. store something like

using System.Reflection;
using System.Linq;

...

[Serializable]
public class ListenerInfo
{
    public Action<float> action;
    public string MethodName;
    public string TypeName;
}

[SerializeField] private List<string> listenerInfos;

public void AddListener(Action<float> action)
{
    listeners.AddListener(action);

    var info = action.GetMethodInfo();
    listenerInfos.Add(new ListenerInfo { action = action, MethodName = info.Name, TypeName = info.DeclaringType.Name });
}

public void RemoveListener (Action<float> action)
{
    listeners.RemoveListener(action);

    var info = var info = action.GetMethodInfo();
    listenerInfos.RemoveAll(l => l.action == action);
}

Also see e.g. Action delegate. How to get the instance that call the method

I guess that would kinda be the closest you can get without really diving deep into Unity Editor scripting and even more reflection ^^

derHugo
  • 83,094
  • 9
  • 75
  • 115
  • This sadly has the same issue of observers added in script not showing up in the Inspector. I've now come to believe that this is a limitation of UnityEvent, which simply doesn't have a method to get the non-persistent listeners. :-( – Tom Dec 22 '21 at 07:19
  • That doesn't matter does it? The runtime added listeners are never appearing in the Inspector... – derHugo Dec 22 '21 at 07:21
  • But that is one of the features I want (#3 in my list above). When checking the game functionality, looking for bugs, etc. I want AT RUNTIME to inspect if all the listeners are set up correctly, etc. – Tom Dec 22 '21 at 07:24
  • @Tom well that is simply not possible. At least not the same way as the persistent listeners where you can click on the object etc ... you **can** use Reflection and store e.g. a list of strings of the type and method names but I guess that's the closest you will get without a more complex custom Inspector implementation – derHugo Dec 22 '21 at 08:32
  • thanks. Yeah, I think I will need to use reflection. – Tom Dec 22 '21 at 08:41
  • @Tom I edited the answer .. typing this on the phone but I hope it gives a good start point somehow – derHugo Dec 22 '21 at 08:43
  • brilliant. That is almost exactly what I came up with, except that I use the HashCode to identify the listener. See my answer. – Tom Dec 22 '21 at 08:50
-1

I've come up with a solution that works, though I'm not perfectly sure about it, so I posted it in CodeReview - https://codereview.stackexchange.com/questions/272241/unity3d-observable-variable

Here's the code (but check the above link for possible fixes/improvements). A huge thanks to @derHugo who pointed me in the right direction:

using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.Events;
using UnityEditor;
using Sirenix.OdinInspector;


[CreateAssetMenu(fileName = "New Observable Float", menuName = "Observables/Float")]
public class ObservableFloat : ScriptableObject {

    [System.Serializable]
    public class Listener {
        [DisplayAsString, HideInInspector] public int ID;
        [DisplayAsString, HideLabel, HorizontalGroup] public string Observer;
        [DisplayAsString, HideLabel, HorizontalGroup] public string Method;
        [HideInInspector] public UnityAction<float> Callback;
        
        public Listener(UnityAction<float> cb) {
            ID = cb.GetHashCode();
            Observer = cb.Target.ToString();
            Method = cb.Method.ToString();
            Callback = cb;
        }
    }

    [Delayed]
    [OnValueChanged("NotifyListeners")]
    [SerializeField] private float m_Value;
    public float Value {
        get {
            return m_Value;
        }
        set {
            m_Value = value;
            NotifyListeners();
        }
    }

    [Tooltip("Log Invoke() calls")]
    [SerializeField] bool Trace;

    [Tooltip("Use try/catch around Invoke() calls so events continue to other listeners even if one fails")]
    [SerializeField] bool CatchExceptions;

    [ListDrawerSettings(DraggableItems = false, Expanded = true, ShowIndexLabels = false, ShowPaging = false, ShowItemCount = true)]
    [SerializeField] List<Listener> listeners = new List<Listener>();


    void Awake() {
        // clear out whenever we start - just in case some observer doesn't properly remove himself
        // maybe later I'll also add persistent listeners, but for now I don't see the use case
        listeners = new List<Listener>();
    }

    void NotifyListeners() {
        foreach (Listener listener in listeners) {
            if (Trace) {
                Debug.Log("invoking "+listener.Observer+" / "+listener.Method+ " / value = "+m_Value);
            }
            if (CatchExceptions) {
                try {
                    listener.Callback.Invoke(m_Value);
                } catch (System.Exception exception) {
                    Debug.LogException(exception, this);
                }
            } else {
                listener.Callback.Invoke(m_Value);              
            }
        }
    }

    public void AddListener(UnityAction<float> func) { listeners.Add(new Listener(func)); }
    public void RemoveListener(UnityAction<float> func) { listeners.RemoveAll(l => l.ID == func.GetHashCode()); }
}

This works and gives me the features I wanted, not sure if it's a great solution, so I'll leave the question open for better answers.

Tom
  • 2,688
  • 3
  • 29
  • 53