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​