В этой статье я расскажу вам о том, что такое система событий применительно к Unity. Изучим популярные методы и подробно разберем реализацию на интерфейсах, с которой я познакомился, работая в Owlcat Games.

Содержание
- Что такое система событий?
 - Существующие реализации
2.1. Подписка по ключу
2.2. Подписка по типу события
2.3. Подписка по типу подписчика - Реализация на интерфейсах
3.1. Подписка на событие
3.2. Вызов события
3.3. В чем прелесть интерфейсов - Тонкости реализации
4.1. Отказоустойчивость
4.2. Кеширование типов подписчиков
4.3. Отписка во время события - Завершение
 
1. Что такое система событий?
Любая игра состоит из множества систем: UI, звук, графика, ввод и тд и тп. Эти системы неизбежно взаимодействуют:
- В онлайн шутере игрок А убил игрока Б. Нужно вывести сообщение об этом в игровой лог.
 - В экономической стратегии завершилось строительство здания. Нужно проиграть звук уведомления и показать отметку на карте.
 - Игрок нажал на клавишу быстрого сохранения. Обработчик ввода должен передать сообщение об этом в систему сохранения.
 
Некоторые системы могут быть сильно связаны, например ввод и движение персонажа. В таких случаях можно вызывать нужный метод напрямую. Но если системы связаны слабо, гораздо лучше использовать систему событий. Давайте посмотрим, как это может работать на примере с сохранением.
public class InputManager : MonoBehavioiur
{
    private void Update()
    {
        if (Input.GetKeyDown(KeyCode.S))
        {
            EventSystem.RaiseEvent("quick-save");
        }
    }
}
public class SaveLoadManager : Monobehaviour
{
    private void OnEnable()
    {
        EventSystem.Subscribe("quick-save", QuickSave);
    }
    private void OnDisable()
    {
        EventSystem.Unsubscribe("quick-save", QuickSave);
    }
    private void QuickSave()
    {
        // код сохранения
        ...
    }
}В методе SaveLoadManager.OnEnable() мы подписываем метод QuickSave на событие типа "quick-save". Теперь, после вызова EventSystem.RaiseEvent("quick-save") отработает метод SaveLoadManager.QuickSave() и игра сохранится. Важно не забывать отписываться от событий, иначе это может привести к null reference exception или утечке памяти.
Такая реализация системы служит лишь примером. Использование строк, как меток событий очень багоопасно и неудобно.
В широком смысле система событий — это общедоступный объект, чаще всего статический класс. У него есть метод подписки на определенные события и метод вызова этих событий. Все остальное — детали.
2. Существующие реализации
В большинстве случаев методы подписки и вызова будут выглядеть примерно следующим образом:
// Подписка
EventSystem.Subscribe(тип_события, подписываемый_метод);
// Вызов
EventSystem.RaiseEvent(тип_события, аргументы);Рассмотрим самые популярные реализации, опираясь на эту схему.
2.1. Подписка по ключу
Один из самых простых вариантов это использовать в качестве тип_события строку или Enum. Строка однозначно хуже — мы можем опечататься и нам не поможет ни IDE, ни компилятор. Но проблема с передачей аргументов встает в обоих случаях. Чаще всего они передаются через params object[] args. И тут мы опять лишены подсказок IDE и компилятора.
// Подписка
EventSystem.Subscribe("get-damage", OnPlayerGotDamage);
// Вызов
EventSystem.RaiseEvent("get-damage", player, 10);
// Подписанный метод
void OnPlayerGotDamage(params object[] args)
{
    Player player = args[0] as Player;
    int damage = args[1] as int;
    ...
}2.2. Подписка по типу события
Этот метод использует обобщенные методы, что позволяет нам жестко задать аргументы.
// Подписка
EventSystem.Subscribe<GetDamageEvent>(OnPlayerGotDamage);
// Вызов
EventSystem.RaiseEvent<GetDamageEvent>(new GetDamageEvent(player, 10));
// Подписанный метод
void OnPlayerGotDamage(GetDamageEvent evt)
{
    Player player = evt.Player;
    int damage = evt.Damage;
    Debug.Log($"{Player} got damage {damage}");
}2.3. Подписка по типу подписчика
Этот способ как раз используется в нашем проекте. В нем мы опираемся на интерфейсы, которые реализует подписчик. Объяснение принципа его работы оставлю для следующей главы, здесь покажу лишь пример.
public class UILog : MonoBehaviour, IPlayerDamageHandler
{
    void Start()
    {
        // Подписка
        EventSystem.Subscribe(this);
    }
    // Подписанный метод
    public void HandlePlayerDamage(Player player, int damage)
    {
        Debug.Log($"{Player} got damage {damage}");
    }
}
// Вызов
EventSystem.RaiseEvent<IPlayerDamageHandler>(h =>
    h.HandlePlayerDamage(player, damage));3. Реализация на интерфейсах
