Владение данными, мутация и реактивность

Эта страница — единый разбор того, кто и как меняет данные в Vortex.

В разных пакетах используются разные механизмы — ReactiveValue<T>, SetOwner, ExtLogic, контроллеры с extension-методами, OnUpdateData-подписки. По отдельности каждый выглядит как «утилитный кирпич». На самом деле это один rule-set, и понять его как целое важнее, чем заучивать API каждого пакета.

Базовый тезис

Vortex — фреймворк с capability-based mutation policy: любое изменение состояния проходит через осознанную точку входа с проверкой полномочий.

Это не лозунг и не «договорились писать аккуратно». Это встроенный архитектурный механизм на уровне типов и контрактов:

  • Данные не имеют публичных сеттеров «свободного доступа».
  • Изменение шинной модели идёт через её контроллер.
  • Изменение ReactiveValue<T> идёт через Set(value, owner) с предъявлением ключа-капабилити.
  • Реактивная пропагация — стандартный канал, через который View узнаёт об изменениях, не опрашивая Data в Update.

Когда в чужом коде встречаются Hp.Set(value, _key) или Inventory.AddItem(targetId, ...) — это не «странная обёртка над int» и не «глобальная функция». Это точки входа в mutation policy с гарантиями типа «никто другой это не изменил».

Кто имеет право менять данные

В Vortex три роли с чёткими полномочиями.

Controller — mutation authority

Контроллер — единственный класс, имеющий право изменять модель своего домена. У InventoryController есть полномочие менять InventoryModel, у QuestControllerQuestModel. Эти полномочия не передаются.

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

// View сообщает контроллеру:
InventoryController.AddItem(playerId, itemGuid, count);

// Внутри контроллера:
public static void AddItem(string playerId, string itemId, int count)
{
    if (!CanAdd(playerId, itemId, count)) return;
    
    var data = Database.GetRecord<InventoryData>(playerId);
    data.Items.Add(itemId, count, _key);   // мутация через owner-ключ
    
    OnItemAdded?.Invoke(playerId, itemId, count);  // event для подписчиков
}

View — сигнализирует, не мутирует

View никогда не пишет в модель напрямую. Когда пользователь нажимает кнопку, View вызывает extension-метод контроллера или handler — то есть сообщает о намерении. Решение принимает контроллер.

public class InventoryButton : MonoBehaviour
{
    [SerializeField] private string itemGuid;
    
    public void OnClick()
    {
        // View не мутирует данные, она сообщает контроллеру:
        InventoryController.UseItem(PlayerSession.Id, itemGuid);
    }
}

Этот водораздел — фундаментальный. Когда View начинает мутировать модель, теряется единый источник истины: на вопрос «кто поменял это поле» больше нет точного ответа.

ExtLogic / Handlers — controlled bridges

Между View и Controller часто находится handler — MonoBehaviour, привязывающий конкретный визуальный объект к доменной операции. Handler может содержать визуальную логику (анимация, переключение состояний UI), но не доменную мутацию — для неё он тоже вызывает контроллер.

public class QuestRewardClaimHandler : MonoBehaviour
{
    [DbRecord(typeof(QuestPreset))] private string questId;
    
    private void OnClaim()
    {
        // визуальная логика — закрытие панели:
        _panelSwitcher.Set(SwitcherState.Off);
        
        // доменная мутация — через контроллер:
        QuestController.ClaimReward(questId);
    }
}

Handler — не место для бизнес-логики. Это переходник между событием View и точкой входа в контроллер.

Капабилити через owner-key

Для ReactiveValue<T> (и его наследников — IntData, BoolData, FloatData, StringData, EnumData<T>, ListData<T>) защита доступа реализована напрямую — через owner-ключ.

Механика

public class HealthController
{
    private readonly object _key = new();
    public IntData Hp { get; } = new IntData(100);
    
    public HealthController()
    {
        Hp.SetOwner(_key);   // запираем контейнер на приватный ключ
    }
    
