[Разработка игр, C#, Unity] EventBus — Система событий для Unity

Автор Сообщение
news_bot ®

Стаж: 6 лет 7 месяцев
Сообщений: 27286

Создавать темы news_bot ® написал(а)
11-Ноя-2020 00:30

В этой статье я расскажу вам о том, что такое система событий применительно к 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. Завершение
Большинство систем событий похожи друг на друга. Они имеют в основе такую же подписку по ключу и реализованы внутри тоже при помощи словаря. Проблема удаления во время перебора для них тоже актуальна.
Наше решение отличается использованием интерфейсов. Если немного задуматься, то использование интерфейсов в системе событий является очень логичным. Ведь интерфейсы изначально придуманы для определения возможностей объекта. В нашем случае речь идет о возможностях реагировать на те или иные события в игре.
В дальнейшем систему можно развивать под конкретный проект. Например в нашей игре существуют подписки на события конкретного юнита. Еще на вызов и завершение какого-то механического события.
Ссылка не репозиторий.
===========
Источник:
habr.com
===========

Похожие новости: Теги для поиска: #_razrabotka_igr (Разработка игр), #_c#, #_unity, #_unity, #_sistema_sobytij (система событий), #_event_system, #_event_bus, #_messaging_system, #_event_aggregator, #_razrabotka_igr (
Разработка игр
)
, #_c#, #_unity
Профиль  ЛС 
Показать сообщения:     

Вы не можете начинать темы
Вы не можете отвечать на сообщения
Вы не можете редактировать свои сообщения
Вы не можете удалять свои сообщения
Вы не можете голосовать в опросах
Вы не можете прикреплять файлы к сообщениям
Вы не можете скачивать файлы

Текущее время: 28-Сен 03:46
Часовой пояс: UTC + 5