Эта реализация менеджера событий, и теперь эта заметка, появились на практике нескольких проектов. Это достаточно простая, лекговесная библиотека чтобы уменьшить связность между системами, объектами и сценами в своём проекте.
Менеджер событий или шина данных — паттерн проектирования pub-sub (публикация-подписка), который служит посредником между компонентами системы, позволяя им взаимодействовать, не зная друг друга напрямую.
Таких библиотек и скриптов очень много в открытом доступе. И много их потому что каждый разработчик решает в первую очередь свою проблему: скорость, удобство, универсальность, простота. Вот некоторые популярные:
Это мощные универсальные библиотеки, которые закрывают все задачи по реализации паттерна публикация-подписка.
Данная же библиотека очень простая, автономная и выполняющая только свою задачу, без зависимости от какой-то «основной» большой части.
Сразу простой пример:
using Shardy.Signals;
/// <summary>
/// Demo interface
/// </summary>
public interface IDemo : ISubscriber {
void OnSignalAction(int data);
}
/// <summary>
/// Demo class with sender/receiver
/// </summary>
public class Demo : MonoBehaviour, IDemo {
/// <summary>
/// Log tag
/// </summary>
public const string TAG = "DEMO";
/// <summary>
/// Destroy method name
/// </summary>
const string SEND_METHOD = "Send";
/// <summary>
/// Init and subscribe to signals
/// </summary>
void Awake() {
Signals.Subscribe(this);
InvokeRepeating(SEND_METHOD, 1f, 1f);
}
/// <summary>
/// Unsubscribe from signals
/// </summary>
void OnDestroy() {
Signals.Unsubscribe(this);
}
/// <summary>
/// Sends demo signals
/// Send int data -> current subscriber count
/// </summary>
void Send() {
Signals.Send<IDemo>(a => a.OnSignalAction(Signals.GetSubscriberCount<IDemo>()));
}
/// <summary>
/// Called when a signal is received
/// </summary>
public void OnSignalAction(int data) {
Debug.Log($"[{TAG}] action: {data}");
}
}
В общем-то, в примере выше есть почти все методы которые понадобятся для работы с библиотекой: интерфейс, подписка, отписка, пример отправки и получения. В данном случае отправитель и получатель — это один и тот же класс. В демо есть пример с несколькими подписчиками и разрушаемыми объектами.
Когда актуально использовать менеджер событий
- очевидно, когда нужно развязать системы
- одно событие важно для нескольких подсистем
- независимость UI и геймплея
- много временных динамических объектов
- надо сделать быстрый прототип
Это хорошо работает с пунктом №2, т.е. когда одно действие должно вызвать реакцию в разных подсистемах. Как пример, событие PlayerKilled. Что может происходить в этом случае:
- надо начислить очки
- обновить UI
- обновить статистику
- воспроизвести звук
Отправив одно сообщение, каждая подсистема связанная с игроком и подписанная на это событие, примет его и выполнит.
Вот как это можно реализовать:
/// <summary>
/// Player interface
/// </summary>
public interface IPlayer : ISubscriber {
void OnPlayerKilled(int level, int score);
...
}
/// <summary>
/// Player class on character object
/// </summary>
public class Player {
/// <summary>
/// Current player level
/// </summary>
int _level = 3;
/// <summary>
/// Current player score
/// </summary>
int _score = 120;
/// <summary>
/// Called when take damage
/// </summary>
public void OnDamage(int health) {
if (health <= 0) {
Signals.Send<IPlayer>(action => action.OnPlayerKilled(_level, _score));
}
}
}
/// <summary>
/// UI
/// </summary>
public class UI: IPlayer {
/// <summary>
/// Subscribe to signals
/// </summary>
void Awake() {
Signals.Subscribe(this);
}
/// <summary>
/// Called when a signal is received
/// </summary>
public void OnPlayerKilled(int level, int score) {
TMP.SetText("Player killed on {0} level with {1} score", level, score);
}
}
/// <summary>
/// Sounds
/// </summary>
public class SoundManager: IPlayer {
/// <summary>
/// Subscribe to signals
/// </summary>
void Awake() {
Signals.Subscribe(this);
}
/// <summary>
/// Called when a signal is received
/// </summary>
public void OnPlayerKilled(int level, int score) {
Audio.Play($"killed_sound_{level}");
}
}
/// <summary>
/// Stats
/// </summary>
public class Stats: IPlayer {
/// <summary>
/// Subscribe to signals
/// </summary>
void Awake() {
Signals.Subscribe(this);
}
/// <summary>
/// Called when a signal is received
/// </summary>
public void OnPlayerKilled(int level, int score) {
DB.SendStat("killed", level, score);
}
}
Тут конечно всё упрощено, но смысл думаю понятен:
- есть классы которые реализуют интерфейс и подписаны на событие
- игрок получает дамаг
- когда здоровье падает до нуля, то отправляется событие
- каждая система это событие получает и по своему обрабатывает
Особенности реализации
Данная реализация поддерживает WeakReference, это означает что если получатель при отправке события уже удалён, то ошибки не будет.
После каждой отправки проверяется есть ли «мертвые» получатели. И если такие имеются, то они автоматически удаляются из списка на отправку.
WeakReference — это специальный контейнер, который хранит ссылку на объект, но не мешает сборщику мусора его уничтожить. Часто применяется в кеширования, в менеджерах событий 😅 ну и чтобы избежать утечек памяти.
Если бы этот пакет был не для использования в Unity, то этой проверки можно было бы избежать. А так есть нюанс, как в старом анекдоте…
В Unity уничтожение объекта не значит что GC его сразу убрал. UnityEngine.Object имеют кастомное поведение:
obj == nullвозвращает true, хотя в памяти объект ещё естьReferenceEquals(obj, null)вернёт false
Поэтому при работе с WeakReference в Unity проверка должна быть двойная:
/// <summary>
/// Check if object is alive, check custom null behavior
/// </summary>
bool IsAlive(object target) {
if (target == null) {
return false;
}
if (target is UnityEngine.Object obj && obj == null) {
return false;
}
return true;
}
Когда не стоит использовать
- когда связь «один к одному»
- очень частые события
Update,FixedUpdateи другие перегрузят систему, тут лучше прямые вызовы - есть критическая зависимость: лучше передать ссылку напрямую, а не надеятся, что «кто-то подпишется»
Иногда, попробовав такой метод коммуникации между объектами и системами, возникает соблазн слать события на всё подряд. Но это фатальная ошибка 😬 Если игра (проект) более-менее крупная, а не прототип, то нужен строгий контроль или использование других паттернов, например ECS.
В уменьшении связности и использовании менеджера событий есть и свои минусы: сложнее тестировать, сложнее вычислить порядок действий как получилось текущее состояние, в общем, использовать его рекомендуется не повсеместно.
Документации нет, всё поместилось в README файле. Добавил этот модуль как часть Shardy, но использовать его конечно можно и отдельно.
Все исходники доступны на Github
Нет комментариев