    public void Damage(int amount)
    {
        Hp.Set(Hp - amount, _key);   // мутация с предъявлением ключа
    }
}

После SetOwner(_key) контейнер Hp принимает мутации только при предъявлении того же ключа. Все остальные вызовы Hp.Set(...) без правильного ключа логируют ошибку и не меняют значение.

// Снаружи:
controller.Hp.Set(0);              // null != _key → ошибка
controller.Hp.Set(0, controller);  // ссылка на контроллер != _key → ошибка
controller.Hp.Set(0, "anything");  // строка != _key → ошибка

Почему ключ, а не this

Соблазн использовать SetOwner(this) понятен: контроллер — естественный владелец своих данных. Но это протекает:

// Опасный вариант:
public class HealthController
{
    public IntData Hp { get; }
    
    public HealthController()
    {
        Hp.SetOwner(this);
    }
}

// Любой код с ссылкой на контроллер может мутировать:
var ctrl = HealthController.Instance;
ctrl.Hp.Set(0, ctrl);   // ✓ пройдёт, ctrl == owner

Ссылка на контроллер обычно публично доступна. Если она годится как owner — любой код может выдать себя за владельца.

Приватный ключ опаковый и нигде не публикуется:

private readonly object _key = new();

Ни рефлексия по object-типу не даст постороннему классу его получить, ни случайная утечка ссылки не сломает защиту. Ключ живёт только внутри контроллера.

ReactiveCollection — расширение модели

ListData<T> (наследник ReactiveCollection<T>) защищён той же моделью:

public class InventoryController
{
    private readonly object _key = new();
    public ListData<ItemId> Items { get; } = new();
    
    public InventoryController()
    {
        Items.SetOwner(_key);
    }
    
    public void AddItem(ItemId id)
    {
        Items.Add(id, _key);   // owner-аргумент в мутаторах коллекции
    }
}

Все мутаторы (Add, Remove, Insert, RemoveAt, Sort, Clear, Set(index, ...), Set(List<T>)) принимают owner-аргумент. Снаружи коллекция читается через GetList() — возвращает IReadOnlyList<T>-обёртку без мутаторов.

Реактивная пропагация

После того как мутация прошла через mutation authority и контейнер обновился, подписчики автоматически узнают об изменении. Это не «полл значения каждый кадр» и не «вручную дёргать обновление UI» — это встроенный contract.

Два уровня уведомления

ReactiveValue<T> эмитит два события:

public event Action<T> OnUpdate;        // типизированное: новое значение
public event Action OnUpdateData;       // нетипизированное (из IReactiveData)

OnUpdate — для подписчиков, которым нужно конкретное значение (View с биндингом). OnUpdateData — для подписчиков, которым важен факт изменения, а не значение (квестовые условия, например).

// View с биндингом:
_model.Hp.OnUpdate += hp => _hpText.text = hp.ToString();

// Квест с проверкой условия после любого изменения:
_model.Hp.OnUpdateData += QuestController.RecheckConditions;

IReactiveData как унифицированный канал

OnUpdateData живёт в интерфейсе IReactiveData. Любой ReactiveValue<T> его реализует. Это позволяет коду, не знающему конкретный тип данных, подписываться на их изменение:

public static class QuestController
{
    public static void SetListener(IReactiveData data, IQuestCondition condition)
    {
        data.OnUpdateData += () => condition.Recheck();
    }
}

// Где-то в коде:
QuestController.SetListener(model.Hp, healthCondition);     // IntData
QuestController.SetListener(model.IsAlive, aliveCondition); // BoolData
QuestController.SetListener(model.Inventory, invCondition); // ListData<ItemId>

IReactiveData помечен [POCO] — это значит, что любая его реализация автоматически попадает в сериализатор Vortex. Подписки восстанавливаются после load.

Дедупликация

ReactiveValue<T>.Set(value, owner) не эмитит событие, если новое значение равно старому:

if (EqualityComparer<T>.Default.Equals(Value, value))
    return;

