Паттерн «Наблюдатель»
Паттерн «Наблюдатель»
При разработке игры нескольким объектам GameObject часто требуется обмениваться данными или состояниями. В небольшой игре между ними можно создать прямые ссылки, но при масштабировании этот подход быстро становится неудобным. Управление такими зависимостями требует значительных усилий и нередко приводит к ошибкам. По мере роста приложения потребуется более эффективное . решение.
Отказ от синглтонов Многие разработчики выбирают синглтон, то есть единственный глобальный экземпляр класса, который сохраняется при загрузке сцен. Однако синглтоны создают глобальное состояние и затрудняют модульное тестирование. Если префаб ссылается на синглтон, для проверки одной изолированной функции придется подключить все зависимости этого синглтона. Это снижает модульность, а отладка кода усложняется. В качестве альтернативы для обмена данными между объектами можно использовать события на основе ScriptableObject.
Подробнее о синглтонах Синглтоны в разработке игр на Unity часто вызывают споры. Они могут быть подходящим решением для небольших проектов или прототипов, но в крупных приложениях недостатки обычно перевешивают преимущества. Поэтому многие разработчики считают синглтон антипаттерном.
Синглтоны легко изучить и понять, однако при неправильном применении они создают проблемы. Большинство описанных здесь паттернов помогает обходиться без синглтонов. Если нужен простой доступ к общим данным, рассмотрите Runtime Set на основе ScriptableObject, описанный далее. Для обмена сообщениями между объектами подойдет канал событий на основе ScriptableObject. Переход от синглтонов к такой архитектуре может повысить масштабируемость и тестируемость проекта.
Подробнее о преимуществах и недостатках синглтонов см. в электронной книге «Повышаем уровень кода с паттернами проектирования и SOLID».
События на основе ScriptableObject Как вы уже видели, ScriptableObject предназначен не только для работы с данными. Как и любой другой скрипт, он может содержать методы, через которые объекты будут обмениваться сообщениями. В паттерне «Наблюдатель» субъект передает сообщение одному или нескольким слабо связанным наблюдателям. Каждый наблюдатель реагирует независимо от субъекта и ничего не знает об остальных наблюдателях. Субъект также называют издателем или отправителем, а наблюдателей - подписчиками или слушателями.
Архитектура на основе событий выполняет код только при необходимости, а не в каждом кадре. Поэтому она часто эффективнее логики, добавленной в методы обновления MonoBehaviour. Подписчики Наблюдатель
Событие
Издатель
Субъект
Наблюдатель
Наблюдатель
Базовый паттерн «Наблюдатель»
Паттерн «Наблюдатель» можно реализовать с помощью MonoBehaviour или объектов C#. Такой подход широко применяется в Unity, но при использовании одних скриптов дизайнеры будут обращаться к программистам для создания каждого события игрового процесса. Альтернативой служат события на основе ScriptableObject. Это удобный для дизайнеров способ настроить паттерн «Наблюдатель»: ScriptableObject выступает посредником между субъектом и наблюдателем и предоставляет графический интерфейс в редакторе Unity.
Подписчики Наблюдатель
Канал событий
Издатель
Субъект
Событие
Событие
Наблюдатель
ScriptableObject Наблюдатель
Канал событий на основе ScriptableObject
На первый взгляд кажется, что в паттерне «Наблюдатель» появился лишь дополнительный служебный слой, однако такая структура дает ряд преимуществ. ScriptableObject является ассетом, поэтому доступен всем объектам в иерархии сцены и не исчезает при загрузке другой сцены. Именно ради простого постоянного доступа к определенным ресурсам разработчики часто используют синглтоны. ScriptableObject нередко дает те же преимущества, не создавая столько лишних зависимостей. В событиях на основе ScriptableObject издателем, который отправляет событие, и подписчиком, который его принимает, может быть любой объект. ScriptableObject находится между ними, передает сигнал и выступает единым посредником.
Такую структуру удобно представить как канал событий. ScriptableObject подобен радиовышке, сигналы которой принимает любое количество объектов. Заинтересованный MonoBehaviour подписывается на канал событий и реагирует, когда что-либо происходит.
Пример: каналы событий Любой ScriptableObject со следующими элементами может работать как канал событий: - Делегат UnityAction или System.Action: он уведомляет подписчиков и передает нужные данные в параметрах. UnityAction удобнее для художников и дизайнеров; в остальных случаях хорошо подходит System.Action. Ключевое слово event ограничивает делегат: вызвать его можно только из класса ScriptableObject, в котором он объявлен, или из производного класса. - Метод отправки события: этот открытый метод вызывает делегат. Вот и все. Можно создать любое количество каналов событий для управления разными аспектами игрового процесса. Объекты ScriptableObject существуют на уровне проекта, поэтому могут отправлять глобально доступные события. Так можно связывать даже не связанные напрямую объекты сцены без ущерба для масштабируемости.
System.Action или UnityAction System.Action - универсальный тип делегата, определенный в пространстве имен System платформы .NET Framework. Его можно использовать в проектах Unity без объявления собственного делегата. Ключевое слово event делает делегат доступным только для чтения: другие объекты могут подписываться на него, регистрируя свои методы, но не могут вызывать делегат напрямую.
UnityAction - тип делегата, определенный специально в пространстве имен UnityEngine.Events. Обычно он применяется вместе с классом UnityEvent, который предоставляет еще один способ создавать события в Unity. UnityEvent и UnityAction отображаются в окне Inspector, поэтому с их помощью часто удобнее реализовывать паттерн «Наблюдатель» для пользователей, художников и дизайнеров.
В зависимости от задачи можно использовать System.Action, UnityAction или оба типа в одном проекте. Если требуется универсальный делегат, не привязанный к игровому движку Unity, используйте System.Action. Если делегат предназначен специально для UnityEvent, выберите UnityAction.
В этом примере VoidEventChannelSO отправляет событие без параметров. Он содержит UnityAction с именем OnEventRaised.
[CreateAssetMenu(menuName = "Events/Void Event Channel")] public class VoidEventChannelSO : ScriptableObject {
public event UnityAction OnEventRaised;
public void RaiseEvent() {if (OnEventRaised != null)
OnEventRaised.Invoke();
}
}После создания ScriptableObject типа VoidEventChannelSO любой MonoBehaviour сможет подписаться на OnEventRaised. Например, можно создать ScriptableObject StartNewGame типа VoidEventChannelSO. Другой объект может вызвать открытый метод RaiseEvent и тем самым отправить событие.
Событие на основе ScriptableObject: пример канала событий
Другой MonoBehaviour может сослаться на ScriptableObject канала событий в окне Inspector, а затем подписаться на OnEventRaised или отменить подписку.
Когда канал событий вызывает OnEventRaised, в ответ выполняется StartNewGame: public class StartGame : MonoBehaviour { [SerializeField] private VoidEventChannelSO m_onNewGameButton = default;
private void Start() {m_onNewGameButton.OnEventRaised += StartNewGame;
private void OnDestroy() {m_onNewGameButton.OnEventRaised -= StartNewGame;
private void StartNewGame() {
// здесь размещается логика загрузки уровня...
}
}
Чтобы сделать компоненты-слушатели удобнее для художников и дизайнеров, можно создать MonoBehaviour, не требующий дополнительной настройки скриптом. Класс VoidEventListener не добавляет новых функций, но предоставляет поля, доступные в окне Inspector:
public class VoidEventListener : MonoBehaviour {
[SerializeField] private VoidEventChannelSO m_channel = default;public UnityEvent OnEventRaised;private void OnEnable() {if (m_channel != null)
m_channel.OnEventRaised += Respond; }
private void OnDisable() {if (m_channel != null)
m_channel.OnEventRaised -= Respond; }
private void Respond() {if (OnEventRaised != null)
OnEventRaised.Invoke();
}
}Добавьте VoidEventListener на GameObject, затем перетащите ScriptableObject канала событий в поле _channel окна Inspector. Чтобы реагировать на событие, добавьте в OnRaisedEvent нужные обработчики UnityAction.
Слушатель событий позволяет специалисту без навыков программирования настраивать действия, запускаемые событиями.
Независимо от того, какой компонент выбран в качестве слушателя, каналы событий обеспечивают взаимодействие между объектами во время выполнения. Игрок выполнил задание или заработал очко? Игра завершилась? Событие может уведомить любой GameObject в сцене, которому нужна эта информация.
Поскольку такие события на основе ScriptableObject являются ассетами уровня проекта, они могут управлять значительной частью инфраструктуры приложения. Это особенно удобно для обмена сообщениями между системами, лежащими в основе архитектуры игры.
Распространенные системы управления: - Управление звуком: самые разные события в игре могут запускать звуки. Эта система может воспроизводить объекты AudioClip или изменять параметры AudioMixer в ответ на события приложения.
- Управление сценами: эта система отвечает за загрузку и выгрузку сцен Unity и игровых уровней. - Управление UI: эта система отвечает за экраны меню до начала игры, во время нее и после завершения. - Управление сохранениями: эта система отвечает за сохранение игровых данных и настроек в файловой системе, а также за их загрузку. Все эти системы решают разные задачи, но им необходимо взаимодействовать. События служат связующим звеном между ними. Дополнительные примеры реализации собственных событий на основе ScriptableObject приведены в сопутствующем примере проекта.
GameManager из примера проекта использует каналы событий.
События с разной полезной нагрузкой позволяют передавать данные разных типов. Например, среди событий на основе ScriptableObject есть IntEventChannelSO, Vector2EventChannelSO, VoidEventChannelsSO и другие. Выбор события зависит от контекста.
Создавайте дополнительные типы событий с учетом игровой логики. Например, событию нанесения урона могут понадобиться сведения о том, кто нанес урон и какова его величина.
Варианты применения каналов событий ограничены только вашей фантазией. Помимо перечисленных основных систем, события нередко помогают связать совершенно разные игровые подсистемы и наладить их взаимодействие:
- Камеры: позволяют создавать выразительные и кинематографичные эффекты, например тряску камеры или переход к другому ракурсу. - Задания: задачи или цели, которые игрок должен выполнить, чтобы продвинуться по игре или получить награду. Задания часто объединяют разные элементы игрового процесса, например сбор предметов, победу над противниками или решение головоломок. - Здоровье: этот важный элемент многих игр связывает игрока, противников, а также любые объекты и действия, способные нанести игроку урон. - Достижения: как и задания, это особые награды, которые игроки открывают за выполнение определенных задач или целей. Достижения могут охватывать разные элементы игрового процесса, например достижение заданного уровня или накопление определенного количества очков. В свою очередь, эти элементы игрового процесса взаимодействуют через события с другими системами управления, включая звук, UI и сохранения. Такой подход обеспечивает модульность и независимость компонентов архитектуры, сохраняя при этом возможность обмениваться данными с другими системами.
Отладка каналов событий Пользовательский Editor или PropertyDrawer может добавить в окно Inspector кнопку «Вызвать событие». С ее помощью событие можно запускать вручную при отладке. Ниже приведен простой скрипт Editor, который добавляет в окно Inspector специальную кнопку для VoidEventChannelSO: [CustomEditor(typeof(VoidEventChannelSO))]
public class VoidEventChannelSOEditor : Editor {
public override void OnInspectorGUI() {
DrawDefaultInspector();
VoidEventChannelSO eventChannel = (VoidEventChannelSO)target;
if (GUILayout.Button("Raise Event")) { eventChannel.RaiseEvent(); }
}
}В результате появляется кнопка, с помощью которой событие можно вызвать в любой момент, что упрощает поиск проблем во время выполнения.
Кнопка, добавленная пользовательским Editor, помогает тестировать каналы событий.
Добавив немного кода, можно создать кнопки и для каналов событий, передающих данные. Предусмотрите в самом канале поле для отладочного значения, а затем передайте его в скрипт Editor. Примеры такой реализации есть в проектах SOAP и Unity Atoms.
По мере использования каналов событий для разделения зависимостей между объектами стоит развивать средства отладки, например вести список всех слушателей каждого события. В класс канала событий можно добавить методы регистрации и отмены регистрации объектов, чтобы во время выполнения было проще определить, какие события вызывают конкретное поведение.
Пример: InputReader Объектам, которые обрабатывают пользовательский ввод, нужен специализированный тип канала событий. Input System в Unity использует InputAction, чтобы представлять необработанные входные данные в виде логических действий, например прыжка или ходьбы.
Каждый InputAction, в свою очередь, содержит собственные события started, performed и canceled.
Настройка действий (Actions) и карт действий (Action Maps) в редакторе Input System
Для обработки привязок InputAction можно создать специальный ScriptableObject InputReader. Он снова выступает посредником между субъектом и наблюдателями. Однако в этом случае компоненты MonoBehaviour не вызывают события напрямую. Вместо этого роль субъекта, то есть отправителя событий, выполняет Input System:
Наблюдатель
Ресурс Game Input
Действие Move
Input Reader
Событие OnMove
Наблюдатель
ScriptableObject
Unity Input System
Наблюдатель
MonoBehaviour
ScriptableObject InputReader ретранслирует события от объектов InputAction.
Здесь в Input System настраиваются действия (Actions) и карты действий (Action Maps). Каждый InputAction описывает отдельную ось ввода и связывается с клавиатурой, геймпадом или любым другим устройством ввода.
Вместо прямой подписки на InputAction контроллеры ракеток подписываются соответственно на события OnMoveP1.performed и OnMoveP2.performed.
Геймпад
Изменения Input System затрагивают только InputReader
Ресурс Game Input
Input Actions
Input Reader
Событие OnMoveP1
Событие OnMoveP2
Сенсорный экран ScriptableObject
Событие OnMove
Unity Input System
Клавиатура
Ввод
MonoBehaviour
InputReader изолирует объекты от прямой зависимости от системы ввода.
В результате InputReader задает единый способ обработки действий с геймпада и клавиатуры. Каждый GameObject, которому нужен ввод: - хранит ссылку на ScriptableObject InputReader; - подписывается на нужные события и привязывает к ним свои методы-обработчики. Для небольшой игры такой паттерн может показаться избыточным, но этот пример помогает лучше понять принцип его работы. Преимущества станут очевидны, когда проект разрастется и в нем появится гораздо больше компонентов. Отделение ввода от использующих его GameObject делает систему гибче и упрощает повторное использование компонентов. Если во время разработки потребуется изменить InputAction, достаточно сопровождать только InputReader. Если сами события менять не требуется, объекты-слушатели не затрагиваются. Поэтому поддерживать связь между вводом и наблюдателями становится проще, особенно если наблюдателей много.
Статические и нестатические события Можно использовать статические события, чтобы слушающим объектам не приходилось искать ScriptableObject. Например, в методе OnEnable компонент MonoBehaviour может подписаться на статические события MoveP1Event и MoveP2Event класса InputReader: InputReader.MoveP1Event += OnMoveP1;
InputReader.MoveP2Event += OnMoveP2;
При работе со статическими событиями особенно тщательно управляйте подписками. Не забудьте отписаться в OnDisable: InputReader.MoveP1Event -= OnMoveP1;
InputReader.MoveP2Event -= OnMoveP2;
Статические события всегда остаются доступными и при наличии активных подписчиков не освобождаются сборщиком мусора. Забытые подписчики будут препятствовать очистке до завершения работы приложения. Однако статические события не сериализуются. Если нужна интерактивная работа в редакторе Unity, используйте нестатические события и укажите в окне Inspector ссылку на соответствующий ScriptableObject.