474 lines
14 KiB
Markdown
474 lines
14 KiB
Markdown
# uFrame MVVM Core 分析
|
||
|
||
日期:2026-06-12
|
||
|
||
## 结论
|
||
|
||
uFrame MVVM 的核心价值不在它的 Kernel、IOC 启动或代码生成器,而在运行时 MVVM 的几个机制:
|
||
|
||
- `ViewModel` 持有可观察状态。
|
||
- `P<T>` 把 ViewModel 属性包装成可订阅的数据流。
|
||
- `Signal<TCommand>` 把 UI 操作表达成命令流。
|
||
- `ViewBase` 通过 `Bind / Unbind` 管理订阅生命周期。
|
||
- `ViewService` 负责把 View 与 ViewModel 对接。
|
||
|
||
但这套 MVVM 被 uFrame 自身的全局 Kernel、全局容器、命名约定、`Resources.Load` 和设计器生成代码强绑定。它可以作为反面边界样本和局部机制参考,不适合作为 FlowScope Kernel 或 UI 模块的直接原型。
|
||
|
||
FlowScope 可以吸收“可观察状态 + 命令流 + 绑定生命周期作用域”,但不应吸收它的全局 IOC 查找、ViewModel 自动解析、Controller 命名约定和资源自动定位。
|
||
|
||
## 源码范围
|
||
|
||
本次分析基于 `uFrame/uFrame.MVVM` 当前公开仓库源码,重点阅读:
|
||
|
||
- [`ViewModel.cs`](https://github.com/uFrame/uFrame.MVVM/blob/master/uFrame.MVVM/Source/Runtime/ViewModels/ViewModel.cs)
|
||
- [`ModelPropertyBase.cs`](https://github.com/uFrame/uFrame.MVVM/blob/master/uFrame.MVVM/Source/Runtime/ViewModels/ModelPropertyBase.cs)
|
||
- [`Signal.cs`](https://github.com/uFrame/uFrame.MVVM/blob/master/uFrame.MVVM/Source/Runtime/ViewModels/Signal.cs)
|
||
- [`ViewBase.cs`](https://github.com/uFrame/uFrame.MVVM/blob/master/uFrame.MVVM/Source/Runtime/Views/ViewBase.cs)
|
||
- [`View.cs`](https://github.com/uFrame/uFrame.MVVM/blob/master/uFrame.MVVM/Source/Runtime/Views/View.cs)
|
||
- [`Controller.cs`](https://github.com/uFrame/uFrame.MVVM/blob/master/uFrame.MVVM/Source/Runtime/Controllers/Controller.cs)
|
||
- [`ViewService.cs`](https://github.com/uFrame/uFrame.MVVM/blob/master/uFrame.MVVM/Source/Runtime/Services/ViewService.cs)
|
||
- [`MVVMKernelExtensions.cs`](https://github.com/uFrame/uFrame.MVVM/blob/master/uFrame.MVVM/Source/Runtime/MVVMKernelExtensions.cs)
|
||
- [`ViewBindings.cs`](https://github.com/uFrame/uFrame.MVVM/blob/master/uFrame.MVVM/Source/Runtime/Bindings/ViewBindings.cs)
|
||
- [`ViewResolver.cs`](https://github.com/uFrame/uFrame.MVVM/blob/master/uFrame.MVVM/Source/Runtime/Views/ViewResolver.cs)
|
||
|
||
## 运行时结构
|
||
|
||
uFrame MVVM 的运行时可以拆成五组对象:
|
||
|
||
```text
|
||
ViewModel 层
|
||
ViewModel
|
||
P<T>
|
||
ModelCollection<T>
|
||
Signal<TCommand>
|
||
ViewModelCommand
|
||
|
||
View 层
|
||
ViewBase
|
||
View<TModel>
|
||
ViewComponent
|
||
ViewBindings
|
||
|
||
创建与连接层
|
||
Controller
|
||
ViewService
|
||
ViewResolver
|
||
|
||
绑定层
|
||
Binding
|
||
ModelPropertyBinding
|
||
ModelViewPropertyBinding
|
||
ModelViewModelCollectionBinding
|
||
|
||
全局基础设施
|
||
uFrameKernel.Container
|
||
IEventAggregator
|
||
SystemService / SystemServiceMonoBehavior
|
||
```
|
||
|
||
它不是一个独立、纯粹的 MVVM runtime。`ViewModel`、`View`、`Controller` 和 `ViewService` 都会触碰 uFrame Kernel 或全局容器。
|
||
|
||
## ViewModel
|
||
|
||
`ViewModel` 是所有生成 ViewModel 的基类。它承担了状态、绑定、序列化、事件发布、销毁通知等多种职责。
|
||
|
||
关键职责:
|
||
|
||
- 通过 `FillProperties` 暴露属性元信息。
|
||
- 通过 `FillCommands` 暴露命令元信息。
|
||
- 持有 `Bindings`,用于释放订阅。
|
||
- 持有 `Identifier`,用于全局容器按 id 查找。
|
||
- 通过 `PropertyChanged` 通知属性变化。
|
||
- `Dispose` 时发布 `ViewModelDestroyedEvent`。
|
||
|
||
问题是它太重。一个干净的 ViewModel 应该主要表达 UI 状态和 UI 意图,但这里的 `ViewModel` 混入了:
|
||
|
||
- 序列化协议。
|
||
- `IBindable`。
|
||
- `IDisposable`。
|
||
- 全局事件聚合器。
|
||
- binding 容器。
|
||
- 引用计数。
|
||
- controller wiring hook。
|
||
|
||
这会让 ViewModel 从“状态对象”变成“框架对象”。长期项目里,这种设计会让测试、复用和生命周期判断都变复杂。
|
||
|
||
## P<T>
|
||
|
||
`P<T>` 是 uFrame MVVM 最值得看的部分。它把单个属性封装成 observable property:
|
||
|
||
```csharp
|
||
public class P<T> : ISubject<T>, IObservableProperty, ISimpleNotifyPropertyChanged
|
||
```
|
||
|
||
它提供:
|
||
|
||
- `Value`:当前值。
|
||
- `LastValue`:上一次值。
|
||
- `ChangedObservable`:只在值变化时通知。
|
||
- `ObjectValue`:非泛型访问入口。
|
||
- `Owner`:所属 ViewModel。
|
||
- `PropertyName`:属性名。
|
||
- `ToComputed`:根据其他 observable property 计算派生值。
|
||
|
||
View 侧可以订阅 `P<T>`,属性变化时刷新 UI。这个机制适合 Unity UI,因为它避免了大量手动 `Refresh()` 和每帧轮询。
|
||
|
||
可取点:
|
||
|
||
- 属性变化可以显式订阅。
|
||
- 派生属性可以声明依赖。
|
||
- View 绑定时可以立即应用当前值。
|
||
- IDisposable 可以挂到绑定作用域统一释放。
|
||
|
||
风险点:
|
||
|
||
- `Value` setter 没有先比较新旧值,任何赋值都会触发通知。
|
||
- `Owner` 让 property 反向知道 ViewModel。
|
||
- `P<T>` 同时实现 subject、property、notify,职责偏多。
|
||
- `ChangedObservable` 依赖 `Value` 和 `LastValue` 的外部状态,语义不够直观。
|
||
|
||
FlowScope 如果吸收这个思路,应改成更小的 `ObservableProperty<T>`:
|
||
|
||
```text
|
||
只负责保存值、比较值、发布变化、释放订阅。
|
||
不要知道全局 Kernel。
|
||
不要知道 View。
|
||
不要知道资源或 Controller。
|
||
```
|
||
|
||
## Signal<TCommand>
|
||
|
||
`Signal<TCommand>` 用来表达 ViewModel 命令:
|
||
|
||
```csharp
|
||
public class Signal<TClass> : ISubject<TClass>, ISignal<TClass>
|
||
where TClass : IViewModelCommand, new()
|
||
```
|
||
|
||
它的行为是:
|
||
|
||
1. View 或绑定层构造 command。
|
||
2. 调用 `Signal.OnNext(command)`。
|
||
3. `Signal` 把 `Sender` 设置为当前 ViewModel。
|
||
4. 执行本地 `Action`。
|
||
5. 推送给订阅者。
|
||
|
||
这个方向是好的:UI 操作不应该直接调用业务系统,而应该变成可观察的 command 或 intent。
|
||
|
||
可取点:
|
||
|
||
- UI 输入被建模为显式命令。
|
||
- 命令可以订阅、组合、测试。
|
||
- View 与业务处理之间不需要直接互相引用。
|
||
|
||
风险点:
|
||
|
||
- command 的 sender 被 `Signal` 自动写入,有隐式副作用。
|
||
- 命令流最终仍然容易接到全局 EventAggregator。
|
||
- 生成器会为每个命令生成强约定代码,手写维护成本高。
|
||
|
||
FlowScope 可以吸收 command stream,但建议命名为更中性的 `UIIntent` / `ViewAction` / `FeatureCommand`,并放在 feature scope 内,不进入 Kernel core。
|
||
|
||
## ViewBase / View<TModel>
|
||
|
||
`ViewBase` 是 View 与 ViewModel 的连接点。它提供:
|
||
|
||
- `ViewModelObject`:当前绑定的 ViewModel。
|
||
- `Bind()`:用户编写订阅逻辑。
|
||
- `PreBind()`:生成器插入绑定逻辑。
|
||
- `AfterBind()`:绑定完成后的 hook。
|
||
- `Unbind()`:释放绑定。
|
||
- `AddBinding()`:把 disposable 放到当前 View 的绑定列表。
|
||
- `BindOnStart`:是否启动时自动绑定。
|
||
- `DisposeViewModelOnDestroy`:View 销毁时是否销毁 ViewModel。
|
||
|
||
`View<TModel>` 只是给 `ViewBase` 加了泛型 Model 访问:
|
||
|
||
```csharp
|
||
public abstract class View<TModel> : ViewBase where TModel : ViewModel, new()
|
||
```
|
||
|
||
可取点:
|
||
|
||
- View 的绑定逻辑有明确生命周期。
|
||
- 订阅统一挂到 binding scope。
|
||
- View 只响应 ViewModel 的状态,不需要每帧刷 UI。
|
||
- View 销毁时可以集中释放订阅。
|
||
|
||
问题点:
|
||
|
||
- `ViewBase.KernelLoaded()` 会调用 `uFrameKernel.Container.Inject(this)`。
|
||
- `OnDestroy()` 依赖 `uFrameKernel.IsKernelLoaded` 和全局事件。
|
||
- View 的 `Bindings` 实际挂在 `ViewModelObject.Bindings[ViewId]` 上,View 和 ViewModel 的生命周期互相缠住。
|
||
- `References` 引用计数用于判断 ViewModel 是否销毁,复杂且脆弱。
|
||
|
||
FlowScope 可以借鉴 `Bind / Unbind / AddBinding`,但绑定容器应属于 View 或 feature-local binding scope,而不是塞进 ViewModel。
|
||
|
||
## Controller
|
||
|
||
uFrame 的 `Controller` 是特殊的 `SystemService`,主要负责创建和初始化 ViewModel。
|
||
|
||
流程大致是:
|
||
|
||
```text
|
||
Controller.Create(identifier)
|
||
-> CreateEmpty(identifier)
|
||
-> RegisterViewModel(vm, identifier)
|
||
-> Initialize(vm)
|
||
-> Publish ViewModelCreatedEvent
|
||
```
|
||
|
||
问题在于 `CreateEmpty` 内部直接注册到全局容器:
|
||
|
||
```text
|
||
uFrameKernel.Container.RegisterViewModel(vm, identifier)
|
||
```
|
||
|
||
`MVVMKernelExtensions.CreateViewModel(type)` 又会通过 ViewModel 类型名去全局容器找 Controller:
|
||
|
||
```text
|
||
Resolve<Controller>(type.Name)
|
||
```
|
||
|
||
这是强命名约定 + 服务定位器。它表面上把 ViewModel 创建集中到了 Controller,实际上把依赖关系藏进全局容器和类型名字符串。
|
||
|
||
FlowScope 不应该吸收这套 Controller 模式。更合适的是:
|
||
|
||
```text
|
||
Feature 明确创建 ViewModel。
|
||
Feature 明确创建/绑定 View。
|
||
FeatureContext 提供本 feature 需要的服务。
|
||
UI module 不通过全局容器按名字找 Controller。
|
||
```
|
||
|
||
## ViewService / ViewResolver
|
||
|
||
`ViewService` 负责:
|
||
|
||
- 监听 `InstantiateViewCommand`。
|
||
- 根据 ViewModel 找 View prefab。
|
||
- 实例化 View。
|
||
- 在 View 创建时补齐 ViewModel。
|
||
- View 销毁时移除记录。
|
||
- ViewModel 销毁时从全局容器移除实例。
|
||
|
||
`ViewResolver` 默认根据 ViewModel 类型名找 prefab:
|
||
|
||
```text
|
||
PlayerViewModel -> Player -> Resources.Load("Player")
|
||
```
|
||
|
||
这是老式 Unity 框架常见做法,但不适合 FlowScope:
|
||
|
||
- 资源路径是隐式命名约定。
|
||
- 默认绑定到 `Resources.Load`。
|
||
- 不支持现代资源后端的显式 handle、group、release。
|
||
- 不利于 Addressables / YooAsset / AssetBundle / 自定义资源系统适配。
|
||
- View 的创建和 ViewModel 的创建混在同一个服务里。
|
||
|
||
FlowScope 如果需要 UI 模块,可以把它拆成:
|
||
|
||
```text
|
||
IViewFactory
|
||
负责创建 View,不负责创建 ViewModel。
|
||
|
||
IViewBinder<TView, TViewModel>
|
||
负责绑定和解绑。
|
||
|
||
IUIViewRegistry 或 IViewCatalog
|
||
负责显式注册 view key 到 prefab/address。
|
||
|
||
IResourceService adapter
|
||
负责真实资源加载和释放。
|
||
```
|
||
|
||
## Binding
|
||
|
||
uFrame 的 binding 层包括:
|
||
|
||
- `Binding`
|
||
- `ModelPropertyBinding`
|
||
- `ModelViewPropertyBinding`
|
||
- `ModelViewModelCollectionBinding`
|
||
- `ViewBindings` 扩展方法
|
||
|
||
最实用的是 `ViewBindings.BindProperty`:
|
||
|
||
```text
|
||
立即用当前值刷新一次 UI。
|
||
订阅后续变化。
|
||
把 IDisposable 加入 bindable 的绑定列表。
|
||
```
|
||
|
||
这是 FlowScope 可以吸收的重点:
|
||
|
||
```csharp
|
||
bindingScope.Bind(vm.Title, value => titleText.text = value);
|
||
```
|
||
|
||
但不要吸收它的反射绑定和生成器绑定作为 core。反射/生成器可以是工具层能力,不应该成为 UI runtime 的基本前提。
|
||
|
||
## 与 FlowScope Kernel 的关系
|
||
|
||
uFrame MVVM 不应该进入 FlowScope Kernel core。
|
||
|
||
FlowScope Kernel 已经明确应保持机制小核:
|
||
|
||
```text
|
||
Container
|
||
AppKernel
|
||
StartupPipeline
|
||
FeatureHost
|
||
FeatureInstance
|
||
FeatureContext
|
||
FeatureLifetime
|
||
FeatureSlot
|
||
FeaturePolicy
|
||
```
|
||
|
||
MVVM 属于更高层的 UI module 或 feature-local module。Kernel 最多提供 feature 生命周期和上下文,不提供:
|
||
|
||
- ViewModel 基类。
|
||
- View 自动绑定。
|
||
- UI prefab 查找。
|
||
- 命令总线。
|
||
- 全局 ViewService。
|
||
- 全局事件聚合器。
|
||
- 全局 IOC 注入 View。
|
||
|
||
推荐边界:
|
||
|
||
```text
|
||
FlowScope.Kernel
|
||
只负责 Feature 生命周期和 Slot/Policy 机制。
|
||
|
||
FlowScope.UI
|
||
提供 ViewModel、ObservableProperty、BindingScope、ViewBinder。
|
||
|
||
FlowScope.Resources
|
||
提供资源加载接口和后端适配。
|
||
|
||
Game Feature
|
||
自己声明 ViewModel、View、Binder、Command handler。
|
||
```
|
||
|
||
## 可吸收清单
|
||
|
||
可以吸收:
|
||
|
||
- `ObservableProperty<T>` 思路。
|
||
- `ObservableCollection<T>` 思路。
|
||
- UI command / intent stream。
|
||
- `Bind / Unbind` 生命周期。
|
||
- `IDisposable` binding scope。
|
||
- View 订阅 ViewModel,ViewModel 不直接操作 View。
|
||
- View 创建和 ViewModel 绑定分离。
|
||
- 集合绑定生成子 View 的思路,但要显式管理资源释放。
|
||
|
||
不应吸收:
|
||
|
||
- `uFrameKernel.Container` 全局创建 ViewModel。
|
||
- `Container.Inject(view)`。
|
||
- 按 `ViewModel` 类型名查找 `Controller`。
|
||
- 按 `ViewModel` 类型名 `Resources.Load` prefab。
|
||
- ViewModel 持有 View 绑定列表。
|
||
- ViewModel 引用计数决定销毁。
|
||
- 全局 EventAggregator 作为 UI command 默认通道。
|
||
- 代码生成器作为 runtime 必需条件。
|
||
- ViewModel 同时承担序列化、绑定、事件、销毁、controller hook。
|
||
|
||
## FlowScope 可采用的最小 UI MVVM 形态
|
||
|
||
建议的最小形态:
|
||
|
||
```text
|
||
ObservableProperty<T>
|
||
保存值并发布变化。
|
||
|
||
BindingScope
|
||
持有 IDisposable 列表,Dispose 时统一释放。
|
||
|
||
IViewModel
|
||
标记 UI 状态对象,不依赖 Kernel。
|
||
|
||
IViewBinder<TView, TViewModel>
|
||
Bind(view, vm, scope)
|
||
Unbind 由 scope.Dispose 承担。
|
||
|
||
UIView<TViewModel>
|
||
MonoBehaviour,只持有 Unity UI 引用和当前 ViewModel。
|
||
|
||
UICommand<T>
|
||
feature-local command,不走全局事件总线。
|
||
```
|
||
|
||
示意:
|
||
|
||
```csharp
|
||
public sealed class BindingScope : IDisposable
|
||
{
|
||
private readonly List<IDisposable> _items = new();
|
||
|
||
public T Add<T>(T item) where T : IDisposable
|
||
{
|
||
_items.Add(item);
|
||
return item;
|
||
}
|
||
|
||
public void Dispose()
|
||
{
|
||
foreach (var item in _items)
|
||
item.Dispose();
|
||
|
||
_items.Clear();
|
||
}
|
||
}
|
||
```
|
||
|
||
```csharp
|
||
public sealed class MainMenuViewModel
|
||
{
|
||
public ObservableProperty<string> Title { get; } = new("Main Menu");
|
||
public Subject<StartGameIntent> StartGame { get; } = new();
|
||
}
|
||
```
|
||
|
||
```csharp
|
||
public sealed class MainMenuBinder
|
||
{
|
||
public void Bind(MainMenuView view, MainMenuViewModel vm, BindingScope scope)
|
||
{
|
||
scope.Bind(vm.Title, value => view.TitleText.text = value);
|
||
scope.Add(view.StartButton.OnClickAsObservable()
|
||
.Subscribe(_ => vm.StartGame.OnNext(new StartGameIntent())));
|
||
}
|
||
}
|
||
```
|
||
|
||
关键点是:这里没有全局容器查找,没有 `Resources.Load` 约定,没有 Kernel 注入 View,也没有 ViewModel 引用计数。
|
||
|
||
## 最终判断
|
||
|
||
uFrame MVVM 的局部机制比它的 Kernel 更有参考价值,但必须拆开看。
|
||
|
||
真正值得保留的是:
|
||
|
||
```text
|
||
状态可观察
|
||
操作命令化
|
||
绑定有生命周期
|
||
View 不直接写业务
|
||
```
|
||
|
||
必须避免的是:
|
||
|
||
```text
|
||
全局容器创建一切
|
||
命名约定连接一切
|
||
ViewModel 变成框架对象
|
||
资源加载隐藏在 ViewResolver
|
||
UI 模块污染 Kernel core
|
||
```
|
||
|
||
对 FlowScope 来说,uFrame MVVM 最好的用途不是成为模板,而是提醒我们:UI MVVM 应该是 Kernel 之上的可选模块,且必须保持 feature-local、显式绑定、显式资源、显式生命周期。
|