21.10.2026 16 мин чтения Просмотров:0

Реактивные свойства в Unity: ReactiveProperty своими руками и R3

Как убрать опрос значений из Update и связать данные игры с интерфейсом: пишем свой ReactiveProperty на C#, а затем переходим на R3 от Cysharp с производными свойствами, CombineLatest и автоматической отпиской через AddTo.

Реактивные свойства в Unity: ReactiveProperty своими руками и R3

Из этой статьи вы узнаете, как перестать опрашивать значения в Update и связывать данные игры с интерфейсом через реактивные свойства. Сначала напишем минимальный ReactiveProperty<T> на чистом C# — около сорока строк, без зависимостей, — разберём, где он заканчивается, а потом перейдём на библиотеку R3 от Cysharp: производные значения, CombineLatest, управление подписками через AddTo и типичные ошибки, из-за которых реактивный код течёт памятью или уходит в бесконечный цикл.

Проблема: интерфейс, который спрашивает каждый кадр

Самый частый способ вывести здоровье игрока на экран выглядит так:

public class HealthLabel : MonoBehaviour
{
    [SerializeField] private Player player;
    [SerializeField] private TMP_Text label;

    private void Update()
    {
        label.text = $"HP: {player.Health}";
    }
}

Код работает, но у него три недостатка. Во-первых, строка собирается и текст перестраивается каждый кадр, хотя здоровье меняется раз в несколько секунд: это и аллокации, и лишняя перестройка меша TextMeshPro. Во-вторых, метка знает, где искать данные, и зависит от конкретного класса Player. В-третьих, таких «опросчиков» в проекте становятся десятки: полоска здоровья, иконка низкого HP, звук сердцебиения, виньетка — и каждый в своём Update сравнивает текущее значение с прошлым.

Классический ответ — событие OnHealthChanged. Это уже лучше, но быстро появляется рутина: в каждом сеттере нужно не забыть вызвать событие, проверить, что значение действительно изменилось, а подписчику при старте отдельно прочитать начальное значение, потому что событие придёт только при следующем изменении. Реактивное свойство упаковывает всё это в один тип: значение плюс уведомление об его изменении плюс выдача текущего значения при подписке.

Минимальный ReactiveProperty своими руками

Начнём с собственной реализации. Она полезна даже если вы потом возьмёте библиотеку: становится понятно, что происходит внутри, и в маленьком проекте её может оказаться достаточно.

using System;
using System.Collections.Generic;

public interface IReadOnlyReactiveProperty<T>
{
    T Value { get; }
    IDisposable Subscribe(Action<T> onChanged);
}

public sealed class ReactiveProperty<T> : IReadOnlyReactiveProperty<T>
{
    private T _value;
    private readonly List<Action<T>> _handlers = new();
    private readonly IEqualityComparer<T> _comparer;

    public ReactiveProperty(T initial = default, IEqualityComparer<T> comparer = null)
    {
        _value = initial;
        _comparer = comparer ?? EqualityComparer<T>.Default;
    }

    public T Value
    {
        get => _value;
        set
        {
            if (_comparer.Equals(_value, value)) return;
            _value = value;
            Notify();
        }
    }

    public void ForceNotify() => Notify();

    public IDisposable Subscribe(Action<T> onChanged)
    {
        _handlers.Add(onChanged);
        onChanged(_value); // подписчик сразу получает текущее значение
        return new Subscription(this, onChanged);
    }

    private void Notify()
    {
        // копия списка: обработчик может отписаться прямо во время рассылки
        var snapshot = _handlers.ToArray();
        foreach (var handler in snapshot)
            handler(_value);
    }

    private sealed class Subscription : IDisposable
    {
        private ReactiveProperty<T> _owner;
        private readonly Action<T> _handler;

        public Subscription(ReactiveProperty<T> owner, Action<T> handler)
        {
            _owner = owner;
            _handler = handler;
        }

        public void Dispose()
        {
            _owner?._handlers.Remove(_handler);
            _owner = null;
        }
    }
}

