Better/more elegant way of unsubscribing from event Action?

Hi all -

I make extensive use of C# events (not to be confused with UnityEvents). For example, when one of my classes spawns an enemy, they subscribe to that instance’s EnemyDied event.

Unfortunately, as my game involves a lot of switching scenes, forgetting to unsubscribe from these can quickly lead to errors arising from references to a destroyed object.

So far, I handle this by creating a list of all the enemies that the class has subscribed to and looping through it in the OnDisable / OnDestroy functions to make sure I’m unsubscribed from them.

That still leaves room for human error though (and seems a bit ham-fisted), so I’m wondering whether there’s a more elegant/automatic way of unsubscribing from C# events? The only alternative I can think of is to make these events static - that would eliminate the need to subscribe to every individual instance of the event, but still leave me with the need to maintain a list of subscribed senders so I know whether to react whenever the static event is raised.

Question - if the scene transitions wouldn’t all the enemies be destroyed along with the class that spawns the enemy? Why is there a situation that the spawner is destroyed but the enemy not?

What is this event handler doing on the death of an enemy as well?

There isn’t necessarily a single elegant way of unsubscribing bulk like that. But if you’re in that position, that might mean you have an inverted responsibility going on and refactoring your design may resolve your issue.

But I don’t know your actual use case. The conversation is rather abstract right now.

2 Likes

I use a similar approach to register for events, and the way I went was that although I have a subscription manager that any class can manually used to subscribe/unsubscribe, most of my MonoBehaviours end up implementing a common base class that provides convenience methods for registering in Start and automatically unregistering in Destroy. This doesn’t mean that the subscription service requires the use of a common base class for all subscribers, it’s just a convenience. Instead of having every class manually register, store a subscription token, and unregister on Destroy, my classes just call a method “RegisterSubscription()” in start, which is a method on my base class that registers and stores the subscription key, and will later unregister on Destroy.

1 Like

If your events work only in context of specific scenes, you could simply null the event when there is a change of scenes, instead of getting every subscriber for each event to unsubscribe.

1 Like

I use GetInvocationList() and then invoke each method myself in a try. If an exception is thrown the listener is gone and the method can be removed from the delegate.
See:

I know its debateable if try/catch shall be used for program flow but I have not found a better solution. And when even Microsoft does it in the example above what could go wrong? :wink:

Could you please give an example of how you use it? Especially how you generate and store the sub token?

Might be a bit tricky to sort out exactly what’s going on here, but I’ll just include the code for my SubscriptionDirector. It’s a singleton class. But first, here’s the code I most often use to register for events. This is part of the base class I use for almost all other classes, to make it easy for them to register for events:

        private HashSet<SubscriptionToken> _subscriptionTokens;
        private HashSet<ProviderToken> _providerTokens;

        protected virtual void Awake()
        {
            _subscriptionTokens = new HashSet<SubscriptionToken>();
            _providerTokens = new HashSet<ProviderToken>();
        }

        protected SubscriptionToken RegisterSubscription(SubscriptionType subscriptionType, Action<ISubscriptionPayload> payload)
        {
            var token = SubscriptionDirector.Instance.Subscribe(subscriptionType, this.gameObject, payload);
            _subscriptionTokens.Add(token);
            return token;
        }

There’s also some cleanup code there to clear out any subscriptions when an object gets destroyed.

SubscriptionType is just an enum that contains the various “events” that are possible to register for.

And if I want to trigger an event, I use a Publish call to the SubscriptionDirector. Here I’m calling an event to make the player immortal, as various unrelated parts of the application might care when that happens:

SubscriptionDirector.Instance.Publish(SubscriptionType.TogglePlayerImmortalMode, new PlayerImmortalModeSubscriptionPayload()
{
    ImmortalityMode = PlayerImmortalityMode.TakesDamage,
    DamageTypeExceptions = GetAllDamageTypesButExplosions()
});

Anyway, this is probably confusing without having full access to the code base, but maybe it gives some ideas. Here’s the whole subscription director:

using GraviaSoftware.Gravia.Code.Common.Enums;
using GraviaSoftware.Gravia.Code.Data.Subscriptions;
using GraviaSoftware.Gravia.Code.GameManagers;
using GraviaSoftware.Gravia.Code.Interfaces.Subscription;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using UnityEngine;

namespace GraviaSoftware.Gravia.Code.Directors
{
    /// <summary>
    /// This is a fairly generic service that allows subscribers to subscribe to various events
    /// </summary>
    public class SubscriptionDirector : DirectorBase<SubscriptionDirector>
    {
        [Tooltip("When enabled, verbosely logs subscribe and publish calls.")]
        public bool Verbose;

        private Dictionary<SubscriptionType, Dictionary<SubscriptionToken, Action<ISubscriptionPayload>>> _subscriptionStorage;

        protected SubscriptionDirector()
        {
            // Protected because this is a singleton.
        }


        protected override void Awake()
        {
            base.Awake();

            _subscriptionStorage = new Dictionary<SubscriptionType, Dictionary<SubscriptionToken, Action<ISubscriptionPayload>>>();


            // These needs to be instantiated very early.
            GameObjectTrackingManager.Instance.Ensure();
            ElectricalChargeManager.Instance.Ensure();

        }

        // Use this for initialization
        void Start()
        {
        }


        #region Subscribe/Unsubscribe


