В этой статье я расскажу вам о том, что такое система событий применительно к Unity. Изучим популярные методы и подробно разберем реализацию на интерфейсах, с которой я познакомился, работая в Owlcat Games.  Любая игра состоит из множества систем: UI, звук, графика, ввод и тд и тп. Эти системы неизбежно взаимодействуют: Некоторые системы могут быть сильно связаны, например ввод и движение персонажа. В таких случаях можно вызывать нужный метод напрямую. Но если системы связаны слабо, гораздо лучше использовать систему событий. Давайте посмотрим, как это может работать на примере с сохранением. В методе SaveLoadManager.OnEnable() мы подписываем метод QuickSave на событие типа "quick-save". Теперь, после вызова EventBus.RaiseEvent("quick-save") отработает метод SaveLoadManager.QuickSave() и игра сохранится. Важно не забывать отписываться от событий, иначе это может привести к null reference exception или утечке памяти. Такая реализация системы служит лишь примером. Использование строк, как меток событий очень багоопасно и неудобно. В широком смысле система событий — это общедоступный объект, чаще всего статический класс. У него есть метод подписки на определенные события и метод вызова этих событий. Все остальное — детали. В большинстве случаев методы подписки и вызова будут выглядеть примерно следующим образом: Рассмотрим самые популярные реализации, опираясь на эту схему. Один из самых простых вариантов это использовать в качестве тип_события строку или Enum. Строка однозначно хуже — мы можем опечататься и нам не поможет ни IDE, ни компилятор. Но проблема с передачей аргументов встает в обоих случаях. Чаще всего они передаются через params object[] args. И тут мы опять лишены подсказок IDE и компилятора. Этот метод использует обобщенные методы, что позволяет нам жестко задать аргументы. Этот способ как раз используется в нашем проекте. В нем мы опираемся на интерфейсы, которые реализует подписчик. Объяснение принципа его работы оставлю для следующей главы, здесь покажу лишь пример. В угоду понятности и краткости в выкладках ниже убраны некоторые детали. Без них система будет багоопасной, но для понимания основного принципа они не важны. Тем не менее мы рассмотрим их в разделе "Тонкости реализации". В нашем случае в качестве ключа выступает тип подписчика, а точнее интерфейсов, который этот тип реализует. Рассмотрим на примере быстрого сохранения. Создадим интерфейс, который будет выступать в роли ключа для такого события: Для того, чтобы интерфейс мог выступать ключом, он должен наследовать IGlobalSubscriber. Этот позволит системе отличать интерфейсы-ключи от всех остальных, скоро увидим как именно. Сам интерфейс IGlobalSubscriber не содержит никаких свойств и методов, он лишь метка. Теперь подписка и отписка будут выглядеть очень просто:  Посмотрим на код метода Subscribe. Все подписчики хранятся в словаре s_Subscribers. Ключом этого словаря является тип, а значением список подписчиков соответствующего типа. Метод GetSubscriberTypes будет описан чуть ниже. Он возвращает список типов интерфейсов-ключей, которые реализует подписчик. В нашем случае это будет список из одного элемента: IQiuckSaveHandler — хотя в реальности SaveLoadManager может реализовать несколько интерфейсов. Вот мы имеем список типов subscriberTypes. Теперь остается для каждого типа получить соответствующий список из словаря s_Subscribers и добавить туда нашего подписчика. А вот и реализация GetSubscribersTypes: Этот метод берет тип подписчика, берет у него список всех реализованных интерфейсов и оставляет среди них лишь те, которые в свою очередь реализуют IGlobalSubscriber. То есть делает ровно то, что и было заявлено. Итак, в качества ключей в EventBus выступают интерфейсы, которые реализует подписчик. Напомню, что мы все еще рассматриваем пример с быстрым сохранением. InputManager отслеживает нажатие на кнопку 'S', после чего вызывает событие быстрого сохранения. Вот как это будет выглядеть в нашей реализации: Давайте посмотрим на метод RaiseEvent: В нашем случае TSubscriber это IQiuckSaveHandler. IQiuckSaveHandler handler => handler.HandleQuickSave() это action, который мы применяем на всех подписчиков типа IQiuckSaveHandler. То есть в результате выполнения action вызовется метод HandleQuickSave и игра сохранится. Для краткости вместоIQiuckSaveHandler handler => handler.HandleQuickSave() C# позволяет писать h => h.HandleQuickSave(). Описание интерфейсов в итоге определяет события, которые мы можем вызывать. Интерфейс может реализовать более одного метода. Для нашего примера в реальности более логичным мог бы оказаться такой интерфейс: Таким образом мы подписываемся не по одному методу, а сразу группой методов, которые объединены в один интерфейс.  Также важно отметить, что передавать какие-либо параметры в такой реализации как никогда просто. Рассмотрим пример 1 из начала статьи про онлайн шутер. Работа системы событий могла бы выглядеть следующим образом. Интерфейсы позволяют очень гибко определять набор возможных событий и их сигнатуру. Как и обещал, рассмотрим некоторые технические детали, пропущенные в прошлом разделе. Код внутри любого из подписчиков может привести к ошибке. Чтобы это не оборвало цепочку вызовов, обнесем это место try catch: Функция GetSubscribersTypes работает при помощи рефлексии, а рефлексия всегда работает очень медленно. Мы не можем полностью избавиться от этих вызовов, но можем закешировать уже пройденные значения. Мы еще не описывали здесь метод отписки, но скорее всего он мог бы выглядеть как-то так: И такой метод будет работать в большинстве случаев. Но рано или поздно при вызове очередного события мы можем получить ошибку вида Collection was modified; enumeration operation might not execute. Такая ошибка возникает, если внутри прохода по какой-то коллекции при помощи foreach мы удалим элемент из этой коллекции. В нашем случае проблема возникает, если во время вызова события один из подписчиков отписывается. Для борьбы с этим мы во время отписки будем проверять, не проходимся ли мы сейчас по списку. Если нет, то просто удаляем, как и раньше. Но если проходимся, то обнулим этого подписчика в списке, а после прохода удалим из списка все null. Для реализации этого создадим обертку вокруг списка. Теперь обновим наш словарь в EventBus: После этого обновим метод вызова события RaiseEvent: В теории этой ситуации может и не возникнуть, но на практике рано или поздно это происходит. Вы можете заметить, что мы беспокоимся только об удалении из коллекции, но не думаем о добавлении во время прохода. Вообще было бы правильно и этот случай обработать, но на практике у нас этой проблемы ни разу не возникало. Но если возникнет у вас, вы уже будете знать в чем дело. Большинство систем событий похожи друг на друга. Они имеют в основе такую же подписку по ключу и реализованы внутри тоже при помощи словаря. Проблема удаления во время перебора для них тоже актуальна. Наше решение отличается использованием интерфейсов. Если немного задуматься, то использование интерфейсов в системе событий является очень логичным. Ведь интерфейсы изначально придуманы для определения возможностей объекта. В нашем случае речь идет о возможностях реагировать на те или иные события в игре. В дальнейшем систему можно развивать под конкретный проект. Например в нашей игре существуют подписки на события конкретного юнита. Еще на вызов и завершение какого-то механического события. Ссылка на репозиторий.
 Разработчик Unity