Менеджер событий для Unity

Эта реализация менеджера событий, и теперь эта заметка, появились на практике нескольких проектов. Это достаточно простая, лекговесная библиотека чтобы уменьшить связность между системами, объектами и сценами в своём проекте.

Менеджер событий или шина данных — паттерн проектирования 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

Нет комментариев

    Ваш комментарий