Агрегатор событий для Unity3d (Event Aggregator)

в 9:45, , рубрики: C#, event-driven programming, indie, unity, unity3d, unity3d events

Идея написать свой расширенный агрегатор событий для Unity3d назрела давно. Прочитав несколько статей на эту тему, я понял что нет достаточно «правильного» (в рамках Unity3d) и нужного для меня агрегатора, все решения урезаны и не обладают нужным функционалом. 

Необходимый функционал:

  1. Любой класс может подписаться на любое событие (часто агрегаторы в юнити делают подписчиками конкретные Gameobject)
  2. Должна быть исключена возможность двойной подписки конкретного экземпляра, на конкретное событие (в стандартных средствах за этим нужно следить самому)
  3. Должен быть функционал как ручной отписки, так и автоматической, в случае удаления экземпляра/отключения монобеха (хочется подписаться и не париться что подписчик вдруг откинет копытца)
  4. события должны уметь перекидывать данные/ссылки любой сложности (хочется в одну строку подписаться и получить весь комплект данных без заморочек)

Где это применять

  1. Это идеально подходит для UI, когда есть необходимость прокинуть данные от любого объекта без какой либо связанности.
  2. Сообщения об изменении данных, некий аналог реактивного кода.
  3. Для инъекций зависимостей
  4. Глобальных колбэков

Слабые места

  1. Из за проверок на дохлых подписчиков и дублей (раскрою позже) код медленней чем аналогичные решения
  2. В качестве ядра ивента используется class/struct, чтобы не аллоцировать память + верхняя проблема, не рекомендуется спамить ивентами в апдейте )

Общая идеология

Общая идеология заключается в том, что для нас событие это конкретный и актуальный пакет данных. Допустим мы нажали кнопку на интерфейсе/джойстике. И хотим отправить ивент с признаками нажатия конкретной кнопки для последующей обработки. Результат нажатия обработки — визуальные изменения интерфейса и  какое то действие в логике. Соответственно может быть обработка/подписка в двух разных местах. 

Как выглядит в моем случае тело события/пакет данных:

Пример тела ивента

public struct ClickOnButtonEvent
    {
        public int ButtonID; // здесь может быть также enum клавиши
    }

Как выглядит подписка на ивент:


public static void AddListener<T>(object listener, Action<T> action)

