EventBus — Система событий для Unity

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



Содержание


  1. Что такое система событий?
  2. Существующие реализации
    2.1. Подписка по ключу
    2.2. Подписка по типу события
    2.3. Подписка по типу подписчика
  3. Реализация на интерфейсах
    3.1. Подписка на событие
    3.2. Вызов события
    3.3. В чем прелесть интерфейсов
  4. Тонкости реализации
    4.1. Отказоустойчивость
    4.2. Кеширование типов подписчиков
    4.3. Отписка во время события
  5. Завершение

1. Что такое система событий?


Любая игра состоит из множества систем: UI, звук, графика, ввод и тд и тп. Эти системы неизбежно взаимодействуют:


  1. В онлайн шутере игрок А убил игрока Б. Нужно вывести сообщение об этом в игровой лог.
  2. В экономической стратегии завершилось строительство здания. Нужно проиграть звук уведомления и показать отметку на карте.
  3. Игрок нажал на клавишу быстрого сохранения. Обработчик ввода должен передать сообщение об этом в систему сохранения.

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


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. Завершение


Большинство систем событий похожи друг на друга. Они имеют в основе такую же подписку по ключу и реализованы внутри тоже при помощи словаря. Проблема удаления во время перебора для них тоже актуальна.


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


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


Ссылка на репозиторий.

Комментарии 7

    0
    Это всё очень хорошо, особенно для прототипов и хакатонов. Но а как же работать со связностью, если вдруг захочется расширить проект?
      +1
      Не очень понимаю, как увеличение проекта ведет к проблемам со связностью. Могу лишь сказать, что мы используем такую систему (с различными расширениями) в наших играх (Pathfinder: Kingmaker и Pathfinder: WoTR). Это весьма большие проекты и никаких особых проблем с EventBus мы не испытываем.
        0
        Я попробую ответить.
        Если кратко — со связностью нужно работать аккуратно и внимательно следить за ней.
        Тут есть 2 аспекта — проблемы с EventBus и связность компонентов с EventBus компонентов с компонентами.

        Зависимости:
        — Сам по себе EventBus без сомнений станет зависимостью для всех, но это само по себе не большая проблема. Проблемой станет паразитные зависимости — если event bus будет связан с настройками/интерфейсами или конкретными сущностями (например, если его встраивают в главный контроллер игры). Он должен быть стабилен — синглтон с ленивой инициализацией, не ссылающйся ни на какие другие файлы.
        — Если вы пишите переносимые компоненты — виджеты, компоненты рендера — не используйте EventBus. Используйте его в игровой логике.
        — Не используйте EventBus с абстрактными или строковыми параметрами, используйте на интерфейсах или типах данных. Это защитит при переносах и переиспользовании компонент через зависимость на типы передаваемых сообщений/интерфейсы. Если вы завели тип/интерфейс в неймспейсе поставщика, все подписчики все равно получат явную зависимость на абстракцию от поставщика (его тип данных).

        Но на самом деле это меньшие из проблем, самая важная проблема — это когда основной flow приложения неявным образом опирается на порядок вызовов в EventBus (который, по идее, обычно никак не гарантируется контрактом). Есть еще смежная проблема — когда в игре нельзя найти концов того, как игра пришла к такому состоянию (потому что ею управляла цепочка подписок).

        Чтобы бороться с этими проблемами нужно ограничить использование EventBus:
        — Пассивные реакции — компоненты, реагирующие на ивенты должны менять только свое состояние, но не состояние других компонентов.
        — Низкоуровневые реакции — компоненты, реагирующие на ивенты, должны быть ниже по уровню абстракции. Условно, листья не должны шевелить деревом, дерево не должно управлять стартом игры (но наоборот — можно). Если у вас есть такое место, к примеру, рестарт игры завязан на упавшее здоровье игрока — возможно вы что-то делаете не так. Об этом же говорят проблемы с изменением списка подписчиков — скорее всего один из подписчиков слишком важная шишка и не должен быть просто подписчиком. Решайте это выделением специальных механизмов — к примеру, игрок должен иметь специальный колбек, который должен быть задан при его создании — тогда контроллер сущностей получает явную ручку для организации flow. А вот все остальные — интерфейсы здоровья, эффекты и т.д. — они подписываются на ивент изменения здоровья игрока.
        — Изоляция. Если вы вынуждены написать логику, нарушающую первые 2 правила — выделите ее в отдельное место, которое об этом явно говорит. Например, ваши ГД хотят сами управлять условиями того, как завершается уровень, и хотят использовать для этого любой ивент, доступный им. Или стартующее новые миссии.
        В этом случае это место станет точкой отказа, по логами и стактрейсу можно будет определить его как источник в случае ошибок, и в нем же можно будет разобраться с проблемами очередности и одновременности (к примеру, в этом компоненте можно будет организовать очередь сообщений, если могут быть выполнены сразу несколько условий с конфликтующими управляющими воздействиями)

        +1
        Есть прекрасная либа (которая портирована под Unity тоже) — Rx.
        В ней есть прекрасный EventBus.

        В вашем коде я не вижу, чтобы вы использовали WeakRef и значит если где-то забыли отписаться от события, но объект решили удалить, то можем получить утечки памяти.

        Поправьте, если я ошибаюсь
          0
          Да, в UniRx есть своя система событий и ее можно отнести ко второму типу (подписка по типу события). Я тут представил другой подход.

          WeakRef у нас нету, поэтому утечки потенциально возможны. Для борьбы с этим можно придумывать различные тактики. Один из вариантов это использовать паттерн Disposable. То есть можно чуть править метод подписки, чтобы он возвращал IDisposable. Это хорошо сочетается с использованием того же UniRx. Но встроить WeakRef звучит как неплохая идея, спасибо за совет.
          0
          опять же никто не отменял встроенные возможности шарпа для работы с событиями, которые, в целом, являются довольно удобным механизмом при наличии ссылки на объект. К тому же их строгость типизации является весомым аргументом, нежели использовать вариант с передачей массива объектов
            0
            На встроенных событиях можно построить систему по второму типу (подписка по типу события). Я тут предлагаю другую и на мой взгляд более продвинутую реализацию.

            Вариант с массивом объектов я описал для полноты картины. Согласен, что он весьма проблемный и не советую никому к нему прибегать.

          Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

          Самое читаемое