系列第 2/15 篇
前提:已有 Unity UI 开发经验,遇到过多个控件同步、异步保存、重复监听或页面关闭后回调。
目标:手写一个最小 MVVM 闭环,使展示逻辑无需加载 Prefab 即可测试,并明确 BindingContext 的绑定与释放边界。
最终效果不是"代码变短",而是玩家资料和设置逻辑可以在普通 C# 测试里运行;View 只持有展示控件,ViewModel 只描述展示状态,BindingContext 统一建立和解除订阅。
实现顺序是:先识别手写事件的失控点,再比较替代方案,随后完成 ObservableObject、OneWay/TwoWay/Command 与事务式 Binding,最后验证异步取消和重复 Bind/Unbind。
先给结论
在 Unity UI 中选择 MVVM,通常是因为页面具备以下特征:
- 同一份状态会显示在多个控件上;
- 状态既可能由用户修改,也可能由网络、配置或游戏逻辑修改;
- 页面包含异步命令、校验、派生文本或可用性状态;
- 希望脱离 Prefab 测试展示逻辑;
- 项目需要自动生成或统一验证绑定关系。
它的核心收益是四点:单一展示状态、明确数据方向、可测试命令、可自动化装配。
它的成本也真实存在:通知机制、绑定生命周期、调试链路和团队学习成本。没有持续状态同步的静态弹窗,用直接事件会更清楚。
1. 从一个设置页看手写事件如何失控
最初的脚本可能很自然:
csharp
public sealed class SettingsView : MonoBehaviour
{
[SerializeField] Slider volume;
[SerializeField] TMP_Text volumeText;
[SerializeField] Button save;
void Start()
{
volume.value = AudioService.Volume;
volumeText.text = $"{volume.value:P0}";
volume.onValueChanged.AddListener(value =>
{
AudioService.Volume = value;
volumeText.text = $"{value:P0}";
});
save.onClick.AddListener(Save);
}
}
随着需求增加,View 会同时承担:
- 从服务读取原始数据;
- 把浮点值格式化为百分比;
- 判定有没有未保存修改;
- 控制保存按钮是否可用;
- 发起异步保存;
- 处理异常、Loading 和重试;
- 在退出时移除监听。
这些逻辑单独看都不复杂,组合起来却很难回答:改变音量的唯一入口在哪里?服务器回写时是否会再次触发 UI 事件?保存期间页面关闭,回调还会不会更新已销毁对象?
2. 先比较 MVC、MVP、MVVM 和响应式状态
| 方案 | View 如何更新 | 用户输入如何进入逻辑 | 优势 | 主要代价 |
|---|---|---|---|---|
| 直接事件 | View 自己赋值 | 事件回调直接调 Service | 最少抽象,适合简单页面 | 状态和控件耦合,难以复用测试 |
| MVC | Controller 协调 Model/View | View 通知 Controller | 角色直观,适合流程型界面 | Unity 中 View/Controller 边界常变模糊 |
| MVP | Presenter 调用 IView |
View 把事件交给 Presenter | 流程显式、易 Mock | View 接口和逐项赋值容易膨胀 |
| MVVM | Binding 同步 ViewModel/View | TwoWay 或 Command | 展示状态集中、适合自动绑定 | 需要通知与绑定基础设施 |
| Redux/单向 Store | Store 变化重建/更新视图 | Action → Reducer | 状态演化高度可追踪 | 对局部小页面偏重,需处理粒度和性能 |
| Reactive 流 | 订阅 Observable | 事件转为流 | 擅长组合时间与异步 | 订阅生命周期、分配和调试门槛更高 |
选择标准不应是"哪一个最先进",而是页面主要复杂度在哪里。
- 如果复杂度是一次性的流程编排,MVP/Presenter 很合适。
- 如果复杂度是状态持续同步,MVVM 更自然。
- 如果复杂度是大量时序事件的组合,Reactive 可以作为局部工具。
- 如果整个产品要求可重放的统一状态,Store 架构可能更合适。
FUI 选择的是 MVVM 作为页面数据流主干,同时保留 Presenter 处理进入、退出和跨服务协调。这不是纯教科书式 MVVM,而是针对 Unity 生命周期做的分工。
3. 最小闭环:让玩家资料逻辑离开场景
玩家资料页要显示昵称、等级、金币和"是否可以领取升级奖励"。错误做法是让 MonoBehaviour 直接读取多个单例:
csharp
void Refresh()
{
nameText.text = Player.Instance.Name;
levelText.text = $"Lv.{Player.Instance.Level}";
coinText.text = Wallet.Instance.Coin.ToString();
claimButton.interactable = Reward.Instance.CanClaim(Player.Instance.Level);
}
任何数据源变化都要记得再次调用 Refresh,测试还必须准备场景、控件和全局单例。更合适的 ViewModel 保存已经准备好展示的状态:
csharp
public sealed class PlayerProfileViewModel : ObservableObject
{
readonly IProfileService profile;
readonly IRewardService rewards;
public string DisplayName { get; private set; }
public string LevelText { get; private set; }
public string CoinText { get; private set; }
public bool CanClaimLevelReward { get; private set; }
public void Refresh()
{
var snapshot = profile.GetSnapshot();
DisplayName = snapshot.Name;
LevelText = $"Lv.{snapshot.Level}";
CoinText = snapshot.Coin.ToString("N0");
CanClaimLevelReward = rewards.CanClaim(snapshot.Level);
}
}
这段代码仍是教学化简,实际属性通知可交给 FUI 的 Observable Property 生成。关键是测试不再需要 Prefab:给假的 Profile/Reward Service 输入 20 级和 12000 金币,就能断言 LevelText、CoinText 与按钮可用性。
MVVM 在这里减少的不是赋值行数,而是把"读取领域状态并翻译成展示状态"的责任从场景对象移到普通 C# 对象。后文继续用音量设置解释 TwoWay,是因为滑块更适合展示双向数据流,并不意味着整个系列固定使用设置页。
4. ViewModel 不是把 MonoBehaviour 换成普通类
一个有价值的 ViewModel 应该表达"页面准备显示给用户的状态",而不是复制业务 Model。
例如业务配置只有:
csharp
public sealed class AudioSettings
{
public float MusicVolume;
}
设置页 ViewModel 可以包含:
csharp
public partial class SettingsViewModel : ObservableObject
{
[ObservableProperty]
float volume;
public string VolumeText => $"{Volume:P0}";
public bool HasChanges { get; private set; }
public bool IsSaving { get; private set; }
public string ErrorMessage { get; private set; }
}
VolumeText、HasChanges、IsSaving 都不是领域数据,却是页面真正需要的展示状态。把它们留在 View 中,会让同一规则被多个控件和页面重复解释。
5. 数据方向要先于绑定语法
绑定不是"连起来就完了",每条绑定都要回答数据向哪里流。
OneWay:ViewModel → View
适合标题、状态文本、进度、按钮可用性:
text
ViewModel.StatusText ─────→ Label.text
View 不应该反向修改它。
TwoWay:ViewModel ↔ View
适合输入框、Toggle、Slider:
text
ViewModel.Volume ←──────→ Slider.value
TwoWay 不是默认答案。它需要回环抑制、初始同步、类型转换和校验策略。
Command:User Event → ViewModel
按钮点击不是一个持续状态,应该表达为命令:
text
Button.onClick ─────→ SaveCommand.Execute()
命令还可以暴露 CanExecute 和执行状态,避免每个 View 自己维护"保存中不可重复点击"。
6. 最小 MVVM 基础设施应该长什么样
先手写一个可测试版本,不要一开始上生成器。
csharp
public abstract class ObservableObject
{
public event Action<string> PropertyChanged;
protected bool Set<T>(ref T field, T value, string name)
{
if (EqualityComparer<T>.Default.Equals(field, value))
return false;
field = value;
PropertyChanged?.Invoke(name);
return true;
}
}
csharp
public sealed class SettingsViewModel : ObservableObject
{
float volume;
bool isSaving;
public float Volume
{
get => volume;
set
{
if (!Set(ref volume, Mathf.Clamp01(value), nameof(Volume)))
return;
PropertyChangedFor(nameof(VolumeText));
PropertyChangedFor(nameof(CanSave));
}
}
public string VolumeText => $"{Volume:P0}";
public bool CanSave => !isSaving;
}
这里最重要的不是基类实现,而是把派生状态的通知链显式化。否则 Volume 变了,VolumeText 仍然显示旧值。
7. 为什么还需要 BindingContext
常见误区是让 ViewModel 直接持有 Slider 或 Button。这样虽然少了一层,ViewModel 却重新依赖 Unity 对象,脱离场景的测试价值也消失了。
BindingContext 是装配层:
csharp
public sealed class SettingsBindingContext : IDisposable
{
readonly SettingsView view;
readonly SettingsViewModel vm;
public void Bind()
{
vm.PropertyChanged += OnPropertyChanged;
view.Volume.ValueChanged += OnVolumeChanged;
view.Save.Clicked += vm.Save;
SynchronizeAll();
}
public void Dispose()
{
vm.PropertyChanged -= OnPropertyChanged;
view.Volume.ValueChanged -= OnVolumeChanged;
view.Save.Clicked -= vm.Save;
}
}
它只负责三件事:定位两端、建立订阅、解除订阅。业务判断仍在 ViewModel 或 Presenter 中。
8. MVVM 最难的实现点
8.1 初始同步顺序
如果先订阅 View 事件,再把 ViewModel 初值写进 Slider,赋值可能被误认为用户输入。
安全顺序通常是:
- 取得全部 Element;
- 建立 VM → View 订阅;
- 使用不触发用户事件的 API 写入初值;
- 再建立 View → VM 订阅;
- 绑定成功后提交。
UGUI 中可使用 SetValueWithoutNotify 一类能力;自定义 Element 也应区分程序赋值和用户事件。
8.2 绑定必须是事务
假设前四条绑定成功,第五个 Element 在 Prefab 中不存在。若直接抛异常,前四条订阅会泄漏。
csharp
try
{
BindTitle();
BindVolume();
BindSave();
}
catch
{
UnbindSave();
UnbindVolume();
UnbindTitle();
throw;
}
更成熟的实现会记录已完成步骤,统一逆序回滚。
8.3 异步 Command 的取消
页面关闭后,异步保存仍可能返回。命令需要绑定到页面生命周期令牌:
csharp
public async ValueTask SaveAsync(CancellationToken token)
{
IsSaving = true;
try
{
await settingsService.SaveAsync(Volume, token);
}
catch (OperationCanceledException) when (token.IsCancellationRequested)
{
// 页面离开是预期取消,不显示错误。
}
finally
{
IsSaving = false;
}
}
BindingContext 解绑时要取消仍在运行的命令,不能只移除按钮监听。
8.4 列表不是很多个普通属性
背包列表需要处理新增、删除、移动、复用和 Item 自身绑定。每次集合变化都重建全部节点简单但昂贵;细粒度 diff 更高效,却显著增加复杂度。
第一版框架可以明确只支持 Reset,再用性能数据决定是否实现增量集合通知。
9. MVVM 常见坑点
坑一:所有字段都做 TwoWay
TwoWay 扩大可写入口,也增加回环和校验复杂度。默认 OneWay,只有真实用户输入才 TwoWay。
坑二:ViewModel 变成新的万能类
网络请求、存档、音频实现都塞进 ViewModel,只是把胖 View 换成胖 ViewModel。ViewModel 应依赖抽象服务,跨页面流程可由 Presenter 协调。
坑三:属性名仍然是裸字符串
Bind("Volume") 在重构时不安全。至少使用 nameof,更进一步则让 Source Generator 基于语义符号生成代码。
坑四:忽略线程和 Unity 主线程
网络任务可能在其他线程完成,但 Unity 对象只能在主线程更新。框架应规定通知在哪个线程产生,或在 Binding 层切回主线程。
坑五:把自动绑定当成零成本
通知次数、转换器分配、列表刷新、重复订阅都可能带来成本。应使用 Profiler 和针对性基准测试,而不是凭模式名称判断性能。
10. 什么时候不该用 MVVM
以下场景直接事件往往更好:
- 只有确认/取消、没有持续状态的简单弹窗;
- 生命周期短、不会复用、不会异步更新的提示;
- 原型阶段,页面结构仍在剧烈变化;
- 团队尚未建立绑定调试与验证工具。
也不必整页二选一。战斗 HUD 的数值区可以使用 OneWay Binding,复杂技能拖拽仍由专用组件处理。
11. 如何验证你的 MVVM 不是"多写了一层"
至少完成这些测试:
- 不加载 Prefab,也能测试音量钳制、派生文本和保存可用性。
- 外部服务改变状态后,所有绑定控件只更新一次。
- 程序同步 Slider 不会反向触发 ViewModel 修改。
- 任意一步 Bind 失败,已有订阅全部回滚。
- 页面关闭后,按钮事件和属性通知不再访问 View。
- 异步 Command 在关闭时取消,预期取消不显示错误。
- 重复 Bind/Unbind 有明确行为,不会叠加监听。
下一篇将解决 MVVM 之外的另一个基础问题:页面入口为什么不能继续依赖字符串,以及 typed Route 和 ViewHandle 分别提供了什么保证。
资料与源码索引
继续阅读
下一篇:别再用字符串打开页面:把导航参数、返回值和句柄都变成类型。