Для подписки нам надо указать:
Объект, который является подписчиком (обычно это сам класс в котором подписка, но это не обязательно, можно указать подписчиком один из экземпляров классов из полей класса.
Тип/ивент на который мы подписываемся. Это и есть ключевая суть данного агрегатора, для нас определенный тип класса и является событием которое мы слушаем и обрабатываем.
Подписываться лучше всего в Awake и OnEnable;

Пример

public class Example : MonoBehaviour
{
    private void Awake()
    {
        EventAggregator.AddListener<ClickOnButtonEvent>(this, ClickButtonListener);
    }

    private void ClickButtonListener(ClickOnButtonEvent obj)
    {
        Debug.Log("нажали на кнопку" + obj.ButtonID);
    }
}

Чтобы было понятно в чем фишка, рассмотрим более сложный случай

У нас есть иконки персонажей которые:

  1. Знают к какому персонажу они прикреплены
  2. Отражают количество маны, хп, экспы, а также статусы(оглушение, слепота, страх, безумие)

И вот тут можно сделать несколько ивентов

На изменение показателей:

public struct CharacterStateChanges
{
    public Character Character;
    public float Hp;
    public float Mp;
    public float Xp;
}

На изменение негативных статусов:

public struct CharacterNegativeStatusEvent
{
    public Character Character;
    public Statuses Statuses; //enum статусов
}

Для чего в обоих случаях мы передаем класс персонажа? Вот подписчик на ивент и его обработчик:

private void Awake()
    {
        EventAggregator.AddListener<CharacterNegativeStatusEvent>
                (this, CharacterNegativeStatusListener);
    }

    private void CharacterNegativeStatusListener(CharacterNegativeStatusEvent obj)
    {
        if (obj.Character != _character)
            return;

        _currentStatus = obj.Statuses;
    }

Это маркер по которому мы обрабатываем ивент и понимаем что именно он нам нужен.
Почему допустим не подписаться напрямую на класс Character? И спамить им?
Такое будет сложно дебажить, лучше для группы классов/событиый создать свой отдельный ивент.

Почему опять же внутрь ивента просто не положить Character и брать всё с него?
Так кстати можно, но часто в классах есть ограничения видимости, и нужные данные для ивента могут быть не видны снаружи.

если класс слишком тяжелый чтобы использовать его в качестве маркера?
На самом деле маркер в большинстве случаев не нужен, группа обновляемых классов — скорее редкость. Обычно в ивенте нуждается одна конкретная сущность — контроллер/модель вьюхи, которая отображает обычно состояние 1го персонажа. А так всегда есть банальное решение — ID разных типов (от инама, до сложного хэша и тд).

Что под капотом и как это работает?

Непосредственно код агрегатора

namespace GlobalEventAggregator
public delegate void EventHandler<T>(T e);
{
    public class EventContainer<T> : IDebugable
    {
        private event EventHandler<T> _eventKeeper;
        private readonly Dictionary<WeakReference, EventHandler<T>> _activeListenersOfThisType = new Dictionary<WeakReference, EventHandler<T>>();
        private const string Error = "null";

        public bool HasDuplicates(object listener)
        {
            return _activeListenersOfThisType.Keys.Any(k => k.Target == listener);
        }

        public void AddToEvent(object listener, EventHandler<T> action)
        {
            var newAction = new WeakReference(listener);
            _activeListenersOfThisType.Add(newAction, action);
            _eventKeeper += _activeListenersOfThisType[newAction];
        }

        public void RemoveFromEvent(object listener)
        {
            var currentEvent = _activeListenersOfThisType.Keys.FirstOrDefault(k => k.Target == listener);
            if (currentEvent != null)
            {
                _eventKeeper -= _activeListenersOfThisType[currentEvent];
                _activeListenersOfThisType.Remove(currentEvent);
            }
        }

        public EventContainer(object listener, EventHandler<T> action)
        {
            _eventKeeper += action;
            _activeListenersOfThisType.Add(new WeakReference(listener), action);
        }

        public void Invoke(T t)
        {
            if (_activeListenersOfThisType.Keys.Any(k => k.Target.ToString() == Error))
            {
                var failObjList = _activeListenersOfThisType.Keys.Where(k => k.Target.ToString() == Error).ToList();
                foreach (var fail in failObjList)
                {
                    _eventKeeper -= _activeListenersOfThisType[fail];
                    _activeListenersOfThisType.Remove(fail);
                }
            }

            if (_eventKeeper != null)
                _eventKeeper(t);
            return;
        }

        public string DebugInfo()
        {
            string info = string.Empty;
            foreach (var c in _activeListenersOfThisType.Keys)
            {
                info += c.Target.ToString() + "n";
            }
            return info;
        }
    }

    public static class EventAggregator
    {
        private static Dictionary<Type, object> GlobalListeners = new Dictionary<Type, object>();

        static EventAggregator()
        {
            SceneManager.sceneUnloaded += ClearGlobalListeners;
        }

        private static void ClearGlobalListeners(Scene scene)
        {
            GlobalListeners.Clear();
        }

        public static void AddListener<T>(object listener, Action<T> action)
        {
            var key = typeof(T);
            EventHandler<T> handler = new EventHandler<T>(action);

            if (GlobalListeners.ContainsKey(key))
            {
                var lr = (EventContainer<T>)GlobalListeners[key];
                if (lr.HasDuplicates(listener))
                    return;
                lr.AddToEvent(listener, handler);
                return;
            }
            GlobalListeners.Add(key, new EventContainer<T>(listener, handler));
        }

        public static void Invoke<T>(T data)
        {
            var key = typeof(T);
            if (!GlobalListeners.ContainsKey(key))
                return;
            var eventContainer = (EventContainer<T>)GlobalListeners[key];
            eventContainer.Invoke(data);
        }

        public static void RemoveListener<T>(object listener)
        {
            var key = typeof(T);
            if (GlobalListeners.ContainsKey(key))
            {
                var eventContainer = (EventContainer<T>)GlobalListeners[key];
                eventContainer.RemoveFromEvent(listener);
            }
        }

        public static string DebugInfo()
        {
            string info = string.Empty;

            foreach (var listener in GlobalListeners)
            {
                info += "тип на который подписаны объекты " +  listener.Key.ToString() + "n";
                var t = (IDebugable)listener.Value;
                info += t.DebugInfo() + "n";
            }

            return info;
        }
    }

    public interface IDebugable
    {
        string DebugInfo();
    }
}

Начнем с основного

Это словарь в котором ключ тип, а значение — контейнер

public class EventContainer<T> : IDebugable

private static Dictionary<Type, object> GlobalListeners = new Dictionary<Type, object>();

Почему мы храним контейнер в виде object? Словарь не умеет хранить дженерики. Но за счёт ключа мы имеем возможность оперативно привести объект к нужному нам типу.

Что содержит контейнер?

private event EventHandler<T> _eventKeeper;
        private readonly Dictionary<WeakReference, EventHandler<T>> _activeListenersOfThisType = new Dictionary<WeakReference, EventHandler<T>>();

Он содержит дженерик мультиделегат и коллекцию где ключом выступает как раз тот объект подписчик, а значение этот тот самый метод обработчик. По факту этот словарь содержит все объекты и методы которые принадлежат этому типу. В итоге мы вызываем мультиделегат, а он вызывает всех подписчиков, это «честная» ивент система, в которой нет ограничений на подписчика, в большинстве же других агрегаторов под капотом итерируется коллекция классов, которые обобщены либо специальным интерфейсом, либо наследуются от класса который реализует систему сообщений.

При вызове мультделегата происходит проверка — есть ли дохлые ключи, чистится коллекция от трупов, и потом инвочится мультиделегат с актуальными подписчиками. Это отнимает время, но опять же по факту, если функционал ивентов сепарирован, то у одного ивента будет 3-5 подписчиков, поэтому проверка не так страшна, выгода от комфорта очевиднее. Для сетевых историй где подписчиков может быть тысяча и более — этот агрегатор лучше не использовать. Хотя тут остается открытым вопрос — если убрать проверку на трупы, что быстрее — итерация по массиву подписчиков из 1к или вызов мультиделегата с 1к подписчиков.

Особенности пользования

Подписку лучше всего пихать в Awake.

Если объект активно включается/выключается, лучше подписаться и в Awake и OnEnable, он не подпишется дважды, но будет исключена возможность что неактивный GameObject примут за дохлый.

Инвочить события лучше не раньше старта, когда все подписчики будут созданы и зарегестрированы.

Агрегатор чистит список на выгрузке сцены. В некоторых агрегаторах предлагается чистить на загрузке сцены — это фейл, ивент загрузки сцены приходит после Awake/OnEnable, добавленные подписчики будут удалены.

У агрегатора есть — public static string DebugInfo(), можно посмотреть какие классы на какие ивенты подписаны.

Репозиторий на GitHub

Автор: Brightori

Источник

* - обязательные к заполнению поля