Несколько решений здесь неслучайны.

  • Сравнение через EqualityComparer<T>.Default, а не через == или object.Equals. Для структур вроде int и Vector3 это сравнение без упаковки, а для своих типов можно передать собственный компаратор — например, сравнивать float с допуском.
  • Немедленный вызов при подписке. Это главное отличие от обычного события: подписчику не нужно отдельно читать начальное значение, и интерфейс никогда не показывает «пустое» состояние между созданием и первым изменением.
  • Подписка возвращает IDisposable. Отписка — это объект, который можно сохранить, сложить в список и уничтожить вместе с владельцем. Не нужно помнить, какой именно делегат передавался, как с -=.
  • Рассылка по копии списка. Если обработчик внутри рассылки отпишется или подпишет кого-то нового, перебор не сломается. Копия через ToArray аллоцирует массив на каждое изменение; для свойств, которые меняются несколько раз в секунду, это незаметно, но об этом стоит помнить.

Модель и представление

Теперь данные игрока описываются так:

public class PlayerModel
{
    private readonly ReactiveProperty<int> _health;
    private readonly ReactiveProperty<int> _gold = new(0);

    public int MaxHealth { get; }
    public IReadOnlyReactiveProperty<int> Health => _health;
    public IReadOnlyReactiveProperty<int> Gold => _gold;

    public PlayerModel(int maxHealth)
    {
        MaxHealth = maxHealth;
        _health = new ReactiveProperty<int>(maxHealth);
    }

    public void TakeDamage(int amount)
    {
        _health.Value = Math.Max(0, _health.Value - amount);
    }

    public void AddGold(int amount) => _gold.Value += amount;
}

Наружу торчит только интерфейс для чтения: менять здоровье может только сама модель через TakeDamage, а представления лишь подписываются. Это тот же принцип, что в паттерне MVP: модель не знает о кнопках и текстах, представление не меняет данные напрямую.

public class PlayerHud : MonoBehaviour
{
    [SerializeField] private TMP_Text healthLabel;
    [SerializeField] private TMP_Text goldLabel;
    [SerializeField] private Image healthBar;

    private readonly List<IDisposable> _subscriptions = new();

    public void Bind(PlayerModel model)
    {
        _subscriptions.Add(model.Health.Subscribe(hp =>
        {
            healthLabel.text = $"{hp} / {model.MaxHealth}";
            healthBar.fillAmount = (float)hp / model.MaxHealth;
        }));

        _subscriptions.Add(model.Gold.Subscribe(gold => goldLabel.SetText("{0}", gold)));
    }

    private void OnDestroy()
    {
        foreach (var s in _subscriptions) s.Dispose();
        _subscriptions.Clear();
    }
}

Текст теперь перестраивается ровно тогда, когда меняется значение. Update исчез. А в OnDestroy подписки снимаются — и это не формальность.

Почему отписка обязательна

Свойство хранит ссылку на обработчик, обработчик — замыкание, которое держит ссылку на PlayerHud. Если модель живёт дольше интерфейса (а так почти всегда: модель игрока переживает перезагрузку UI, смену сцены, открытие и закрытие окон), уничтоженный PlayerHud остаётся в списке обработчиков. Сборщик мусора его не соберёт, а при следующем изменении здоровья обработчик обратится к healthLabel уничтоженного объекта — и вы получите MissingReferenceException в месте, которое никак не связано с причиной.

Правило простое: тот, кто подписался, отвечает за отписку, и время жизни подписки привязывается к времени жизни подписчика, а не источника.

Где самописная версия заканчивается

Пока нужны только «значение + уведомление», своего класса хватает. Проблемы начинаются с производных значений. Допустим, нужна иконка «мало здоровья», которая включается при HP ниже 25%. Хочется написать так:

IReadOnlyReactiveProperty<bool> isLow = model.Health.Select(hp => hp < model.MaxHealth / 4);

Чтобы это работало, нужен метод Select, который создаёт новое свойство, подписывается на исходное и... тоже должен когда-то отписаться. Следом понадобится объединить два свойства («кнопка покупки активна, если золота хватает и инвентарь не полон»), отфильтровать значения, пропустить первое, сгладить частые изменения. Каждый такой оператор — это новый класс с собственным временем жизни. Через неделю у вас получится маленькая и плохо протестированная копия Rx-библиотеки. В этот момент разумнее взять готовую.

R3: реактивные расширения от Cysharp

R3 — библиотека от Cysharp, авторов UniTask и MessagePack for C#, и прямой наследник UniRx, который много лет был стандартом реактивного программирования в Unity. UniRx больше не развивается, а R3 переписан с нуля с упором на производительность и управление подписками. Минимальная поддерживаемая версия — Unity 2021.3, в Unity 6 библиотека работает без дополнительных шагов.

Установка

Установка состоит из двух частей, потому что ядро R3 распространяется как обычный NuGet-пакет, а Unity-специфичная часть — отдельно.

  1. Поставьте NuGetForUnity (через Package Manager, «Add package from git URL»), затем в меню NuGet → Manage NuGet Packages найдите и установите пакет R3. Зависимости подтянутся сами.
  2. Добавьте пакет R3.Unity по git URL: https://github.com/Cysharp/R3.git?path=src/R3.Unity/Assets/R3.Unity. Версию лучше закрепить тегом, например #1.3.1 в конце адреса, — иначе вы получите текущее состояние ветки main.

Если Unity жалуется на конфликт версий сборок, в README библиотеки советуют отключить Assembly Version Validation в Player Settings. Если у вас asmdef-сборки, добавьте в них ссылку на R3.Unity. Для Unity 7 в README описан отдельный обходной шаг: там на момент написания не хватает одной из runtime-зависимостей, так что перед обновлением загляните в раздел «Using R3 on Unity 7».

Та же модель на R3

using R3;

public class PlayerModel : IDisposable
{
    private readonly ReactiveProperty<int> _health;
    private readonly ReactiveProperty<int> _gold = new(0);

    public int MaxHealth { get; }
    public ReadOnlyReactiveProperty<int> Health => _health;
    public ReadOnlyReactiveProperty<int> Gold => _gold;
    public ReadOnlyReactiveProperty<bool> IsLowHealth { get; }
    public ReadOnlyReactiveProperty<bool> IsDead { get; }

    public PlayerModel(int maxHealth)
    {
        MaxHealth = maxHealth;
        _health = new ReactiveProperty<int>(maxHealth);

        IsLowHealth = _health
            .Select(hp => hp > 0 && hp < maxHealth / 4)
            .ToReadOnlyReactiveProperty();

        IsDead = _health
            .Select(hp => hp <= 0)
            .ToReadOnlyReactiveProperty();
    }

    public void TakeDamage(int amount) => _health.Value = Math.Max(0, _health.Value - amount);
    public void AddGold(int amount) => _gold.Value += amount;

    public void Dispose()
    {
        IsLowHealth.Dispose();
        IsDead.Dispose();
        _health.Dispose();
        _gold.Dispose();
    }
}

Что изменилось по сравнению с самописной версией:

  • ReactiveProperty<T> из R3 ведёт себя так же: не уведомляет, если присвоено то же значение, и сразу отдаёт текущее значение новому подписчику.
  • Для чтения наружу используется класс ReadOnlyReactiveProperty<T>, текущее значение у него читается через CurrentValue. Сам ReactiveProperty<T> от него наследуется, поэтому приведение бесплатное.
  • Производные свойства строятся одной строкой: Select превращает поток значений здоровья в поток bool, а ToReadOnlyReactiveProperty() снова делает из него свойство с текущим значением и отсечением повторов. Иконка «мало HP» получит уведомление только при переходе через порог, а не на каждый удар.
  • Производные свойства подписаны на исходное, поэтому их тоже нужно уничтожать. Модель реализует IDisposable и закрывает всё, что создала. При Dispose свойства в R3 отправляют подписчикам OnCompleted, и все подписки на них снимаются автоматически.

Представление и AddTo

using R3;

public class PlayerHud : MonoBehaviour
{
    [SerializeField] private TMP_Text healthLabel;
    [SerializeField] private TMP_Text goldLabel;
    [SerializeField] private Image healthBar;
    [SerializeField] private GameObject lowHealthIcon;

    public void Bind(PlayerModel model)
    {
        model.Health
            .Subscribe(hp =>
            {
                healthLabel.SetText("{0} / {1}", hp, model.MaxHealth);
                healthBar.fillAmount = (float)hp / model.MaxHealth;
            })
            .AddTo(this);

        model.Gold
            .Subscribe(gold => goldLabel.SetText("{0}", gold))
            .AddTo(this);

        model.IsLowHealth
            .Subscribe(lowHealthIcon.SetActive)
            .AddTo(this);
    }
}

AddTo(this) привязывает подписку к компоненту: когда объект уничтожается, подписка снимается. Метода OnDestroy и списка подписок больше нет. У AddTo есть тонкость, которую R3 решает за вас: компонент, который ни разу не был активен, не получает OnDestroy, и его destroyCancellationToken не отменяется. Если объект ещё не активен, AddTo вешает служебный компонент ObservableDestroyTrigger, который отслеживает уничтожение независимо от активности.

Если подписок много и важна каждая аллокация, R3 предлагает вариант со сборщиком:

var d = Disposable.CreateBuilder();
model.Health.Subscribe(OnHealthChanged).AddTo(ref d);
model.Gold.Subscribe(OnGoldChanged).AddTo(ref d);
model.IsDead.Where(dead => dead).Subscribe(_ => ShowDeathScreen()).AddTo(ref d);
d.RegisterTo(destroyCancellationToken);

DisposableBuilder — структура, она копит подписки без промежуточных списков, а RegisterTo собирает их в один объект и уничтожает при отмене токена. Для подписок, которые добавляются динамически (например, на каждый появившийся слот инвентаря), есть DisposableBag — тоже структура с методами Add, Clear и Dispose. Её нельзя копировать: держите в поле и передавайте по ref.

Операторы, которые реально нужны в игре

В R3 сотни операторов, но в повседневном игровом коде используется десяток. Разберём их на задачах.

CombineLatest: условие из нескольких свойств

Кнопка покупки активна, когда золота хватает на товар и в инвентаре есть место:

model.Gold
    .CombineLatest(inventory.FreeSlots, (gold, free) => gold >= item.Price && free > 0)
    .DistinctUntilChanged()
    .Subscribe(canBuy => buyButton.interactable = canBuy)
    .AddTo(this);

CombineLatest срабатывает, когда меняется любой из источников, и передаёт в функцию последние значения всех. Поскольку оба источника — реактивные свойства и сразу отдают текущее значение, кнопка получит правильное состояние в момент подписки. DistinctUntilChanged отсекает повторы: если золото выросло с 500 до 600, а кнопка уже была активна, лишнего вызова не будет.

Where и Skip: реагировать только на нужное

// экран смерти — только при переходе в «мёртв»
model.IsDead
    .Where(dead => dead)
    .Subscribe(_ => deathScreen.Show())
    .AddTo(this);

// звук монеты — на изменения, но не на начальное значение
model.Gold
    .Skip(1)
    .Subscribe(_ => audioSource.PlayOneShot(coinClip))
    .AddTo(this);

Второй пример показывает обратную сторону немедленной выдачи текущего значения: подписчик, который реагирует на изменение, а не на состояние, получит лишний вызов при подписке. Звук монеты при открытии окна — классический баг. Skip(1) пропускает первое значение и решает проблему. Правило: если обработчик рисует состояние, первое значение нужно; если запускает эффект, его почти всегда нужно пропустить.

Pairwise: старое и новое значение

Всплывающая цифра урона должна знать, на сколько изменилось здоровье, а не только новое значение:

model.Health
    .Pairwise()
    .Where(p => p.Current < p.Previous)
    .Subscribe(p => damagePopup.Show(p.Previous - p.Current))
    .AddTo(this);

Pairwise начинает выдавать пары со второго значения, так что отдельный Skip не нужен.

Debounce и ThrottleFirst: сглаживание частых событий

Поле поиска в магазине не должно фильтровать каталог на каждую букву, а кнопка «Атаковать» не должна срабатывать двадцать раз при дребезге:

searchInput.onValueChanged.AsObservable()
    .Debounce(TimeSpan.FromMilliseconds(300))
    .Subscribe(query => shop.Filter(query))
    .AddTo(this);

attackButton.OnClickAsObservable()
    .ThrottleFirst(TimeSpan.FromSeconds(0.5))
    .Subscribe(_ => player.Attack())
    .AddTo(this);

Debounce ждёт паузы указанной длины и выдаёт последнее значение; ThrottleFirst пропускает первое событие и игнорирует остальные в течение интервала. У обоих есть кадровые версии — DebounceFrame и ThrottleFirstFrame, — которые считают не время, а кадры. Если вы мигрируете с UniRx, учтите: старый ThrottleFrame в R3 называется DebounceFrame, а IReadOnlyReactiveProperty.Value превратился в ReadOnlyReactiveProperty.CurrentValue.

AsObservable() для UnityEvent и OnClickAsObservable() для Button — расширения из R3.Unity. Там же есть OnValueChangedAsObservable для слайдеров, тогглов и полей ввода.

EveryValueChanged: мост к нереактивному коду

Не все данные можно сделать реактивными: чужой ассет, поле стороннего компонента, transform.position. Для них есть опрос, оформленный как поток:

Observable.EveryValueChanged(targetCamera, c => c.orthographicSize)
    .Subscribe(size => minimap.SetZoom(size))
    .AddTo(this);

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

Свойства в инспекторе: SerializableReactiveProperty

Обычный ReactiveProperty<T> нельзя пометить [SerializeField]: Unity не умеет его сериализовать. Для настроек, которые хочется крутить в инспекторе и сразу видеть результат, в R3.Unity есть SerializableReactiveProperty<T>:

public class LightSettings : MonoBehaviour
{
    public SerializableReactiveProperty<float> intensity = new(1f);
    public SerializableReactiveProperty<Color> tint = new(Color.white);

    [SerializeField] private Light target;

    private void Start()
    {
        intensity.Subscribe(v => target.intensity = v).AddTo(this);
        tint.Subscribe(c => target.color = c).AddTo(this);
    }

    private void OnDestroy()
    {
        intensity.Dispose();
        tint.Dispose();
    }
}

Изменение поля в инспекторе в Play Mode отправляет уведомление подписчикам. Поддерживаются примитивы, строки, перечисления, векторы, цвета, Rect, Bounds, Quaternion и другие стандартные типы Unity.

Типичные ошибки

Изменение свойства внутри его же обработчика

// плохо: обработчик меняет то, на что подписан
model.Health.Subscribe(hp =>
{
    if (hp > model.MaxHealth) model.SetHealth(model.MaxHealth);
});

Здесь цикл остановится благодаря отсечению повторов, но достаточно двух свойств, которые правят друг друга с округлением, — и вы получите переполнение стека. Ограничения и правила предметной области должны жить в модели, в методе, который меняет значение, а не в подписчиках. Подписчики только отображают.

Мутация структуры внутри свойства

var position = new ReactiveProperty<Vector3>(Vector3.zero);
// position.Value.x = 5; — не скомпилируется: Value возвращает копию
var p = position.Value;
p.x = 5;
position.Value = p; // правильно: новое значение целиком

С классами ловушка коварнее: если в свойстве лежит список, items.Value.Add(x) скомпилируется, но уведомления не будет — ссылка не изменилась. Для коллекций нужны отдельные наблюдаемые коллекции (у Cysharp есть библиотека ObservableCollections, которая интегрируется с R3), а не ReactiveProperty<List<T>>. Если всё же храните ссылочный тип, после мутации вызовите ForceNotify().

То же значение не приходит

Иногда нужно уведомление даже при одинаковом значении — например, «повторный выбор того же слота». Присваивание через Value его не даст по определению. В R3 для этого есть OnNext(value) или ForceNotify(). Но сначала подумайте: если повтор что-то значит, возможно, это не состояние, а событие, и ему место в Subject<T>, а не в свойстве.

Реактивность для значений, меняющихся каждый кадр

