В этой статье я расскажу вам о том, что такое система событий применительно к 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 правила — выделите ее в отдельное место, которое об этом явно говорит. Например, ваши ГД хотят сами управлять условиями того, как завершается уровень, и хотят использовать для этого любой ивент, доступный им. Или стартующее новые миссии.
В этом случае это место станет точкой отказа, по логами и стактрейсу можно будет определить его как источник в случае ошибок, и в нем же можно будет разобраться с проблемами очередности и одновременности (к примеру, в этом компоненте можно будет организовать очередь сообщений, если могут быть выполнены сразу несколько условий с конфликтующими управляющими воздействиями)