Это снимает лишнюю работу с подписчиков. Если код вызывает model.Hp.Set(100, _key) каждый кадр, но HP не меняется — никаких событий, никакого перерисовывания UI.

ForceUpdate — принудительный broadcast

Иногда подписчиков нужно уведомить «без изменения значения» — например, при первой инициализации UI или после загрузки save:

public void OnLoad()
{
    // Значения восстановились из save, но OnUpdate не сработает
    // (Unity-десериализация не идёт через Set).
    // Дёргаем вручную:
    Hp.ForceUpdate();
    Mana.ForceUpdate();
    Inventory.ForceUpdate();
}

ForceUpdate вызывает оба события (OnUpdate с текущим значением и OnUpdateData). Полезно при cold-start подписок и save-load восстановлении.

Что это даёт системно

Capability-based mutation + reactive propagation в совокупности дают четыре системных эффекта.

1. Единый источник истины. На вопрос «кто поменял это поле» в Vortex всегда есть точный ответ — контроллер-владелец модели. В отладке достаточно поставить breakpoint на одном методе контроллера, чтобы поймать любую мутацию данного поля.

2. Save/Load работает корректно. Все мутации проходят через известные точки → нет неконсистентных промежуточных состояний. Save не ловит «модель в полу-инициализированном виде» потому что нельзя мутировать поля мимо контроллера, который мог бы оставить инварианты сломанными.

3. UI автоматически синхронизирован. Никаких Refresh(), UpdateAll(), ручных перерисовываний. View подписывается на OnUpdate — и реагирует на изменение в момент его факта. Если разработчик забыл подписку, View не отобразит изменение — и эта ошибка видна сразу, в первой же сцене с фичей.

4. Race conditions при асинхронных переходах не возникают. Пока async-операция ждёт, никто другой не сможет залезть в её модель напрямую. Все попытки мутации идут через контроллер, который сам управляет порядком (например, через _isProcessing-флаг).

Сравнение с альтернативами

Похожие задачи решают и другие подходы. Понимание различий помогает увидеть, что конкретно даёт Vortex.

vs publish-subscribe (event bus без owner)

Event bus с подписками — стандарт уровня Unity Asset Store. Любой объект публикует событие, любой подписывается:

EventBus.Publish<HpChanged>(new HpChanged { NewValue = 50 });
EventBus.Subscribe<HpChanged>(e => hpText.text = e.NewValue.ToString());

Достоинство — простота. Цена — никакой защиты от посторонней мутации. Любой класс может опубликовать HpChanged с произвольным значением, никаких полномочий не требуется. Источник истины размывается.

Vortex добавляет owner-ключ как точку капабилити: эмит события идёт только после успешной мутации с правильным ключом.

vs immutable state (Redux-style)

В мире фронтенда популярна модель immutable state + reducers: вместо мутации создаётся новый snapshot, применяется reducer, broadcast-итcя новая версия. Это даёт ту же гарантию единого источника истины — но ценой большего объёма церемоний: action, reducer, dispatch, selectors.

Vortex даёт ту же гарантию с меньшим объёмом кода: model.Hp.Set(value, _key) вместо dispatch({ type: SET_HP, payload: value }) + reducer + selector. За счёт того, что капабилити реализованы прямо в типе контейнера, не через внешний оркестратор.

Trade-off: Redux обеспечивает строгую трассируемость (каждый action логируется, можно replay), Vortex — нет. Если проект требует event sourcing для аудита — Vortex недостаточен. Для обычной игровой логики — избыточен Redux.

vs Rx (UniRx / R3 / System.Reactive)

Rx — это функциональное реактивное программирование: всё представляется как поток событий, преобразуемый цепочкой операторов:

// Типичная Rx-цепочка:
hp.Where(v => v > 0)
  .Throttle(TimeSpan.FromMilliseconds(100))
  .Select(v => v * multiplier)
  .Subscribe(v => hpText.text = v.ToString())
  .AddTo(disposables);

