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