В этой статье я расскажу вам о том, что такое система событий применительно к 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. Завершение


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


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


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


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