Идея написать свой расширенный агрегатор событий для 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

Комментарии (6)


  1. rumyancevpavel
    31.03.2019 15:02

    Без возможности приоритизации и фильтрации событий на этом велосипеде можно очень быстро приехать в Race Condition Hell. Советую обратить внимание на библиотеку UniRx, вполне подходит под описанные вами задачи.


    1. Brightori Автор
      31.03.2019 17:37

      Мммм, объект подписывается на тот тип событий который ему нужен, при необходимости отсекает при обработке — интересно конкретно ему это событие или нет. И я там написал что лучше использовать для группы объектов отдельный ивент. Вполне себе фильтрация.

      Насчёт приоритетов и Race Condition Hell, всем надо пользоваться с умом ) можно любую технологию загнать в угол. Если нам нужно обновить пул данных по изменению или определенным условиям — тут нет Race Condition, в Unity это происходит в рамках обработки 1го кадра. Если за один кадр вдруг здоровье персонажа меняется насколько раз и таким образом мы получим кривые данные и в не то время- это уже проблема архитектуры/логики.

      ЗЫ Насчёт UniRx я в курсе )


  1. splatt
    01.04.2019 03:06

    Не критикуя статью, покритикую подход.
    Я считаю что глобальные аггрегаторы событий нарушают принципы SOLID.
    За 6 лет работы с Unity я не видел ни одного проекта, в котором глобальные аггрегаторы событий оправдывали бы себя. В основном все заканчивается спагетти кодом с кучей неявных зависимостей.


    Про re-usability кода можно вообще забыть — любой процесс переноса модуля в другой проект заканчивается жестким рефакторингом с поиском по всему проекту, где и что на что подписано.


    Этим, кстати, на мой взгляд страдают JS фреймворки и дизайны вроде React / Redux. Все отлично работает, когда у вас TODO приложение с двумя событиями. А когда у вас команда на 50 человек, тысячи классов и событий, все это становится очень трудно поддерживать.


    1. Brightori Автор
      01.04.2019 03:26

      В целом согласен, просто приведу ряд аргументов за конкретное решение:
      1) ивенты тут это конкретные классы или структуры, всегда можно посмотреть кто на них подписан и что передаётся внутри. То есть это не какой то DoSomething().Invoke;
      Вызов в моем решении это — Invoke(new DoSomething {data});
      И всегда можно глянуть кто что делает с классом DoSomething;

      2) Самое правильное использование агрегаторов — пробрасывание инфы до интерфейса и/или некая реактивность логики. В бизнес приложениях всё понятно — MVC, MVVM и тд, и там есть уже конкретные реализации. В играх же часто надо чтобы когда что то случилось, случилось что то еще, без дополнительныъ связей. Реальный пример из жизни — при наведении на игровой элемент, должны появиться тултипы(конкретные, с конкретной инфой) и подсветиться элементы интерфейса связанные с тем что под курсором. Это можно пробросить за 1-2 ивента(можно за один, но лучше логически разделить на подсветку определенной группы и вызов определенного тултипа с опред. инфой) и несколько строчек кода. Другие решения приведут к большему количеству кода/контролирующих сущностей.


      1. evnuh
        01.04.2019 16:31

        Это борьба с плохой архитектурой, а не удобство работы. Правильная архитектура это та, которая реализует самое простое из возможных решений.

        В вашем примере — при наведении на ящик, подстветкой его и показыванием тултипа. Логика подсказывает, что событие всего одно, и две реакции на него.
        В вашем решении же тот, кто кидает эвент должен знать что-то о подписчиках, которым может понадобиться этот эвент и конструировать два события для двух листенеров, потому что каждому из них нужны разные данные. Но эмиттер вообще не должно волновать, кто будет слушать этот эвент.


        1. Brightori Автор
          01.04.2019 16:57

          Почему вы решили что тот кто кидает ивент должен что то знать о подписчиках?
          Тут будет два ивента — потому что будут кейсы когда нужно подсвечивать интерфейсы, но не показывать тултип и наоборот. В обоих случаях будет компонент который отслеживает поведение курсора, и будет вызывать ивенты, например ShowToolTip. Ему абсолютно не важно кто подписчик. Подписчиком в архитектуре может быть фабрика тултипов, может быть вьюменеджер и тд. А в этом ивенте можно передать достаточно контекста, чтобы понять — нужен ли тултип с картинкой, или текстом, или анимированный и тд. В этом отличие от остальных агрегаторов, в которых сложно передавать контекст.

          Рассмотрим реализацию без ивента — хорошая альтернатива это прослойка с контроллером тултипов и DI.

          PS почему я считаю альтернативу с DI более слабой — у нас как раз появляется связанность и дополнительные сущности/интерфейсы/фабрики и тд.