Идея написать свой расширенный агрегатор событий для Unity3d назрела давно. Прочитав несколько статей на эту тему, я понял что нет достаточно «правильного» (в рамках Unity3d) и нужного для меня агрегатора, все решения урезаны и не обладают нужным функционалом.
Общая идеология заключается в том, что для нас событие это конкретный и актуальный пакет данных. Допустим мы нажали кнопку на интерфейсе/джойстике. И хотим отправить ивент с признаками нажатия конкретной кнопки для последующей обработки. Результат нажатия обработки — визуальные изменения интерфейса и какое то действие в логике. Соответственно может быть обработка/подписка в двух разных местах.
Как выглядит в моем случае тело события/пакет данных:
Как выглядит подписка на ивент:
Для подписки нам надо указать:
Объект, который является подписчиком (обычно это сам класс в котором подписка, но это не обязательно, можно указать подписчиком один из экземпляров классов из полей класса.
Тип/ивент на который мы подписываемся. Это и есть ключевая суть данного агрегатора, для нас определенный тип класса и является событием которое мы слушаем и обрабатываем.
Подписываться лучше всего в Awake и OnEnable;
Пример
У нас есть иконки персонажей которые:
И вот тут можно сделать несколько ивентов
На изменение показателей:
На изменение негативных статусов:
Для чего в обоих случаях мы передаем класс персонажа? Вот подписчик на ивент и его обработчик:
Это маркер по которому мы обрабатываем ивент и понимаем что именно он нам нужен.
Почему допустим не подписаться напрямую на класс Character? И спамить им?
Такое будет сложно дебажить, лучше для группы классов/событиый создать свой отдельный ивент.
Почему опять же внутрь ивента просто не положить Character и брать всё с него?
Так кстати можно, но часто в классах есть ограничения видимости, и нужные данные для ивента могут быть не видны снаружи.
если класс слишком тяжелый чтобы использовать его в качестве маркера?
На самом деле маркер в большинстве случаев не нужен, группа обновляемых классов — скорее редкость. Обычно в ивенте нуждается одна конкретная сущность — контроллер/модель вьюхи, которая отображает обычно состояние 1го персонажа. А так всегда есть банальное решение — ID разных типов (от инама, до сложного хэша и тд).
Начнем с основного
Это словарь в котором ключ тип, а значение — контейнер
Почему мы храним контейнер в виде object? Словарь не умеет хранить дженерики. Но за счёт ключа мы имеем возможность оперативно привести объект к нужному нам типу.
Что содержит контейнер?
Он содержит дженерик мультиделегат и коллекцию где ключом выступает как раз тот объект подписчик, а значение этот тот самый метод обработчик. По факту этот словарь содержит все объекты и методы которые принадлежат этому типу. В итоге мы вызываем мультиделегат, а он вызывает всех подписчиков, это «честная» ивент система, в которой нет ограничений на подписчика, в большинстве же других агрегаторов под капотом итерируется коллекция классов, которые обобщены либо специальным интерфейсом, либо наследуются от класса который реализует систему сообщений.
При вызове мультделегата происходит проверка — есть ли дохлые ключи, чистится коллекция от трупов, и потом инвочится мультиделегат с актуальными подписчиками. Это отнимает время, но опять же по факту, если функционал ивентов сепарирован, то у одного ивента будет 3-5 подписчиков, поэтому проверка не так страшна, выгода от комфорта очевиднее. Для сетевых историй где подписчиков может быть тысяча и более — этот агрегатор лучше не использовать. Хотя тут остается открытым вопрос — если убрать проверку на трупы, что быстрее — итерация по массиву подписчиков из 1к или вызов мультиделегата с 1к подписчиков.
Подписку лучше всего пихать в Awake.
Если объект активно включается/выключается, лучше подписаться и в Awake и OnEnable, он не подпишется дважды, но будет исключена возможность что неактивный GameObject примут за дохлый.
Инвочить события лучше не раньше старта, когда все подписчики будут созданы и зарегестрированы.
Агрегатор чистит список на выгрузке сцены. В некоторых агрегаторах предлагается чистить на загрузке сцены — это фейл, ивент загрузки сцены приходит после Awake/OnEnable, добавленные подписчики будут удалены.
У агрегатора есть — public static string DebugInfo(), можно посмотреть какие классы на какие ивенты подписаны.
Репозиторий на GitHub
Необходимый функционал:
- Любой класс может подписаться на любое событие (часто агрегаторы в юнити делают подписчиками конкретные 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
