Unity MVVM 最小实现:从手写事件到可测试 BindingContext

系列第 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 金币,就能断言 LevelTextCoinText 与按钮可用性。

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; }
}

VolumeTextHasChangesIsSaving 都不是领域数据,却是页面真正需要的展示状态。把它们留在 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 直接持有 SliderButton。这样虽然少了一层,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,赋值可能被误认为用户输入。

安全顺序通常是:

  1. 取得全部 Element;
  2. 建立 VM → View 订阅;
  3. 使用不触发用户事件的 API 写入初值;
  4. 再建立 View → VM 订阅;
  5. 绑定成功后提交。

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 分别提供了什么保证。

资料与源码索引

继续阅读

下一篇:别再用字符串打开页面:把导航参数、返回值和句柄都变成类型。

相关推荐
EzSharer18 小时前
为什么你开发的 Unity 游戏越玩越烫?(系列 · 第 1 篇)
unity3d
陈言必行1 天前
Unity开发实战技巧:脚本优化与性能提升
unity3d
SmalBox1 天前
【高级着色器】Unity实现-卡通风格水着色器
unity3d·游戏开发·图形学
_zhourui_h_2 天前
Unity WebSocket全平台极限兼容:浏览器禁掉原生 Socket 后,WebGL 到底怎么连服务器?
unity3d
SmalBox2 天前
【高级着色器】Unity实现-彩虹泡泡着色器
unity3d·游戏开发·图形学
fujisheng6613 天前
从 GetTypes() 到强类型 Route:FUI Source Generator 的设计演进
unity3d
SmalBox3 天前
【动态着色】Unity实现3D扫描线
unity3d·游戏开发·图形学
SmalBox4 天前
【动态着色】Unity 实现消融效果
unity3d·游戏开发·图形学
_zhourui_h_4 天前
我以为 ECSList 已经够快了,直到我直接拿到了 Column
unity3d