Позиция персонажа, скорость, таймер способности, который тикает в Update, — плохие кандидаты на ReactiveProperty. Значение меняется каждый кадр, а значит, каждый кадр будут рассылаться уведомления, и выигрыш от «реагировать только на изменения» исчезает, а накладные расходы остаются. Реактивные свойства хороши для дискретного состояния: здоровье, золото, уровень, выбранное оружие, флаги. Для непрерывных значений обычное чтение в Update или LateUpdate честнее и дешевле.

Утечки подписок

Если что-то обновляется после закрытия окна, или в консоли появляются MissingReferenceException из лямбд — почти наверняка забыт AddTo. R3 умеет показывать все живые подписки: включите ObservableTracker.EnableTracking = true (и при желании EnableStackTrace), затем откройте окно Window → Observable Tracker. Там видно, сколько подписок живо и где каждая создана. Удобно открыть сцену, походить по меню туда-обратно и посмотреть, растёт ли число подписок.

А как же встроенный data binding в UI Toolkit

В Unity 6 у UI Toolkit появилась runtime-привязка данных: элемент интерфейса привязывается к свойству объекта-источника, а источник сообщает об изменениях через интерфейс INotifyBindablePropertyChanged. Свойства помечаются атрибутом [CreateProperty], а уведомление отправляется с BindingId изменившегося свойства:

using System;
using Unity.Properties;
using UnityEngine.UIElements;

public class PlayerViewData : INotifyBindablePropertyChanged
{
    public static readonly BindingId HealthProperty = nameof(Health);

    private int _health;

    public event EventHandler<BindablePropertyChangedEventArgs> propertyChanged;

    [CreateProperty]
    public int Health
    {
        get => _health;
        set
        {
            if (_health == value) return;
            _health = value;
            propertyChanged?.Invoke(this, new BindablePropertyChangedEventArgs(HealthProperty));
        }
    }
}

Это хороший выбор, если интерфейс целиком на UI Toolkit и привязки описаны в UXML. Но этот механизм работает только с UI Toolkit: uGUI, звуки, эффекты, логика игры через него не подписываются. Реактивные свойства и встроенная привязка хорошо уживаются вместе: модель хранит ReactiveProperty, а тонкий адаптер подписывается на них и выставляет значения в объект с [CreateProperty] для UI Toolkit.

Самописный класс или R3

Обе дороги рабочие, выбор зависит от проекта.

  • Свой ReactiveProperty<T> подходит, если вам нужны только «значение + уведомление», нет производных свойств, а лишняя зависимость нежелательна: маленький проект, ассет для Asset Store, библиотека, которую вы отдаёте другим. Сорок строк, полностью понятных, легко отлаживаются.
  • R3 стоит брать, как только появились производные значения, объединение нескольких источников, работа со временем (задержки, сглаживание, таймауты) или подписки на события UI. Библиотека даёт проверенную реализацию всего этого, учитывает особенности жизненного цикла Unity и инструменты для поиска утечек.
  • Если в проекте уже UniRx, переход на R3 почти механический: большинство операторов называются так же, а список переименований есть в README библиотеки. Новый код можно писать на R3, а старый мигрировать по мере правок — пакеты живут в разных пространствах имён.

Итог

Реактивное свойство — это значение, которое само сообщает об изменениях и отдаёт текущее состояние каждому новому подписчику. Оно убирает из интерфейса опрос в Update, ручные события в каждом сеттере и баги «показали ноль до первого изменения». Цена — дисциплина: каждую подписку нужно привязать к времени жизни подписчика, обработчики должны только отображать, а не менять данные, а непрерывные значения лучше оставить обычным полям.

Практический путь такой: начните с модели, у которой наружу торчат только свойства для чтения, а меняют их методы самой модели. Для первых экранов хватит самописного класса из начала статьи. Когда понадобятся производные значения и комбинации — подключайте R3, заменяйте ручные списки подписок на AddTo(this) и держите открытым Observable Tracker, пока не убедитесь, что число подписок не растёт.

Комментарии

Чтобы оставить комментарий, войдите в аккаунт.

Пока нет комментариев. Будьте первым!

Смотрите также