Сравните с Vortex:

// Vortex-эквивалент:
_hp.OnUpdate += hp => 
{
    if (hp <= 0) return;
    hpText.text = (hp * multiplier).ToString();
};

Это разные парадигмы, и их разница не просто в синтаксисе. Vortex и Rx отличаются по двум фундаментальным осям.

Чистота примитива. ReactiveValue<T> — это минимальный примитив: «значение + событие». Никаких операторов, никаких pipeline'ов, никаких schedulers. Делает одну вещь и делает её прозрачно. Rx, напротив, привносит целую экосистему: IObservable<T>, IObserver<T>, Subject<T>, Scheduler, ~80 операторов (Where, Select, Throttle, Debounce, CombineLatest, Merge, FlatMap, Buffer, Window...). Каждая такая абстракция увеличивает поверхность, на которой ошибки могут спрятаться.

Принцип единственной ответственности. В Vortex преобразование значений живёт в контроллере — явным кодом, с явными ветвлениями. ReactiveValue отвечает только за «хранить и уведомлять». В Rx-цепочке Where → Throttle → Select → Subscribe всё смешивается в одно выражение: чтение источника, временные манипуляции, фильтрация, преобразование, потребление. SRP нарушен на уровне идиомы.

Что это значит на практике:

  • Отладка. В Vortex breakpoint на OnUpdate-подписчике ловит мутацию сразу — со значением, с источником. В Rx breakpoint на Subscribe срабатывает после того, как цепочка прошла фильтры и преобразования. Хочешь поймать «когда HP реально поменялось» — приходится ставить breakpoint в каждом операторе и разворачивать лестницу абстракций.

  • Понимание чужого кода. Vortex-цепочка прозрачна: Set → OnUpdate → handler. Три шага, ничего не скрыто. Rx-цепочка из пяти операторов требует знать, что каждый делает — иначе невозможно сказать, что произойдёт, если значение придёт.

  • Управление подписками. В Rx подписка — это IDisposable, который нужно хранить и явно диспозить (.AddTo(disposables)). Забыл — утечка. В Vortex event — стандартный C#: += в OnEnable, -= в OnDisable, никаких отдельных контейнеров для disposable'ов.

Где Rx объективно сильнее:

  • Сложные временные сценарии — debounce ввода, throttle сетевых запросов, дрожание input'а. Это родная стихия Rx, и в Vortex такое пишется руками (через TimeController.Call с owner-перезаписью). Если в проекте много таких кейсов — Rx экономит код.
  • Композиция нескольких потоковCombineLatest(hp, mp, stamina).Subscribe(...), объединение трёх источников в один поток UI-обновлений. В Vortex это три отдельные подписки + ручная сборка состояния, что многословнее.
  • Functional pipeline для трансформации данных — если основная задача состоит из чистых преобразований данных, Rx даёт более компактный код.

Где Vortex объективно проще:

  • Прямая мутация состояния с защитой — это родная стихия Vortex, в Rx такого нет. Rx работает на чистых потоках; mutable state он обычно оборачивает в BehaviorSubject<T>, что эквивалентно ReactiveValue<T>, но без owner-капабилити.
  • Минимальная зависимостьReactiveValue<T> это десяток строк кода, входящих в ru.vortex.extensions. UniRx / R3 — это полноценные библиотеки на тысячи строк со своим жизненным циклом версий.
  • Линейная отладка — breakpoint на Set ловит источник, breakpoint на OnUpdate ловит потребителя. Между ними ничего нет.

Trade-off ясен: Rx — мощная функциональная парадигма с богатым набором операторов, но смешивающая ответственности в одном выражении. Vortex — минимальный примитив реактивности, чётко разделяющий хранение, мутацию и реакцию. Для типичной игровой логики Vortex-подход компактнее и прозрачнее. Для проектов с интенсивной потоковой обработкой данных (input pipelines, network streams, complex UI compositions) Rx может оказаться оправданным дополнением — но не заменой ReactiveValue, а слоем поверх.

