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