В угоду понятности и краткости в выкладках ниже убраны некоторые детали. Без них система будет багоопасной, но для понимания основного принципа они не важны. Тем не менее мы рассмотрим их в разделе "Тонкости реализации".
3.1. Подписка на событие
В нашем случае в качестве ключа выступает тип подписчика, а точнее интерфейсов, который этот тип реализует.
Рассмотрим на примере быстрого сохранения. Создадим интерфейс, который будет выступать в роли ключа для такого события:
public interface IQiuckSaveHandler : IGlobalSubscriber
{
    void HandleQuickSave();
}Для того, чтобы интерфейс мог выступать ключом, он должен наследовать IGlobalSubscriber. Этот позволит системе отличать интерфейсы-ключи от всех остальных, скоро увидим как именно. Сам интерфейс IGlobalSubscriber не содержит никаких свойств и методов, он лишь метка.
Теперь подписка и отписка будут выглядеть очень просто:
public class SaveLoadManager : Monobehaviour, IQiuckSaveHandler
{
    private void OnEnable()
    {
        EventBus.Subscribe(this);
    }
    private void OnDisable()
    {
        EventBus.Unsubscribe(this);
    }
    private void HandleQuickSave()
    {
        // код сохранения
        ...
    }
}Посмотрим на код метода Subscribe.
public static class EventBus
{
    private static Dictionary<Type, List<IGlobalSubscriber>> s_Subscribers
        = new Dictionary<Type, List<IGlobalSubscriber>>();
    public static void Subscribe(IGlobalSubscriber subscriber)
    {
        List<Type> subscriberTypes = GetSubscriberTypes(subscriber.GetType());
        foreach (Type t in subscriberTypes)
        {
            if (!s_Subscribers.ContainsKey(t))
                s_Subscribers[t] = new List<IGlobalSubscriber>();
            s_Subscribers[t].Add(subcriber);
        }
    }
}Все подписчики хранятся в словаре s_Subscribers. Ключом этого словаря является тип, а значением список подписчиков соответствующего типа.
Метод GetSubscriberTypes будет описан чуть ниже. Он возвращает список типов интерфейсов-ключей, которые реализует подписчик. В нашем случае это будет список из одного элемента: IQiuckSaveHandler — хотя в реальности SaveLoadManager может реализовать несколько интерфейсов.
Вот мы имеем список типов subscriberTypes. Теперь остается для каждого типа получить соответствующий список из словаря s_Subscribers и добавить туда нашего подписчика.
А вот и реализация GetSubscribersTypes:
public static List<Type> GetSubscribersTypes(IGlobalSubscriber globalSubscriber)
{
    Type type = globalSubscriber.GetType();
    List<Type> subscriberTypes = type
        .GetInterfaces()
        .Where(it =>
                it.Implements<IGlobalSubscriber>() &&
                it != typeof(IGlobalSubscriber))
        .ToList();
    return subscriberTypes;
}Этот метод берет тип подписчика, берет у него список всех реализованных интерфейсов и оставляет среди них лишь те, которые в свою очередь реализуют IGlobalSubscriber. То есть делает ровно то, что и было заявлено.
Итак, в качества ключей в EventBus выступают интерфейсы, которые реализует подписчик.
3.2. Вызов события
Напомню, что мы все еще рассматриваем пример с быстрым сохранением. InputManager отслеживает нажатие на кнопку 'S', после чего вызывает событие быстрого сохранения.
Вот как это будет выглядеть в нашей реализации:
public class InputManager : MonoBehavioiur
{
    private void Update()
    {
        if (Input.GetKeyDown(KeyCode.S))
        {
            EventBus.RaiseEvent<IQiuckSaveHandler>(
                IQiuckSaveHandler handler => handler.HandleQuickSave());
        }
    }
}Давайте посмотрим на метод RaiseEvent:
public static class EventBus
{
    public static void RaiseEvent<TSubscriber>(Action<TSubscriber> action)
    where TSubscriber : IGlobalSubscriber
    {
        List<IGlobalSubscriber> subscribers = s_Subscribers[typeof(TSubscriber)];
        foreach (IGlobalSubscriber subscriber in subscribers)
        {
            action.Invoke(subscriber as TSubscriber);
        }
    }
}В нашем случае TSubscriber это IQiuckSaveHandler. IQiuckSaveHandler handler => handler.HandleQuickSave() это action, который мы применяем на всех подписчиков типа IQiuckSaveHandler. То есть в результате выполнения action вызовется метод HandleQuickSave и игра сохранится.
Для краткости вместоIQiuckSaveHandler handler => handler.HandleQuickSave() C# позволяет писать h => h.HandleQuickSave().
Описание интерфейсов в итоге определяет события, которые мы можем вызывать.
3.3. В чем прелесть интерфейсов
Интерфейс может реализовать более одного метода. Для нашего примера в реальности более логичным мог бы оказаться такой интерфейс:
public interface IQuickSaveLoadHandler : IGlobalSubscriber
{
    void HandleQuickSave();
    void HandleQuickLoad();
}Таким образом мы подписываемся не по одному методу, а сразу группой методов, которые объединены в один интерфейс.
Также важно отметить, что передавать какие-либо параметры в такой реализации как никогда просто. Рассмотрим пример 1 из начала статьи про онлайн шутер. Работа системы событий могла бы выглядеть следующим образом.
public interface IUnitDeathHandler : IGlobalSubscriber
{
    void HandleUnitDeath(Unit deadUnit, Unit killer);
}
public class UILog : IUnitDeathHandler
{
    public void HandleUnitDeath(Unit deadUnit, Unit killer)
    {
        Debug.Log(killer.name + " killed " + deadUnit.name);
    }
}
public class Unit 
{
    private int m_Health
    public void GetDamage(Unit damageDealer, int damage)
    {
        m_Health -= damage;
        if (m_Health <= 0)
        {
            EventBus.RaiseEvent<IQiuckSaveHandler>(h =>
                h.HandleUnitDeath(this, damageDealer));
        }
    }
}Интерфейсы позволяют очень гибко определять набор возможных событий и их сигнатуру.
4. Тонкости реализации
Как и обещал, рассмотрим некоторые технические детали, пропущенные в прошлом разделе.
4.1. Отказоустойчивость
Код внутри любого из подписчиков может привести к ошибке. Чтобы это не оборвало цепочку вызовов, обнесем это место try catch:
public static void RaiseEvent<TSubscriber>(Action<TSubscriber> action)
where TSubscriber : IGlobalSubscriber
{
    List<IGlobalSubscriber> subscribers = s_Subscribers[typeof(TSubscriber)];
    foreach (IGlobalSubscriber subscriber in subscribers)
    {
        try
        {
            action.Invoke(subscriber as TSubscriber);
        }
        catch (Exception e)
        {
            Debug.LogError(e);
        }
    }
}4.2. Кеширование типов подписчиков
Функция GetSubscribersTypes работает при помощи рефлексии, а рефлексия всегда работает очень медленно. Мы не можем полностью избавиться от этих вызовов, но можем закешировать уже пройденные значения.
private static Dictionary<Type, List<Types>> s_CashedSubscriberTypes = 
    new Dictionary<Type, List<Types>>()