vs property setters с private set

Простая инкапсуляция на уровне класса:

public class Health
{
    public int Hp { get; private set; }
    
    public void Damage(int amount) => Hp -= amount;
}

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

Vortex даёт три расширения поверх:

  • Owner-ключ для общих контейнеров (не только внутри класса).
  • Реактивная пропагация (OnUpdate).
  • Унифицированный signal-канал через IReactiveData.

vs DI-injected services с public mutator methods

В DI-подходе у IInventoryService есть публичный AddItem(...)-метод. Защита — что только зарегистрированные потребители могут получить сервис через [Inject]. Кто получил — может вызывать.

Это более грубая гранулярность капабилити. У Vortex она тоньше: даже внутри домена, между разными классами одного слоя, можно ограничить право на конкретную мутацию через owner-ключ. В DI это потребовало бы выделять отдельный микро-сервис для каждой capability.

Антипаттерны

Самые частые ошибки, которые ломают mutation policy.

this как owner вместо приватного ключа

// ПЛОХО:
Hp.SetOwner(this);
Hp.Set(value, this);

// ХОРОШО:
private readonly object _key = new();
Hp.SetOwner(_key);
Hp.Set(value, _key);

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

View напрямую в Data

// ПЛОХО:
public void OnDamageButtonClick()
{
    var hp = Database.GetRecord<HealthData>(playerId);
    hp.CurrentHp -= 10;   // обход контроллера
}

// ХОРОШО:
public void OnDamageButtonClick()
{
    HealthController.Damage(playerId, 10);
}

Прямая мутация модели из View — разрушение единого источника истины. Контроллер уже не гарантирует, что инварианты соблюдены.

Полл значения вместо подписки

// ПЛОХО:
private void Update()
{
    if (_model.Hp != _lastHp)
    {
        _hpText.text = _model.Hp.ToString();
        _lastHp = _model.Hp;
    }
}

// ХОРОШО:
private void OnEnable()
{
    _model.Hp.OnUpdate += UpdateHpText;
    UpdateHpText(_model.Hp);
}

private void OnDisable() => _model.Hp.OnUpdate -= UpdateHpText;

private void UpdateHpText(int hp) => _hpText.text = hp.ToString();

Каждый кадр сравнивать значение — растрата CPU + источник скрытых багов (если за кадр изменилось два раза, увидим только последнее).

Мутация коллекции через cast

// ПЛОХО:
var list = inventory.Items.GetList();
((List<ItemId>)list).Add(newItem);   // обход owner-механизма

// ХОРОШО:
inventory.Items.Add(newItem, _key);

GetList() возвращает IReadOnlyList<T>-обёртку именно для защиты. Каст обратно в List<T> — обход контракта и потенциальная Liskov violation.

Public mutator на модели

// ПЛОХО:
public class InventoryData
{
    public List<ItemId> Items;   // открытый список, любой может мутировать
}

// ХОРОШО:
public class InventoryData
{
    private ListData<ItemId> _items = new();
    public IReadOnlyList<ItemId> Items => _items.GetList();
    
    internal ListData<ItemId> ItemsContainer => _items;   // доступ для контроллера
}

Open list на public-поле модели — приглашение к мутации мимо контроллера. Реактивная коллекция с IReadOnlyList<T>-проекцией снаружи и закрытым контейнером для контроллера — канон.

Заключение

Capability-based mutation policy — не «лишняя строгость для надёжности». Это позитивный механизм проектирования:

  • Когда любая мутация проходит через известную точку, отладка становится механической: поставил breakpoint на методе контроллера → поймал источник изменения.
  • Когда View подписан на OnUpdate, синхронизация UI становится автоматической: не нужно помнить о вызове Refresh() после каждого действия.
  • Когда модель защищена owner-ключом, рефакторинг становится дешёвым: контроллер можно переписать, переехать в другой пакет — потребители не сломаются, потому что не имели прямого доступа.

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


Связанные страницы: