3. Реактивность
Обновлено: 2026-07-01 — добавлены props между компонентами через
SetChildProp/BindChildProp.
Prop<T> — реактивное свойство
public class HealthBar : SusComponent
{
public Prop<float> Health = new(100f);
public Prop<string> Name = new("Player");
protected override void Created()
{
Watch(Health, (oldVal, newVal) =>
{
Debug.Log($"Health: {oldVal} → {newVal}");
});
}
private void TakeDamage(float amount)
{
Health.Value -= amount; // UI will update automatically
}
}Особенности:
- Неявное приведение:
Prop<float>работает какfloat(черезimplicit operator) - Сравнение по значению: если новое значение равно старому, событие
Changedне вызывается - IL2CPP-safe: не использует reflection
Computed<T> — вычисляемое свойство
public class Inventory : SusComponent
{
public Prop<int> Gold = new(100);
public Prop<int> Gems = new(50);
public Computed<int> TotalValue => C(() => Gold.Value + Gems.Value * 10);
protected override void Build()
{
var label = new Label();
BindText(label, () => TotalValue.ToString());
}
}Computed<T> кэширует значение и пересчитывает его только при изменении зависимостей. Автотрекинг: когда Value вычисляет _fn(), все Prop<T>.Value и Computed<T>.Value, прочитанные внутри, автоматически становятся зависимостями.
С 1 июля 2026:Computed<T> реализует IReactiveSource — сам является реактивным источником:
- Цепочки
Prop → Computed A → Computed B → BindTextработают (push-инвалидация вверх по цепочке) BindText(label, () => MyComputed.Value)— подписывается на computed как на источник
Watch<T> — отслеживание изменений
public Prop<string> Status = new("idle");
protected override void Created()
{
Watch(Status, (oldVal, newVal) =>
{
if (newVal == "error")
PlayErrorAnimation();
});
}Возвращает IDisposable — для ручной отписки:
var handle = Watch(someProp, callback);
handle.Dispose(); // LaterWatchEffect(Action) — эффект с автотрекингом
public Prop<float> Health = new(100f);
public Prop<float> MaxHealth = new(150f);
protected override void Created()
{
WatchEffect(() =>
{
var ratio = Health.Value / MaxHealth.Value;
bar.style.width = Length.Percent(ratio * 100f);
});
}Автоматически отслеживает все Prop<T> и Computed<T>, прочитанные внутри fn, и перезапускает fn при изменении любого из них. Возвращает WatchHandle для отписки.
Внутри использует ReactiveEffect — единый реактивный примитив, на котором строятся все Bind* методы и WatchEffect. При отключении компонента все подписки автоматически очищаются (DisposeAllBindings).
ReactiveEffect — единый реактивный примитив (внутренний)
Все биндинги (BindText, BindShow, BindVisibility, BindClass, BindList, BindListFor) работают через ReactiveEffect:
// Operating principle (simplified):
private WatchHandle ReactiveEffect(Action fn)
{
var subs = new List<IDisposable>();
void Run()
{
foreach (var s in subs) s.Dispose();
subs.Clear();
// Auto-track: collect all Prop/Computed read in fn
using (DependencyTracker.Track(src =>
subs.Add(src.SubscribeInvalidate(() => ScheduleBindUpdate(Run)))))
{
fn();
}
}
Run();
return new WatchHandle(() => { foreach (var s in subs) s.Dispose(); });
}Ключевые свойства:
fn()выполняется подDependencyTracker.Track()— автосбор зависимостей- Подписка через
SubscribeInvalidateдля каждого источника - При инвалидации — пакетный перезапуск через
ScheduleBindUpdate(один проход за кадр) - Дедупликация через
HashSet<Action>— устраняет повторы при частых сеттерах
Хелперы
// P<T> is shorthand for new Prop<T>
public Prop<string> Title = P("Default Title");
// C<T> is shorthand for new Computed<T>
public Computed<bool> IsValid => C(() => !string.IsNullOrEmpty(Title));
// WatchEffect - auto-tracking
protected WatchHandle WatchEffect(Action fn);Очистка при отключении от панели
Все подписки (Bind*, Watch, WatchEffect) автоматически очищаются при отключении компонента через DisposeAllBindings() в OnDetachFromPanelHandler. Явно вызывать Dispose() на watch-хендлах не нужно, если не требуется ручной контроль.
API
Prop<T>
public class Prop<T> : INotifyBindablePropertyChanged
{
public T Value { get; set; } // notifies subscribers
public event Action<T, T> Changed; // (old, new)
public static implicit operator T(Prop<T> p);
public Prop(T initial = default);
}Computed<T>
public class Computed<T> : IReactiveSource // itself is a source (push invalidation)
{
public T Value { get; } // cached, auto-invalidated
public static implicit operator T(Computed<T> c);
public Computed(Func<T> fn);
public void Invalidate(); // force mark dirty
public void Refresh(); // recalculate immediately
}
Computed<T>.ValueвызываетDependencyTracker.RegisterSource(this)— внешний трекинг видит computed как источник.MarkDirty()передаёт инвалидацию подписчикам только на фронтеfalse→true.
WatchHandle
public class WatchHandle : IDisposable
{
public void Dispose(); // unsubscribe from Prop<T>
}IReactiveSource
public interface IReactiveSource
{
IDisposable SubscribeInvalidate(Action onInvalidate);
}Prop<T> реализует IReactiveSource. Computed<T> использует его для автотрекинга.
DependencyTracker
internal static class DependencyTracker
{
public static IDisposable Track(Action<IReactiveSource> collector);
public static void RegisterSource(IReactiveSource source);
}[ThreadStatic] — потокобезопасен. Явный DependsOn() не нужен.
Props между компонентами
При использовании кастомных компонентов (<sus:SusButton>) в .sharq.
Литеральный prop
<!-- variant="primary" - mutates the .Value of an existing Prop, does not replace it -->
<sus:SusButton variant="primary" :text="Title" />Генератор выпускает SetChildProp(child, "variant", "primary") который:
- Находит поле
Variant(без учёта регистра,BindingFlags.IgnoreCase) - Если член —
Prop<T>и неnull→ записывает в.Value(сохраняет внутренние биндинги дочернего компонента) - Если член —
Prop<T>иnull→ создаёт новый экземпляр - Если член — обычный тип → прямое присваивание
Реактивный prop
<!-- :variant="item.Kind" - reactive whenever item.Kind changes -->
<sus:SusButton :variant="item.Kind" />Генератор выпускает BindChildProp(child, "variant", () => item.Kind) который:
- Находит поле
Variant(без учёта регистра) - Оборачивает в
ReactiveEffect— автоподписка + очистка при отключении - Изменяет
.ValueсуществующегоProp<T>
Приведение скаляров
// ConvertScalar(value, targetType) supports:
string → bool (via bool.TryParse)
string → int (via Convert.ChangeType)
string → float (via Convert.ChangeType)
string → enum (via Enum.Parse, ignoreCase)
string → string (direct assignment)Диагностика
В dev-сборке (#if DEVELOPMENT_BUILD || UNITY_EDITOR):
LogWarningпри ошибке конвертации или неизвестном propLogError, еслиBindChildPropне нашёлProp<T>-член по имени