        /// <summary>
        /// Subscribed to an event of a certain type.
        /// </summary>
        /// <param name="subscriptionType"></param>
        /// <param name="action"></param>
        /// <returns></returns>
        public SubscriptionToken Subscribe(SubscriptionType subscriptionType,
            GameObject subscriber,
            Action<ISubscriptionPayload> callback)
        {
            VerboseLog($"Subscriber {subscriber.name} subscribing for {subscriptionType} events.");
            var token = new SubscriptionToken(subscriptionType, subscriber);

            lock (this)
            {
                if (!_subscriptionStorage.ContainsKey(subscriptionType))
                {
                    _subscriptionStorage.Add(subscriptionType, new Dictionary<SubscriptionToken, Action<ISubscriptionPayload>>());
                }
                _subscriptionStorage[subscriptionType].Add(token, callback);
            }

            return token;
        }


        private void VerboseLog(string message)
        {
            if (Verbose)
            {
                Debug.Log(message);
            }
        }

        public void Unsubscribe(SubscriptionToken subscriptionToken)
        {
            if (subscriptionToken.Subscriber != null)
            {
                VerboseLog($"Subscriber {subscriptionToken.Subscriber.name} unsubscribing for {subscriptionToken.SubscriptionType} events.");
            }

            lock (this)
            {
                if (_subscriptionStorage.ContainsKey(subscriptionToken.SubscriptionType))
                {
                    _subscriptionStorage[subscriptionToken.SubscriptionType].Remove(subscriptionToken);
                }
            }
        }

        public bool HasSubscription(SubscriptionToken subscriptionToken)
        {
            return _subscriptionStorage.ContainsKey(subscriptionToken.SubscriptionType)
                && _subscriptionStorage[subscriptionToken.SubscriptionType].ContainsKey(subscriptionToken);
        }

        public int GetSubscriptionCount(SubscriptionType subscriptionType)
        {
            if (!_subscriptionStorage.ContainsKey(subscriptionType))
            {
                return 0;
            }
            else
            {
                return _subscriptionStorage[subscriptionType].Count;
            }
        }

        /// <summary>
        /// Gets all current subscriptions of a given subscription type.
        /// </summary>
        /// <param name="subscriptionType"></param>
        /// <returns></returns>
        public List<SubscriptionToken> GetSubscriptions(SubscriptionType subscriptionType)
        {
            if (!_subscriptionStorage.ContainsKey(subscriptionType))
            {
                return new List<SubscriptionToken>();
            }
            else
            {
                return _subscriptionStorage[subscriptionType].Keys.ToList();
            }
        }

        /// <summary>
        /// Gets all current subscriptions for a game object.
        /// </summary>
        /// <param name="go"></param>
        /// <returns></returns>
        public List<SubscriptionToken> GetSubscriptions(GameObject go)
        {
            if (go == null)
            {
                return Enumerable.Empty<SubscriptionToken>().ToList();
            }
            return _subscriptionStorage.SelectMany(s => s.Value).Select(s => s.Key).Where(s => s.Subscriber == go).ToList();
        }

        public string GetSubscriberSummary()
        {
            StringBuilder sb = new StringBuilder();
            sb.AppendLine("SubscriptionDirector Subscriptions:");
            foreach (var subscriptionType in _subscriptionStorage.Keys)
            {
                if (_subscriptionStorage[subscriptionType].Any())
                {
                    sb.AppendLine($"  {subscriptionType}");
                    foreach (var kvp in _subscriptionStorage[subscriptionType].Where(k => k.Key.Subscriber != null)
                        .OrderBy(k => k.Key.TimeAdded).ThenBy(k => k.Key.Subscriber.name))
                    {
                        sb.AppendLine($"    { kvp.Key.Subscriber.name} (Time Added: { kvp.Key.TimeAdded})");
                    }
                }
            }
            return sb.ToString();
        }

        /// <summary>
        /// This is mainly just for testing, and probably should not be used often.
        /// </summary>
        public void ClearAllSubscriptions()
        {
            _subscriptionStorage = new Dictionary<SubscriptionType, Dictionary<SubscriptionToken, Action<ISubscriptionPayload>>>();
        }


        #endregion

        #region Publish

        /// <summary>
        /// Publish an event of a particular type with a given payload, alerting all subscribers.
        /// </summary>
        /// <param name="subscriptionType"></param>
        /// <param name="payload"></param>
        public void Publish(SubscriptionType subscriptionType, ISubscriptionPayload payload = null)
        {
            VerboseLog($"Publishing events for subscriptionType {subscriptionType}");

            Dictionary<SubscriptionToken, Action<ISubscriptionPayload>> subscriptions;

            if (_subscriptionStorage.TryGetValue(subscriptionType, out subscriptions))
            {
                // These needs to act on a list, because it's possible that a subscriber's response to handling
                // a publication is to immediately unsubscribe.
                foreach (var kvp in subscriptions.ToList())
                {
                    if (kvp.Key.Subscriber == null)
                    {
                        // The subscriber has probably been destroyed. Don't run the action, and warn that
                        // the subscription wasn't formally removed.
                        Debug.LogWarning($"Subscription for action {subscriptionType} associated with gameObject {kvp.Key.OriginalSubscriberName} is invalid. The subscriber has been destroyed, but the subscription was not removed. Subscription has automatically been removed, but look into why Unsubscribe was not called.");
                        Unsubscribe(kvp.Key);
                    }
                    else
                    {
                        try
                        {
                            kvp.Value?.Invoke(payload);
                            VerboseLog($"Notified {kvp.Key.Subscriber.name} of published event for subscriptionType {subscriptionType}");
                        }
                        catch (Exception ex)
                        {
                            // If any exceptions occur when informing the subscriber, we unsubscribe the subscriber.
                            Unsubscribe(kvp.Key);
                            Debug.LogException(ex);
                        }
                    }
                }
            }

        }


        #endregion



    }

}
1 Like

Thank you so much! I understand the concept and will try to apply it.