public static List<Type> GetSubscribersTypes(
    IGlobalSubscriber globalSubscriber)
{
    Type type = globalSubscriber.GetType();
    if (s_CashedSubscriberTypes.ContainsKey(type))
        return s_CashedSubscriberTypes[type];
    List<Type> subscriberTypes = type
        .GetInterfaces()
        .Where(it =>
                it.Implements<IGlobalSubsriber>() &&
                it != typeof(IGlobalSubsriber))
        .ToList();
    s_CashedSubscriberTypes[type] = subscriberTypes;
    return subscriberTypes;
}4.3. Отписка во время события
Мы еще не описывали здесь метод отписки, но скорее всего он мог бы выглядеть как-то так:
public static void Unsubscribe(IGlobalSubsriber subcriber)
{
    List<Types> subscriberTypes = GetSubscriberTypes(subscriber.GetType());
    foreach (Type t in subscriberTypes)
    {
        if (s_Subscribers.ContainsKey(t))
            s_Subscribers[t].Remove(subcriber);
    }
}И такой метод будет работать в большинстве случаев. Но рано или поздно при вызове очередного события мы можем получить ошибку вида
Collection was modified; enumeration operation might not execute.
Такая ошибка возникает, если внутри прохода по какой-то коллекции при помощи foreach мы удалим элемент из этой коллекции.
foreach (var a in collection)
{
    if (a.IsBad())
    {
        collection.Remove(a); // получаем ошибку
    }
}В нашем случае проблема возникает, если во время вызова события один из подписчиков отписывается.
Для борьбы с этим мы во время отписки будем проверять, не проходимся ли мы сейчас по списку. Если нет, то просто удаляем, как и раньше. Но если проходимся, то обнулим этого подписчика в списке, а после прохода удалим из списка все null. Для реализации этого создадим обертку вокруг списка.
public class SubscribersList<TSubscriber> where TSubscriber : class
{
    private bool m_NeedsCleanUp = false;
    public bool Executing;
    public readonly List<TSubscriber> List = new List<TSubscriber>();
    public void Add(TSubscriber subscriber)
    {
        List.Add(subscriber);
    }
    public void Remove(TSubscriber subscriber)
    {
        if (Executing)
        {
            var i = List.IndexOf(subscriber);
            if (i >= 0)
            {
                m_NeedsCleanUp = true;
                List[i] = null;
            }
        }
        else
        {
            List.Remove(subscriber);
        }
    }
    public void Cleanup()
    {
        if (!m_NeedsCleanUp)
        {
            return;
        }
        List.RemoveAll(s => s == null);
        m_NeedsCleanUp = false;
    }
}Теперь обновим наш словарь в EventBus:
public static class EventBus
{
    private static Dictionary<Type, SubscribersList<IGlobalSubcriber>> s_Subscribers
        = new Dictionary<Type, SubscribersList<IGlobalSubcriber>>();
}После этого обновим метод вызова события RaiseEvent:
public static void RaiseEvent<TSubscriber>(Action<TSubscriber> action)
where TSubscriber : IGlobalSubscriber
{
    SubscribersList<IGlobalSubscriber> subscribers = s_Subscribers[typeof(TSubscriber)];
    subscribers.Executing = true;
    foreach (IGlobalSubscriber subscriber in subscribers.List)
    {
        try
        {
            action.Invoke(subscriber as TSubscriber);
        }
        catch (Exception e)
        {
            Debug.LogError(e);
        }
    }
    subscribers.Executing = false;
    subscribers.Cleanup();
}В теории этой ситуации может и не возникнуть, но на практике рано или поздно это происходит. Вы можете заметить, что мы беспокоимся только об удалении из коллекции, но не думаем о добавлении во время прохода. Вообще было бы правильно и этот случай обработать, но на практике у нас этой проблемы ни разу не возникало. Но если возникнет у вас, вы уже будете знать в чем дело.
5. Завершение
Большинство систем событий похожи друг на друга. Они имеют в основе такую же подписку по ключу и реализованы внутри тоже при помощи словаря. Проблема удаления во время перебора для них тоже актуальна.
Наше решение отличается использованием интерфейсов. Если немного задуматься, то использование интерфейсов в системе событий является очень логичным. Ведь интерфейсы изначально придуманы для определения возможностей объекта. В нашем случае речь идет о возможностях реагировать на те или иные события в игре.
В дальнейшем систему можно развивать под конкретный проект. Например в нашей игре существуют подписки на события конкретного юнита. Еще на вызов и завершение какого-то механического события.
          
 
Danand
Это всё очень хорошо, особенно для прототипов и хакатонов. Но а как же работать со связностью, если вдруг захочется расширить проект?
velik97 Автор
Не очень понимаю, как увеличение проекта ведет к проблемам со связностью. Могу лишь сказать, что мы используем такую систему (с различными расширениями) в наших играх (Pathfinder: Kingmaker и Pathfinder: WoTR). Это весьма большие проекты и никаких особых проблем с EventBus мы не испытываем.
SadOcean
Я попробую ответить.
Если кратко — со связностью нужно работать аккуратно и внимательно следить за ней.
Тут есть 2 аспекта — проблемы с EventBus и связность компонентов с EventBus компонентов с компонентами.
Зависимости:
— Сам по себе EventBus без сомнений станет зависимостью для всех, но это само по себе не большая проблема. Проблемой станет паразитные зависимости — если event bus будет связан с настройками/интерфейсами или конкретными сущностями (например, если его встраивают в главный контроллер игры). Он должен быть стабилен — синглтон с ленивой инициализацией, не ссылающйся ни на какие другие файлы.
— Если вы пишите переносимые компоненты — виджеты, компоненты рендера — не используйте EventBus. Используйте его в игровой логике.
— Не используйте EventBus с абстрактными или строковыми параметрами, используйте на интерфейсах или типах данных. Это защитит при переносах и переиспользовании компонент через зависимость на типы передаваемых сообщений/интерфейсы. Если вы завели тип/интерфейс в неймспейсе поставщика, все подписчики все равно получат явную зависимость на абстракцию от поставщика (его тип данных).
Но на самом деле это меньшие из проблем, самая важная проблема — это когда основной flow приложения неявным образом опирается на порядок вызовов в EventBus (который, по идее, обычно никак не гарантируется контрактом). Есть еще смежная проблема — когда в игре нельзя найти концов того, как игра пришла к такому состоянию (потому что ею управляла цепочка подписок).
Чтобы бороться с этими проблемами нужно ограничить использование EventBus:
— Пассивные реакции — компоненты, реагирующие на ивенты должны менять только свое состояние, но не состояние других компонентов.
— Низкоуровневые реакции — компоненты, реагирующие на ивенты, должны быть ниже по уровню абстракции. Условно, листья не должны шевелить деревом, дерево не должно управлять стартом игры (но наоборот — можно). Если у вас есть такое место, к примеру, рестарт игры завязан на упавшее здоровье игрока — возможно вы что-то делаете не так. Об этом же говорят проблемы с изменением списка подписчиков — скорее всего один из подписчиков слишком важная шишка и не должен быть просто подписчиком. Решайте это выделением специальных механизмов — к примеру, игрок должен иметь специальный колбек, который должен быть задан при его создании — тогда контроллер сущностей получает явную ручку для организации flow. А вот все остальные — интерфейсы здоровья, эффекты и т.д. — они подписываются на ивент изменения здоровья игрока.
— Изоляция. Если вы вынуждены написать логику, нарушающую первые 2 правила — выделите ее в отдельное место, которое об этом явно говорит. Например, ваши ГД хотят сами управлять условиями того, как завершается уровень, и хотят использовать для этого любой ивент, доступный им. Или стартующее новые миссии.
В этом случае это место станет точкой отказа, по логами и стактрейсу можно будет определить его как источник в случае ошибок, и в нем же можно будет разобраться с проблемами очередности и одновременности (к примеру, в этом компоненте можно будет организовать очередь сообщений, если могут быть выполнены сразу несколько условий с конфликтующими управляющими воздействиями)