К содержимому

3. Реактивность

Обновлено: 2026-07-01 — добавлены props между компонентами через SetChildProp/BindChildProp.

Prop<​T> — реактивное свойство

csharp
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> — вычисляемое свойство

csharp
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> — отслеживание изменений

csharp
public Prop<string> Status = new("idle");

protected override void Created()
{
    Watch(Status, (oldVal, newVal) =>
    {
        if (newVal == "error")
            PlayErrorAnimation();
    });
}

Возвращает IDisposable — для ручной отписки:

csharp
var handle = Watch(someProp, callback);
handle.Dispose();  // Later

WatchEffect(Action) — эффект с автотрекингом

csharp
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:

csharp
// 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> — устраняет повторы при частых сеттерах

Хелперы

csharp
// 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>

csharp
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>

csharp
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

csharp
public class WatchHandle : IDisposable
{
    public void Dispose();             // unsubscribe from Prop<T>
}

IReactiveSource

csharp
public interface IReactiveSource
{
    IDisposable SubscribeInvalidate(Action onInvalidate);
}

Prop<T> реализует IReactiveSource. Computed<T> использует его для автотрекинга.

DependencyTracker

csharp
internal static class DependencyTracker
{
    public static IDisposable Track(Action<IReactiveSource> collector);
    public static void RegisterSource(IReactiveSource source);
}

[ThreadStatic] — потокобезопасен. Явный DependsOn() не нужен.


Props между компонентами

При использовании кастомных компонентов (<sus:SusButton>) в .sharq.

Литеральный prop

xml
<!-- variant="primary" - mutates the .Value of an existing Prop, does not replace it -->
<sus:SusButton variant="primary" :text="Title" />

Генератор выпускает SetChildProp(child, "variant", "primary") который:

  1. Находит поле Variant (без учёта регистра, BindingFlags.IgnoreCase)
  2. Если член — Prop<T> и не null → записывает в .Value (сохраняет внутренние биндинги дочернего компонента)
  3. Если член — Prop<T> и null → создаёт новый экземпляр
  4. Если член — обычный тип → прямое присваивание

Реактивный prop

xml
<!-- :variant="item.Kind" - reactive whenever item.Kind changes -->
<sus:SusButton :variant="item.Kind" />

Генератор выпускает BindChildProp(child, "variant", () => item.Kind) который:

  1. Находит поле Variant (без учёта регистра)
  2. Оборачивает в ReactiveEffect — автоподписка + очистка при отключении
  3. Изменяет .Value существующего Prop<T>

Приведение скаляров

csharp
// ConvertScalar(value, targetType) supports:
stringbool (via bool.TryParse)
stringint (via Convert.ChangeType)
stringfloat (via Convert.ChangeType)
string → enum (via Enum.Parse, ignoreCase)
string → string (direct assignment)

Диагностика

В dev-сборке (#if DEVELOPMENT_BUILD || UNITY_EDITOR):

  • LogWarning при ошибке конвертации или неизвестном prop
  • LogError, если BindChildProp не нашёл Prop<T>-член по имени