[Разработка игр, C#, Unity] EventBus — Система событий для Unity
Автор
Сообщение
news_bot ®
Стаж: 6 лет 9 месяцев
Сообщений: 27286
В этой статье я расскажу вам о том, что такое система событий применительно к 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
===========
Похожие новости:
- [Разработка игр, Читальный зал, Дизайн игр, Старое железо, Игры и игровые приставки] История GoldenEye 007 (перевод)
- [Разработка игр, Продвижение игр, Будущее здесь] Детям геймеров нечего делать в школе. Почему игры должны быть частью школьного образования
- [Разработка игр, Продвижение игр, Игры и игровые приставки, Логические игры] Юридические грабли инди-разработчиков и методы их обхода
- [Информационная безопасность, Тестирование веб-сервисов] Web Security by Bugbounty
- [Open source, Виртуализация, Искусственный интеллект, Openshift] Еще немного про C# 8.0, шпаргалка по Red Hat OpenShift Container Platform и создаем конвейер upstream-to-downstream
- [Open source, Lisp, Функциональное программирование, Учебный процесс в IT, Искусственный интеллект] SRFI-216: Поддержка курса SICP. Обсудим?
- [Разработка игр, Игры и игровые приставки] Новую Call of Duty удалось втиснуть в 125 ГБ
- [Разработка игр, Prolog, Искусственный интеллект] Навеянное Prolog-ом коммерческое решение пробыло больше 10 лет в эксплуатации
- [Разработка мобильных приложений, Разработка игр, Игры и игровые приставки] Как я создал мобильную игру для своего ребёнка
- [Программирование, C++, C#] Как подружить .NET и IDA Pro (о дружбе C# и C++)
Теги для поиска: #_razrabotka_igr (Разработка игр), #_c#, #_unity, #_unity, #_sistema_sobytij (система событий), #_event_system, #_event_bus, #_messaging_system, #_event_aggregator, #_razrabotka_igr (
Разработка игр
), #_c#, #_unity
Вы не можете начинать темы
Вы не можете отвечать на сообщения
Вы не можете редактировать свои сообщения
Вы не можете удалять свои сообщения
Вы не можете голосовать в опросах
Вы не можете прикреплять файлы к сообщениям
Вы не можете скачивать файлы
Текущее время: 22-Ноя 19:03
Часовой пояс: UTC + 5
Автор | Сообщение |
---|---|
news_bot ®
Стаж: 6 лет 9 месяцев |
|
В этой статье я расскажу вам о том, что такое система событий применительно к Unity. Изучим популярные методы и подробно разберем реализацию на интерфейсах, с которой я познакомился работая в Owlcat Games. Содержание
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 =========== Похожие новости:
Разработка игр ), #_c#, #_unity |
|
Вы не можете начинать темы
Вы не можете отвечать на сообщения
Вы не можете редактировать свои сообщения
Вы не можете удалять свои сообщения
Вы не можете голосовать в опросах
Вы не можете прикреплять файлы к сообщениям
Вы не можете скачивать файлы
Вы не можете отвечать на сообщения
Вы не можете редактировать свои сообщения
Вы не можете удалять свои сообщения
Вы не можете голосовать в опросах
Вы не можете прикреплять файлы к сообщениям
Вы не можете скачивать файлы
Текущее время: 22-Ноя 19:03
Часовой пояс: UTC + 5