Владение данными, мутация и реактивность
Эта страница — единый разбор того, кто и как меняет данные в 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, у QuestController — QuestModel. Эти полномочия не передаются.
Если другой части системы нужно изменить инвентарь, она не получает прямой доступ к модели. Она вызывает метод контроллера, который вычисляет, валидирует и применяет изменение от имени своих полномочий.
// 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)). Забыл — утечка. В Vortexevent— стандартный 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-ключом, рефакторинг становится дешёвым: контроллер можно переписать, переехать в другой пакет — потребители не сломаются, потому что не имели прямого доступа.
Это и есть смысл правила «менять данные имеет право только контроллер»: не запрет ради запрета, а дисциплина, дающая отдачу в виде предсказуемости и поддерживаемости.
Связанные страницы:
- Философия Vortex — общие архитектурные принципы.
- ReactiveValues — детальный API-reference.
- System (Core) —
Singleton<T>,SystemController<T, TD>, контракты драйверов. - Сравнение Vortex и VContainer — как ownership работает в DI-альтернативе.