Skip to main content

Architecture

How Aspid.MVVM is built: the MVVM pattern in Unity, the role of the Source Generator and the data binding pipeline.

Contents


MVVM in Unity

MVVM (Model-View-ViewModel) splits an application into three layers:

LayerRoleIn Aspid.MVVM
ModelBusiness logic and dataPlain C# classes (POCO)
ViewModelAdapts data for presentationA class with [ViewModel]
ViewPresentation and user inputMonoView + binders (MonoBehaviour)

Key principle: the ViewModel does not know about the View. The View subscribes to ViewModel changes through the binding system. This makes it possible to:

  • Test the ViewModel without Unity
  • Change the View (UI) without touching the logic
  • Show one ViewModel in several Views

Source Generation

Aspid.MVVM uses Roslyn Incremental Source Generators to emit code at compile time. That is what gives zero reflection at runtime.

What is generated

From a partial class with [ViewModel]:

// Your code:
[ViewModel]
public sealed partial class PlayerViewModel
{
[OneWayBind] private int _health;
[TwoWayBind] private string _name;
[RelayCommand] private void Attack() { /* ... */ }
}

The Source Generator produces:

// Generated code (simplified):
partial class PlayerViewModel : IViewModel
{
private OneWayBindableMember<int> _healthBindableMember;
private TwoWayBindableMember<string> _nameBindableMember;
private readonly IRelayCommand _attackCommand;

public int Health
{
get => _health;
private set { /* update + notify */ }
}

public string Name
{
get => _name;
set { /* update + notify */ }
}

public IRelayCommand AttackCommand => _attackCommand;

public FindBindableMemberResult FindBindableMember(
in FindBindableMemberParameters parameters)
{
// Dispatch by id: plain string comparisons
if (parameters.Id == "Health") return new(healthAdder);
if (parameters.Id == "Name") return new(nameAdder);
if (parameters.Id == "AttackCommand") return new(attackAdder);
return default;
}

public void NotifyAll() { /* notifies every binding */ }
}

Generation for the View

// Your code:
[View]
public sealed partial class PlayerView : MonoView
{
[SerializeField] private MonoBinder _health;
[SerializeField] private MonoBinder[] _name;
}

The Source Generator implements IView: Initialize, Deinitialize, enumeration and binding of every declared binder.


Binding pipeline

Step by step, what happens on view.Initialize(viewModel):

1. The View asks for a BindableMember

For every binder field the View calls:

var result = viewModel.FindBindableMember(
new FindBindableMemberParameters("Health"));

FindBindableMemberParameters is a ref struct (zero allocations).

2. The ViewModel returns an IBinderAdder

FindBindableMemberResult carries an IBinderAdder, the interface used to attach a binder:

public interface IBinderAdder
{
BindMode Mode { get; }
IBinderRemover? Add(IBinder binder);
}

3. The binder subscribes

Binder.Bind(IBinderAdder) calls binderAdder.Add(this):

  • The binder subscribes to the Changed event of the BindableMember
  • The binder immediately receives the current value through SetValue

4. Data update

When a ViewModel property changes:

ViewModel.Health = 50
→ _healthBindableMember.Value = 50
→ Changed?.Invoke(50)
→ every IBinder<int>.SetValue(50)
→ UI updates

Reverse binding

In TwoWay and OneWayToSource modes data can travel from the View to the ViewModel:

UI changes (the user types text)
→ IReverseBinder<string>.ValueChanged?.Invoke("new text")
→ TwoWayBindableMember.OnValueChanged("new text")
→ _setValue("new text")
→ ViewModel._name = "new text"

TwoWayBindableMember subscribes to IReverseBinder<T>.ValueChanged inside Add().


Architecture diagram

┌─────────┐ ┌──────────────┐ ┌─────────────────┐ ┌────────┐ ┌────┐
│ Model │◄───►│ ViewModel │◄───►│ BindableMember │◄───►│ Binder │◄───►│ UI │
│ (C#) │ │ [ViewModel] │ │ (OneWay/TwoWay) │ │ (Mono) │ │ │
└─────────┘ └──────────────┘ └─────────────────┘ └────────┘ └────┘


┌───────┴───────┐
│ IConverter │
│ (optional) │
└───────────────┘

Data flow:

  • OneWay: Model → ViewModel → BindableMember → Binder → UI
  • TwoWay: the same, plus UI → Binder → BindableMember → ViewModel
  • OneTime: a single push of the value when binding
  • OneWayToSource: UI → Binder → BindableMember → ViewModel

Key interfaces

InterfacePurpose
IViewModelA single method, FindBindableMember: the entry point of binding
IView / IView<T>Initialize(viewModel), Deinitialize(), ViewModel: lifecycle control
IBinder<T>SetValue(T): receives a value from the ViewModel
IReverseBinder<T>ValueChanged event: sends a value back to the ViewModel
IAnyBinderSetValue<T>(T): accepts any type (debugging and generic binders)
IBinderAdderAdd(IBinder): attaches a binder to a BindableMember
IBinderRemoverRemove(IBinder): detaches a binder

BindableMembers

ClassModeDescription
OneWayBindableMember<T>OneWayStores the value, Changed event, push on subscribe
TwoWayBindableMember<T>TwoWayPlus a subscription to IReverseBinder.ValueChanged
OneTimeBindableMember<T>OneTimeSingleton per T, one-shot push, Add returns null
OneWayToSourceBindableMember<T>OneWayToSourceReverse binding only, no push to the View

See also