Home > Blockchain >  Unity C# creating observable variables with delegates
Unity C# creating observable variables with delegates

Time:12-22

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);
        }
    }
}

CodePudding user response:

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 ^^

CodePudding user response:

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.

CodePudding user response:

I'm afraid you can not use {get;set;} method while you want to show the property in the inspector. Inspector will only show public properties and properties with{get;set;} method will be recognized as only method.

  • Related