Реализовав в своем проекте Unity общие паттерны проектирования игрового программирования, вы сможете эффективно создавать и поддерживать чистую, организованную и читаемую кодовую базу. Паттерны проектирования не только сокращают время на рефакторинг и тестирование, но и ускоряют процессы внедрения и разработки, способствуя созданию прочного фундамента для развития вашей игры, команды разработчиков и бизнеса.
Воспринимайте паттерны проектирования не как готовые решения, которые можно скопировать и вставить в код, а как дополнительные инструменты, которые при правильном использовании помогут вам создавать более крупные и масштабируемые приложения.
Эта страница объясняет паттерн наблюдателя и то, как он может помочь поддержать принцип свободной связи между объектами, которые взаимодействуют друг с другом.
Содержание этой статьи основано на бесплатной электронной книге, Повысьте уровень своего кода с помощью паттернов программирования игрв которой рассказывается о хорошо известных паттернах проектирования и приводятся практические примеры их использования в вашем проекте Unity.
Другие статьи из цикла "Шаблоны проектирования игрового программирования Unity" доступны на хабе " Лучшие практики Unity " или по следующим ссылкам:
Во время выполнения в игре может произойти множество событий. Что происходит, когда игрок уничтожает врага? Или когда они получают прибавку к силе или уровню? Часто требуется механизм, позволяющий одним объектам уведомлять другие без прямой ссылки на них. К сожалению, по мере увеличения кодовой базы появляются ненужные зависимости, которые могут привести к негибкости и лишним затратам на обслуживание кода.
Шаблон наблюдателя - распространенное решение этой проблемы. Он позволяет вашим объектам взаимодействовать, но при этом оставаться слабо связанными друг с другом, используя зависимость "один-ко-многим". Когда один объект меняет состояние, все зависимые объекты получают автоматическое уведомление.
В качестве аналогии можно привести радиовышку, которая вещает на множество разных слушателей. Ему не нужно знать, кто настраивается, достаточно знать, что трансляция идет в прямом эфире на нужной частоте в нужное время.
Объект, который транслируется, называется субъектом. Другие объекты, которые слушают, называются наблюдателями или иногда подписчиками (на этой странице используется название observer).
Преимущество паттерна в том, что он отделяет объект от наблюдателя, который не знает наблюдателей и не заботится о том, что они делают после получения сигнала. В то время как наблюдатели зависят от субъекта, сами наблюдатели не знают друг о друге.
Наблюдатели просто получают уведомления об изменении состояния субъекта, что позволяет им соответствующим образом обновиться. Таким образом, становится проще модифицировать или расширять код, не затрагивая другие части системы.
Еще одно преимущество заключается в том, что паттерн наблюдателя способствует разработке многократно используемого кода, поскольку наблюдатели могут быть повторно использованы в различных контекстах без необходимости внесения изменений. Наконец, это часто улучшает читаемость кода, поскольку зависимости между объектами четко определены.
Вы можете разработать собственные классы субъекта-наблюдателя, но это обычно не нужно, поскольку C# уже реализует этот паттерн с помощью событий. Паттерн наблюдателя настолько распространен, что встроен в язык C#, и не зря: Это поможет вам создать более модульный, многократно используемый и поддерживаемый код.
Что такое событие? Это уведомление, которое указывает на то, что что-то произошло, и включает в себя несколько шагов:
Издатель (также известный как субъект) создает событие на основе делегата, устанавливающего определенную сигнатуру функции. Событие - это просто некоторое действие, которое субъект выполнит во время выполнения (например, получит урон, нажмет на кнопку и так далее). Издатель ведет список своих зависимых объектов (наблюдателей) и отправляет им уведомления об изменении своего состояния, которое представлено этим событием.
Затем каждый из наблюдателей создает метод, называемый обработчиком события, который должен соответствовать сигнатуре делегата. Наблюдатели - это объекты, которые получают уведомления от издателя и соответствующим образом обновляются.
Обработчик событий каждого наблюдателя подписывается на событие издателя. К подписке может присоединиться столько наблюдателей, сколько необходимо. Все они будут ждать наступления события.
Когда издатель сигнализирует о наступлении события во время выполнения, это называется поднятием события. Это, в свою очередь, вызывает обработчики событий наблюдателей, которые в ответ запускают свою собственную внутреннюю логику.
Таким образом, вы заставляете многие компоненты реагировать на одно событие от субъекта. Если испытуемый указывает на нажатие кнопки, наблюдатели могут воспроизвести анимацию или звук, запустить сцену или сохранить файл. Их ответ может быть любым, поэтому для передачи сообщений между объектами часто используется паттерн наблюдателя.
Делегаты против событий
Делегат - это тип, определяющий сигнатуру метода. Это позволяет передавать методы в качестве аргументов другим методам. Думайте об этом как о переменной, в которой вместо значения хранится ссылка на метод.
С другой стороны, событие - это, по сути, особый тип делегата, который позволяет классам взаимодействовать друг с другом в свободной связке. Общие сведения о различиях между делегатами и событиями см. в разделе Различие делегатов и событий в C#.
Давайте посмотрим, как можно определить базовую тему/издателя в приведенном ниже коде.
В классе Subject в приведенном ниже примере кода вы наследуете от MonoBehaviour, чтобы легче было прикрепить его к объекту GameObject, хотя это и не является обязательным условием.
Хотя вы можете определить свой собственный делегат, вы также можете использовать System.Action, который работает в большинстве случаев. В примере кода нет необходимости передавать параметры вместе с событием, но если бы это было необходимо, то можно просто использовать делегат Action<T> и передать их в виде списка List<T> в угловых скобках (до 16 параметров).
В фрагменте кода ThingHappened - это фактическое событие, которое субъект вызывает в методе DoThing.
Оператор "?" является оператором с условием null, что означает, что событие будет вызвано только в том случае, если оно не равно null. Метод Invoke используется для поднятия события, что означает, что он выполнит все обработчики событий, которые подписаны на это событие. В этом случае метод DoThing вызовет событие ThingHappened, если оно не равно null, что приведет к выполнению всех обработчиков событий, подписанных на это событие.
Вы можете загрузить пример проекта, который демонстрирует наблюдателя и другие паттерны проектирования в действии. Этот пример кода доступен здесь.
Чтобы прослушать событие, вы можете создать пример класса Observer, как в приведенном ниже примере с сокращенным кодом (также доступен в проекте Github).
Прикрепите этот скрипт к объекту GameObject в качестве компонента и ссылайтесь на subjectToObserver в инспекторе, чтобы прослушивать событие ThingHappened.
Метод OnThingHappened может содержать любую логику, которую наблюдатель выполняет в ответ на событие. Часто разработчики добавляют префикс "On", чтобы обозначить обработчик события (используйте соглашение об именовании из вашего руководства по стилю).
В режиме Awake или Start можно подписаться на событие с помощью оператора +=. Это объединяет метод OnThingHappened наблюдателя и ThingHappened субъекта.
Если что-то запускает метод DoThing субъекта, это вызывает событие. Затем автоматически вызывается обработчик события OnThingHappened наблюдателя и печатает отладочный отчет.
Примечание: Если вы удалите или снимите наблюдателя во время выполнения, когда он все еще подписан на ThingHappened, вызов этого события может привести к ошибке. Таким образом, важно отписываться от события в методе OnDestroy в MonoBehaviour с помощью оператора -= в соответствующие моменты жизненного цикла объекта.
Если вы загрузите пример проекта и перейдете в папку с именем 11 Observer, то найдете пример, который показывает простую кнопку (ExampleSubject) и динамик (AudioObserver), анимацию (AnimObserver) и эффект частиц (ParticleSystemObserver).
Когда вы нажимаете на кнопку, ExampleSubject вызывает событие ThingHappened. В ответ на это AudioObserver, AnimObserver и ParticleSystemObserver вызывают свои методы обработки событий.
Наблюдатели могут существовать на одном или разных игровых объектах. Обратите внимание, что AnimObserver производит анимацию кнопки на ExampleSubject, в то время как AudioObservers и ParticleSystemObserver занимают разные GameObject'ы.
ButtonSubject позволяет пользователю вызывать событие Clicked с помощью кнопки мыши. Несколько других игровых объектов с компонентами AudioObserver и ParticleSystemObserver могут по-своему реагировать на это событие.
Определение того, какой объект является субъектом, а какой - наблюдателем, зависит только от употребления. Все, что вызывает событие, выступает в качестве субъекта, а все, что реагирует на событие, - в качестве наблюдателя. Различные компоненты одного и того же игрового объекта могут быть субъектами или наблюдателями. Даже один и тот же компонент может быть субъектом в одном контексте и наблюдателем в другом.
Например, AnimObserver в примере добавляет небольшое движение к кнопке при нажатии. Он действует как наблюдатель, хотя и является частью игрового объекта ButtonSubject.
Unity также включает в себя отдельную систему UnityEventsкоторая использует UnityAction делегат из UnityEngine.Events API. Его можно настроить в Инспекторе (предоставляющем графический интерфейс для паттерна наблюдателя), что позволяет разработчикам указать, какие методы должны быть вызваны при возникновении события.
Если вы пользовались системой пользовательского интерфейса Unity (например, создавали событие OnClick для кнопки пользовательского интерфейса), то у вас уже есть некоторый опыт в этом.
На изображении выше событие OnClick кнопки вызывает и вызывает ответ от методов OnThingHappened двух AudioObservers. Таким образом, вы можете настроить событие субъекта без кода.
UnityEvents пригодится, если вы хотите позволить дизайнерам или непрограммистам создавать события игрового процесса. Однако имейте в виду, что они могут работать медленнее, чем аналогичные события или действия из пространства имен System. UnityActions также имеет дополнительное преимущество: они могут использоваться для вызова методов, принимающих аргументы, в то время как UnityEvents ограничены методами, не имеющими аргументов.
При выборе UnityEvents и UnityActions взвесьте соотношение производительности и использования. UnityEvents проще и легче в использовании, но более ограничен в типах методов, которые могут быть вызваны. Кто-то может также возразить, что, раскрывая все события в инспекторе, они могут быть более подвержены ошибкам.
Пример смотрите в модуле " Создание простой системы обмена сообщениями с помощью событий " на Unity Learn.
Реализация событий требует дополнительной работы, но имеет свои преимущества:
Шаблон наблюдателя помогает разделить объекты: Издателю события не нужно ничего знать о самих подписчиках события. Вместо того чтобы создавать прямую зависимость между одним классом и другим, субъект и наблюдатель общаются, сохраняя определенную степень разделения (loose-coupling).
Вам не нужно его строить: В C# уже существует система событий, и вы можете использовать делегат System.Action вместо того, чтобы определять свои собственные делегаты. Кроме того, Unity также включает в себя UnityEvents и UnityActions.
Каждый наблюдатель реализует свою собственную логику обработки событий: Таким образом, каждый наблюдающий объект сохраняет логику, необходимую ему для ответа. Это облегчает отладку и модульное тестирование.
Он хорошо подходит для пользовательского интерфейса: Ваш основной код геймплея может жить отдельно от логики пользовательского интерфейса. Затем ваши элементы пользовательского интерфейса прослушивают определенные игровые события или условия и реагируют соответствующим образом. В паттернах MVP и MVC для этой цели используется паттерн наблюдателя.
Однако следует помнить и об этих предостережениях:
Это создает дополнительные сложности: Как и другие паттерны, создание архитектуры, управляемой событиями, требует дополнительной настройки на начальном этапе. Также будьте осторожны, удаляя субъектов или наблюдателей. Убедитесь, что вы снимаете регистрацию наблюдателей в OnDestroy, чтобы ссылка на память была правильно освобождена, когда наблюдатель больше не нужен.
Наблюдателям нужна ссылка на класс, определяющий событие: Наблюдатели по-прежнему зависят от класса, который публикует событие. Использование статического EventManager (см. следующий раздел), который обрабатывает все события, может помочь отделить объекты друг от друга
Производительность может быть проблемой: Событийно-ориентированная архитектура создает дополнительные накладные расходы. Большие сцены и множество GameObject'ов могут снижать производительность.
Хотя здесь представлена только базовая версия шаблона наблюдателя, вы можете расширить ее, чтобы удовлетворить все потребности вашего игрового приложения.
При настройке схемы наблюдателя учитывайте эти рекомендации:
Используйте класс ObservableCollection: C# предоставляет динамическую ObservableCollection для отслеживания конкретных изменений. Он может уведомлять наблюдателей о добавлении, удалении элементов или обновлении списка.
Передайте уникальный идентификатор экземпляра в качестве аргумента: Каждый GameObject в иерархии имеет уникальный ID экземпляра. Если вы запускаете событие, которое может относиться к нескольким наблюдателям, передайте уникальный ID в событие (используйте тип Action<int>). Затем запустите логику в обработчике события, только если GameObject соответствует уникальному ID.
Создайте статический EventManager: Поскольку события могут определять большую часть игрового процесса, во многих приложениях Unity используется статический или однопользовательский EventManager. Таким образом, ваши наблюдатели смогут ссылаться на центральный источник игровых событий в качестве субъекта, что упростит настройку.
В микроигре FPS есть хорошая реализация статического менеджера событий, который реализует пользовательские события GameEvents и включает статические вспомогательные методы для добавления или удаления слушателей.
Открытый проект Unity также демонстрирует игровую архитектуру, в которой объекты ScriptableObjects передают UnityEvents. Он использует события для воспроизведения звука или загрузки новых сцен.
Создайте очередь событий: Если в вашей сцене много объектов, возможно, вы не захотите поднимать события все сразу. Представьте себе какофонию тысячи объектов, воспроизводящих звуки, когда вы вызываете одно событие. Сочетание паттерна наблюдателя с паттерном команды позволяет вам инкапсулировать события в очередь событий. Затем вы можете использовать командный буфер для воспроизведения событий по одному или выборочно игнорировать их по мере необходимости (например, если у вас есть максимальное количество объектов, которые могут издавать звуки одновременно).
Паттерн наблюдателя в значительной степени является частью архитектурного паттерна Model View Presenter (MVP), который рассматривается в электронной книге Повысьте уровень своего кода с помощью паттернов программирования игр.
Еще больше советов по использованию паттернов проектирования в приложениях Unity, а также принципов SOLID вы найдете в бесплатной электронной книге Повысьте уровень своего кода с помощью паттернов игрового программирования.
Все передовые технические электронные книги и статьи Unity доступны на хабе лучших практик. Электронные книги также доступны на странице " Передовые методы" в документации.