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

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

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

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

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

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

  • Из за проверок на дохлых подписчиков и дублей (раскрою позже) код медленней чем аналогичные решения
  • В качестве ядра ивента используется 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);
    }
    }

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

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

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

    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

    Оставить комментарий