MVVM View System for Unity3D
Odin Inspector Asset recommended to usage with this Package (https://odininspector.com)
- UniGame.ViewSystem
- support base principles of MVVM concepts.
- support ui skins out of the box
- based on Unity Addressables Resources
- handle Addressables Resource lifetime
For this module you need to install R3 package, NuGetForUnity and ObservableCollections.
ObservableCollections can be installer vai NuGetForUnity
In Unity projects, you can installing ObservableCollections with NugetForUnity.
If R3 integration is required, similarly install ObservableCollections.R3 via NuGetForUnity.
follow the instructions on home pages for these packages:
- https://github.com/Cysharp/R3
- https://github.com/GlitchEnzo/NuGetForUnity
- https://github.com/Cysharp/ObservableCollections
"dependencies": {
"com.unity.localization": "1.5.4",
"com.unity.addressables": "2.6.0",
"com.unigame.addressablestools" : "https://github.com/UnioGame/unigame.addressables",
"com.unigame.unicore": "https://github.com/UnioGame/unigame.core.git",
"com.unigame.localization": "https://github.com/UnioGame/unigame.localization.git",
"com.unigame.rx": "https://github.com/UnioGame/unigame.rx.git",
"com.cysharp.unitask" : "https://github.com/Cysharp/UniTask.git?path=src/UniTask/Assets/Plugins/UniTask",
"com.cysharp.r3": "https://github.com/Cysharp/R3.git?path=src/R3.Unity/Assets/R3.Unity",
"com.github-glitchenzo.nugetforunity": "https://github.com/GlitchEnzo/NuGetForUnity.git?path=/src/NuGetForUnity"
},
Add to your project manifiest by path [%UnityProject%]/Packages/manifiest.json new package:
{
"dependencies": {
"com.unigame.viewsystem" : "https://github.com/UnioGame/unigame.viewsystem.git"
"com.unigame.localization" : "https://github.com/UnioGame/unigame.localization.git",
"com.cysharp.unitask" : "https://github.com/Cysharp/UniTask.git?path=src/UniTask/Assets/Plugins/UniTask"
}
}- Create View System Asset
[MenuItem("Assets/UniGame/ViewSystem/Create ViewSystem")]Here you can initialize locations of views
For skinned views. Skin name of view equal to it's parent folder. Your project views prefabs structure involve direct mapping into its skins
For now All views load at runtime through Unity Addressable Asset system
By default if your views not registered as Addressable Asset when View System automatically register it into new Addressable Group With name equal to its ViewsSettings Source Name
You can enable Addressable Group Name override:
- Enable "Apply Addressable Group" option
- SetUp new views group name
View System Support additional "nested" view settings sources. All view from that sources will be registered into main View System when it loaded. All nested view settings loads async. If that't source must be loaded before the View System will be available its possible activate "Await Loading" option.
"Layout Flow Control" asset control views behaviours between all layouts. View System support two flow behaviour out from the box.
- DefaultFlow
Base flow controller with auto closing screens/windows when active scene changed
- SingleViewFlow
More complex flow with support 'IScreenSuspendingWindow' api. If View with 'IScreenSuspendingWindow' is open, when all current screens wills suspend and resume after it closed.
You can manualy trigger rebuild:
- Rebuild Command
- For All Settings
- For target settings asset from inspector context menu
Different flavours of the same view type can be created by utilizing skins. When a skin tag is provided on view creation the corresponding skin is instantiated (if it's been registered prior to it). Skin tag can be provided as a string or a variable of SkinId type (which allows choosing one of the registered tags from a dropdown list and implicitly converts it to a string)
void ExampleFunc(SkinId largeDemoView) {
await gameViewSystem.OpenWindow<DemoView>(ViewModel, "SmallDemoView");
await gameViewSystem.OpenWindow<DemoView>(ViewModel, largeDemoView);
}Place views of the same type in separate folders and add them to the UI Views Skin Folders list in view system settings. After rebuilding the views will be added to the views registry with folder names as their skin tags

Add View Skin Component to a prefab to turn it into a skin. To add a new skin tag enter it into Skin Tag Name field and press Invoke, an existing tag can be chosen from the Skin Tag dropdown list. No need to specify skin folders in view system settings

View Factory - provide custom view creation logic. You can create your own factory by implementing:
- IViewFactory
- IViewFactoryProvider
And select new provider in View System Settings
Add to your project scriptings define symbol "ZENJECT_ENABLED" to enable Zenject DI support
Anywhere in your initialization of game pass Zenject DiContainer to ZenjectViewFactoryProvider.Container static field
public class ZenjectViewFactoryProvider : IViewFactoryProvider
{
public static DiContainer Container { get; set; }
}You can use Zenject DI module as an example to create your own custom DI support in view lines of code. Module is located in ZenjectViewModule directory
//ZenjectViewFactory example
public class ZenjectViewFactory : IViewFactory
{
public ViewFactory _viewFactory;
public DiContainer _container;
public ZenjectViewFactory(DiContainer container,AsyncLazy readyStatus, IViewResourceProvider viewResourceProvider)
{
_container = container;
_viewFactory = new ViewFactory(readyStatus, viewResourceProvider);
}
public async UniTask<IView> Create(string viewId,
string skinTag = "",
Transform parent = null,
string viewName = null,
bool stayWorldPosition = false)
{
var view = await _viewFactory.Create(viewId, skinTag, parent, viewName, stayWorldPosition);
if (view == null || view.GameObject == null) return view;
var viewObject = view.GameObject;
_container.InjectGameObject(viewObject);
return view;
}
}The View System uses MVVM principles to manage UI with reactive data binding and automatic lifetime management.
View System implements a sophisticated two-level lifetime management system that ensures proper cleanup of subscriptions and resources. This is critical for preventing memory leaks and managing complex UI hierarchies.
Each View maintains two independent LifeTimes that serve different purposes:
ViewLifeTime - Lifespan of the View Instance
- Created when View is instantiated
- Lives throughout entire View lifecycle (from creation to destruction)
- Manages resources that persist for the entire View lifecycle
- Terminated only when View is destroyed
- Used for: animations independent of model, component lifecycle events, long-lived resources
ModelLifeTime (aka LifeTime) - Lifespan of Data Subscriptions
- Created/restarted each time View is initialized with a model
- Manages all Observable subscriptions to model data
- Automatically restarted when model is changed
- Old subscriptions are automatically disconnected when restarted
- Used for: data binding, reactive streams, model-dependent operations
Diagram: LifeTime Management Flow
View Created
|
+--- ViewLifeTime.Start() ───────────────────────────────┐
(Lives for entire View) |
|
RegisterView(Model1) |
|
+--- ModelLifeTime.Restart() |
| |
+--- Subscribe to Model1 data |
| (health, mana, etc.) |
| |
+--- Active Subscriptions ●●● |
|
RegisterView(Model2) ← Model Changed! |
| |
+--- ModelLifeTime.Restart() |
| (Old subscriptions auto-disconnected) |
| |
+--- Subscribe to Model2 data |
| (fresh subscriptions) |
| |
+--- Active Subscriptions ●●● |
|
Close()/Destroy() |
| |
+--- ViewLifeTime.Terminate() ◄──────────────────────────┘
ModelLifeTime.Terminate()
Resources Released
View Destroyed
public class HealthBarView : ViewBase<CharacterViewModel>
{
[SerializeField] private Image healthFill;
protected override UniTask OnInitialize(CharacterViewModel model)
{
this.Bind(model.CurrentHealth, UpdateHealthBar);
return UniTask.CompletedTask;
}
}Use ViewLifeTime for:
Use ModelLifeTime (LifeTime) for:
- All data bindings to Observable fields
- Async operations triggered by model changes
- Model-dependent subscriptions
- React to data stream events
// RECOMMENDED: Using Bind extensions
this.Bind(model.Health, UpdateDisplay);
// ALTERNATIVE: Direct Rx approach (what Bind does internally)
model.Health
.Subscribe(UpdateDisplay)
.AddTo(LifeTime);Key Difference:
- ViewLifeTime: "Keep this while View exists"
- ModelLifeTime: "Keep this while this Model is active"
When a View is reinitialized with a new model, the system automatically cleans up old subscriptions:
// Example: Character select screen with Bind extensions
public class CharacterDetailsView : ViewBase<CharacterViewModel>
{
[SerializeField] private TextMeshProUGUI nameText;
[SerializeField] private Slider healthSlider;
protected override UniTask OnInitialize(CharacterViewModel model)
{
// RECOMMENDED: Using Bind extensions (auto-managed)
this.Bind(model.Name, nameText)
.Bind(model.Health, healthSlider);
return UniTask.CompletedTask;
}
}
// In controller code:
var character1ViewModel = new CharacterViewModel { Name = new("Hero") };
var character2ViewModel = new CharacterViewModel { Name = new("Villain") };
// User selects character 1
await detailsView.RegisterView(character1ViewModel);
// → OnInitialize() bound to character1 stats
// → Subscriptions connected to character1 data
// User selects character 2
await detailsView.RegisterView(character2ViewModel);
// → OnInitialize() called again
// → OLD subscriptions to character1 automatically → DISCONNECTED
// → NEW subscriptions to character2 → CREATED
// → No memory leak, no duplicate subscriptionsWithout LifeTime Management (Memory Leak):
// ❌ WRONG - This leaks memory!
public void BadExample(CharacterViewModel model)
{
model.Health.Subscribe(x => UpdateUI(x)); // Never unsubscribes!
// If you call this 100 times with different models,
// you'll have 100 active subscriptions
}With LifeTime Management - Bind Extensions (RECOMMENDED):
// ✅ CORRECT - Using Bind (fluent API)
public void GoodExampleWithBind(CharacterViewModel model)
{
this.Bind(model.Health, UpdateUI);
// Auto-managed lifetime, disconnects on model change
}Views have distinct status states throughout their lifecycle:
| Status | Meaning | Next State |
|---|---|---|
| None | Initial state | Shown, Hidden |
| Shown | View is visible and active | Hiding |
| Showing | Animation in progress | Shown |
| Hidden | View exists but not visible | Shown, Closed |
| Hiding | Hide animation in progress | Hidden |
| Closed | View destroyed, lifecycle ended | (final) |
Complete Status Flow Diagram:
RegisterView(model)
↓
Initialize(model)
↓
Status: None
↓
Show() called
↓
Status: Showing → OnShowAction() plays animation
↓
Status: Shown
↓
Hide() called
↓
Status: Hiding → OnHideAction() plays animation
↓
Status: Hidden
↓
Close() called
↓
Status: Closed → Destroy() → ViewLifeTime.Terminate()
Observable Status Tracking:
// RECOMMENDED: Using Bind extensions
this.Bind(view.SelectStatus(ViewStatus.Hidden),
v => Debug.Log($"{v.SourceName} is hidden"));
// ALTERNATIVE: Direct Rx approach
view.Status
.Where(status => status == ViewStatus.Shown)
.Subscribe(_ => Debug.Log("View is now visible"))
.AddTo(lifeTime);
// ALTERNATIVE: Using SelectStatus helper
view.SelectStatus(ViewStatus.Hidden)
.Subscribe(v => Debug.Log($"{v.SourceName} is hidden"))
.AddTo(lifeTime);Lifecycle Hooks by Status:
public class MyView : ViewBase<MyViewModel>
{
[SerializeField] private Button closeButton;
protected override UniTask OnInitialize(MyViewModel model)
{
// Called right after model attachment
// Status: None → Shown (transitioning)
// Bind button to close command
this.Bind(closeButton, model.CloseCommand);
// Or with action
this.Bind(closeButton, Close);
return UniTask.CompletedTask;
}
protected override UniTask OnShowAction()
{
// Called when transitioning to Shown
// Use for entrance animations
return PlayEntranceAnimation();
}
protected override UniTask OnHideAction()
{
// Called when transitioning to Hidden
// Use for exit animations
return PlayExitAnimation();
}
}All base Bind extensions use ViewModelLifetime that allows auto disconnect from data streams when ViewModel changed.
Binding extensions allow you to easily connect your view and data sources with a rich flow syntax and support Rx methods and async/await semantics.
Bind extensions support static lambda expressions to eliminate closure allocations. This is especially important in performance-critical scenarios like frequent updates or animations.
Static Lambda - Zero Allocation Closures:
A static lambda cannot capture any local variables, which prevents the compiler from creating a display class for closure storage:
// ✅ RECOMMENDED: Static lambda - zero allocation
// Compiler doesn't create display class for closure
this.Bind(model, purchaseStream, static (model, stream) =>
stream.purchase.Execute(stream));
// ❌ Non-static lambda - closure allocation
// Compiler creates display class to capture variables
this.Bind(model, purchaseStream, (model, stream) =>
stream.purchase.Execute(stream));When to use static lambdas:
- Combining multiple observable streams
- High-frequency updates (animations, real-time data)
- Performance-critical UI sections
- The lambda doesn't need to capture
thisor local variables
Example - Static Lambda Binding:
public class PurchaseView : ViewBase<PurchaseViewModel>
{
[SerializeField] private Button purchaseButton;
protected override UniTask OnInitialize(PurchaseViewModel model)
{
// Zero-allocation: static lambda combines two streams
this.Bind(model, model.PurchaseStream,
static (purchaseData,viewModel) => viewModel.purchase.Execute(purchaseData));
return UniTask.CompletedTask;
}
}Static Lambda vs Direct Method Reference:
// ✅ Direct method reference - simple binding, zero allocation
this.Bind(model.Health, UpdateUI);
// ✅ Static lambda - complex binding with multiple parameters, zero allocation
this.Bind(model, itemStream, static (item,m) => m.ProcessItem(item));
// ❌ Non-static lambda - allocates closure class
this.Bind(model, itemStream, (item,m) => model.ProcessItemAndReport(item));
// Captures 'model' in display class - not neededStatic Lambda Requirements (C# 9+):
- Cannot use
thisreference - Cannot capture local variables
- Can only use method parameters and static members
- Compiler enforces these restrictions and prevents allocation
The BindData extension methods are optimized for scenarios where you need both the View context and data stream values in a single callback. They eliminate closure allocations by using static lambdas internally.
BindData Overloads:
// Variant 1: View only + callback with data
view.BindData(model, dataStream, action); // action receives: context
// Variant 2: View + Data + callback with both
view.BindData(model, dataStream, static x => ProcessData(x.Data, x.Source));
// Variant 3: View + Data + Stream value
view.BindData(model, dataStream, static x => x.Data.ProcessItem(x.Source, x.Value));Advantages Over Regular Bind:
- Zero-Allocation Context Passing: Pass both View and Model data without closure allocation
- Static Lambda Friendly: Built to work seamlessly with static lambdas
- Multiple Data Sources: Combine and process multiple reactive streams together
- Fluent API: Returns the sender for method chaining
Example - BindData with Model Update:
public class DetailsView : View<SomeViewModel>
{
protected override UniTask OnInitialize(SomeViewModel model)
{
// Zero-allocation: combines stream + view context
// x.Data = model, x.Source = this (view), x.Value = stream value
this.BindData(model, model.Id, static x => x.Data.SomeMethod(x.Source))
.BindData(model, model.Updated, static x => x.Data.SomeMethod(x.Source));
return UniTask.CompletedTask;
}
}
public class SomeViewModel : ViewModel
{
public ReactiveProperty<int> Id { get; } = new();
public ReactiveCommand<Unit> Updated { get; } = new();
}Help methods to direct bind unity UGUI types to data streams
- Button methods
Bind Button to model action
public Button openChest;
[Serializable]
public class WindowViewModel : ViewModelBase
{
public ReactiveCommand checkAction = new ReactiveCommand();
public IReactiveCommand<Unit> ChestAction => checkAction;
}
protected override UniTask OnViewInitialize(WindowViewModel model)
{
this.Bind(openChest,model.ChestAction);
return UniTask.CompletedTask;
}Bind Model to Button invoke
public Button openChest;
[Serializable]
public class WindowViewModel : ViewModelBase
{
public ReactiveCommand checkAction = new ReactiveCommand();
public IReactiveCommand<Unit> ChestAction => checkAction;
}
protected override UniTask OnViewInitialize(WindowViewModel model)
{
this.Bind(model.ChestAction,openChest);
return UniTask.CompletedTask;
}- TextMeshPro methods
[Serializable]
public class WindowViewModel : ViewModelBase
{
public ReactiveProperty<string> label = new();
public ReactiveProperty<string> value = new();
}
public TextMeshProUGUI label;
public TextMeshProUGUI value;
protected override UniTask OnViewInitialize(WindowViewModel model)
{
this.Bind(model.label,label)
.Bind(model.value,value);
return UniTask.CompletedTask;
}Allow you call show/hide/close and another actions with when views/data streams events occurs
All examples can be found here:














