WPF + MVVM 实战系列03-MVVM 的边界之争,终于有人画了条“差不多”的线

by 雪隐_上班了 · 掘金主页

欢迎分享与聚合,全文转载就不必了,尊重版权,圈子就这么大,若急用可联系授权。


核心:什么放 ViewModel,什么放事件

WPF + MVVM 实战系列 · 第 3 篇

阅读时间:约 10 分钟 · 含 5 个实战对照

友情提示:看完这篇,你再也不用在"放哪"这个问题上纠结到掉头发。


一句话省流版

MVVM 不是为了"分"而分,是让"业务"和"渲染"各回各家、各找各妈。 4 问判别法帮你一刀切------哪段代码该住 ViewModel 的"主卧",哪段该挤 View 的"客厅"。


为什么 MVVM 的边界比男女关系还难搞

很多 WPF 教程教到 Binding / Command 就收工了,等你真上了项目,灵魂拷问接踵而至:

"画图工具画个矩形,坐标计算放 ViewModel 还是 View?"

"鼠标拖拽实时平移图片,要不要绕一大圈回 ViewModel 再回来?"

"Command 死活不触发,我偷偷换 Click 事件算不算背叛 MVVM 信仰?"

纯 MVVM 原教旨主义者 会拍桌子说:"全部放 ViewModel!View 只准写 InitializeComponent!"听起来很对,但实际项目里你这么干,等着你的是一地鸡毛------

  • 鼠标 Move 每秒 60 帧,每帧都走一遍 Command 调度?性能直接崩给你看。
  • 焦点、鼠标坐标、动画进度这些东西,本质就是 UI 状态,ViewModel 根本拿不到(它又没长眼睛)。
  • 为了"严格分离"而强行分离,结果 ViewModel 和 View 之间多了几百行毫无意义的转接代码,为分而分,纯属内耗

我练手 12 天踩完这些坑之后,总结出这套 4 问判别法,实战验证过,能帮你省下至少 3 天纠结时间。


4 问判别法:给代码找个"合法住所"

拿到一段代码,不知道该塞哪,依次问这 4 个问题:

问 1:这是"翻译"还是"业务"?

  • 翻译 :把数据状态转换成 UI 表现(比如 IsLoading=true → 显示"加载中",Name 变了 → 文本框内容跟着变)。这事 Binding 自动干,放 ViewModel 就行。
  • 业务 :有规则、有判断、有副作用(比如保存到数据库、算税、发通知、校验权限)。业务必须放 ViewModel,因为它要测、要复用、要维护。
csharp 复制代码
// ✅ 业务放 ViewModel:保存标注到数据库,有副作用,必须测
[RelayCommand]
private async Task SaveAnnotationAsync(AnnotationEntity ann)
{
    ann.ImageEntityId = CurrentImageId;
    await db.SaveChangesAsync();
}

// ❌ 别把业务逻辑塞 View 的 Click 事件里------测不了,改不动,以后想哭都找不着调
private void SaveButton_Click(object sender, RoutedEventArgs e)
{
    if (string.IsNullOrEmpty(txtName.Text)) return;
    db.Save(...);  // 这行代码放这儿,你的单元测试就告别它了
}

问 2:高频还是低频?

  • 低频(按钮点击、保存、删除、提交表单)→ 走 Command,优雅又体面。
  • 高频 (MouseMove、滚轮、拖拽)→ 直接 View 事件处理,别绕回 ViewModel

高频走 Command 的代价有多大?60fps 的 MouseMove,每秒触发 60 次 ICommand.Execute(),每次都要过一遍 Binding 系统,UI 线程直接累趴,掉帧掉到画面像幻灯片

csharp 复制代码
// ✅ 高频交互:直接在 View 的事件里处理,只把"最终结果"或"必要数据"传给 VM
private void DrawLayer_MouseMove(object sender, MouseEventArgs e)
{
    if (_isDragging)
    {
        var pos = e.GetPosition(this);
        _vm.Pan(pos.X - _lastMousePos.X, pos.Y - _lastMousePos.Y);  // 调 VM 方法,但不走 Command
        _lastMousePos = pos;
    }
}
csharp 复制代码
// ❌ 别用 Command 裹高频事件------Command 系统不是为"每帧都触发"设计的
[RelayCommand]
private void OnMouseMove(Point pos)  // 60fps 触发,扛不住的
{
    Pan(pos.X, pos.Y);
}

问 3:它需要 UI 的"私有状态"吗?

需要 View 内部状态(焦点、鼠标坐标、动画进度、RenderTransform、是否正在拖拽)→ 放 View

ViewModel 不知道鼠标在哪,不知道焦点在哪个文本框,它活在纯 C# 的世界里,对 UI 一无所知。

csharp 复制代码
// ❌ ViewModel 拿不到鼠标位置------它连鼠标是啥都不知道
[RelayCommand]
private void HandleClick()
{
    // 鼠标点哪了?不知道。没法知道。
}
csharp 复制代码
// ✅ View 的事件里拿鼠标位置,需要时再调 VM 方法传参
private void DrawLayer_MouseLeftButtonDown(object sender, MouseButtonEventArgs e)
{
    var pos = e.GetPosition(DrawLayer);  // View 才有资格拿这个
    _drawStart = pos;                    // 这些是"画图临时状态",放 View 完全合理
    _isDrawing = true;
    DrawLayer.CaptureMouse();
}

_drawStart_isDrawing_isDragging 这些都是 "用户正在操作中"的临时 UI 状态 ,它们跟 CaptureMouse() 这种控件方法绑定在一起,放 View 是正解,别硬塞到 ViewModel 里污染业务逻辑。

问 4:能脱离控件独立测试吗?

→ 放 ViewModel。

不能 → 放 View。

这是底线问题。ViewModel 写的对不对,dotnet test 一跑便知。View 里写的逻辑,测试覆盖率永远为零。

csharp 复制代码
// ✅ 可测:纯输入输出,不依赖任何 WPF 类型
public class TagService
{
    public IReadOnlyList<TagCount> GetStats(IEnumerable<ImageEntity> images)
    {
        // 统计逻辑,纯 C#,单元测试秒过
    }
}

// ❌ 不可测:代码里藏着 Mouse.GetPosition(this)------这玩意儿在测试环境里直接炸
public class ImageViewModel
{
    public void ApplyZoom(double factor)
    {
        var mouse = Mouse.GetPosition(this);  // 测试环境里 this 是 null,直接崩
    }
}

这条最关键。 你写得出来 dotnet test 全绿的代码,大概率 ViewModel 的边界画对了。


实战对照:5 个真实场景,一刀一刀切清楚

案例 A:画图工具------画图过程放 View,画完保存放 VM

画图完成的"保存"动作(有副作用,要入库):

csharp 复制代码
// ✅ 放 ViewModel:可单测,有业务规则
[RelayCommand]
private async Task AddAnnotationAsync(AnnotationEntity ann)
{
    if (CurrentImageId == 0) return;
    ann.ImageEntityId = CurrentImageId;
    await db.SaveChangesAsync();
    Annotations.Add(ann);
}

画图过程中的"实时预览"(用户拖着鼠标画临时图形):

csharp 复制代码
// ✅ 放 View:高频 UI 状态,跟鼠标、画布绑定
private Point _drawStart;
private bool _isDrawing;

private void DrawLayer_MouseLeftButtonDown(object sender, MouseButtonEventArgs e)
{
    _drawStart = e.GetPosition(DrawLayer);
    _isDrawing = true;
    DrawLayer.CaptureMouse();
}

private void DrawLayer_MouseLeftButtonUp(object sender, MouseButtonEventArgs e)
{
    _isDrawing = false;
    DrawLayer.ReleaseMouseCapture();
    var end = e.GetPosition(DrawLayer);
    var ann = CreateAnnotation(_vm.CurrentTool, _drawStart, end);
    if (ann != null) _ = _vm.AddAnnotationAsync(ann);  // 画完了,交给 VM 存
}

一句话原则 :用户拖鼠标画图的过程,是"渲染交互",放 View。画完了要存数据库,是"业务动作",放 VM。各管一段,谁也不累。


案例 B:鼠标拖拽平移------全放 View,VM 只暴露"能力"

平移图片是纯渲染操作,没有业务逻辑,不需要存数据库,不需要校验------放 View 就完了。

csharp 复制代码
// ✅ VM 只暴露"平移"的方法,不关心谁调、什么时候调
public void Pan(double dx, double dy)
{
    _suppressUpdate = true;
    TranslateX += dx;
    TranslateY += dy;
    _suppressUpdate = false;
    UpdateTransform();
}

// ✅ View 的事件里高频触发,直接调 VM 方法(不走 Command)
private void DrawLayer_MouseMove(object sender, MouseEventArgs e)
{
    if (_isDragging)
    {
        var pos = e.GetPosition(this);
        _vm.Pan(pos.X - _lastMousePos.X, pos.Y - _lastMousePos.Y);
        _lastMousePos = pos;
    }
}

注意这里的分工:业务能力在 VM(Pan 方法可单测),触发时机在 View(MouseMove 事件)。这叫"能力下沉,触发上浮",清晰得很。


案例 C:删除命令------全放 VM,Command 一把梭

删除有副作用(删数据库),必须走 VM,而且要用 Command 支持 CanExecute:

csharp 复制代码
// ✅ 放 ViewModel:有副作用,可单测,CanExecute 自动控制按钮灰显
[RelayCommand(CanExecute = nameof(CanDelete))]
private async Task DeleteAnnotationAsync()
{
    if (SelectedAnnotation is null) return;
    using var db = new AppDbContext();
    db.Annotations.Remove(SelectedAnnotation);
    await db.SaveChangesAsync();
    Annotations.Remove(SelectedAnnotation);
    SelectedAnnotation = null;
}

private bool CanDelete() => SelectedAnnotation is not null;

XAML 干干净净:

xaml 复制代码
<Button Content="🗑 删除" Command="{Binding DeleteAnnotationCommand}" />
<!-- 键盘快捷键也绑同一个 Command,完美复用 -->
<UserControl.InputBindings>
    <KeyBinding Key="Delete" Command="{Binding DeleteAnnotationCommand}" />
</UserControl.InputBindings>

一个 Command,按钮和键盘都能用,CanExecute 自动控制两者状态同步。这就是 MVVM 的威力------写好一次,到处复用


案例 D:打开文件对话框------View 弹窗,VM 收参

文件对话框是 UI 组件,ViewModel 不该知道它存在

csharp 复制代码
// ❌ 别在 VM 里 new OpenFileDialog------这玩意儿依赖 Microsoft.Win32,测试直接跪
[RelayCommand]
private void OpenImage()
{
    var dlg = new OpenFileDialog();
    if (dlg.ShowDialog() == true) { /* ... */ }
}

正确做法:VM 只管"给我路径我就干活",View 负责弹窗取路径

csharp 复制代码
// VM:只关心路径,不关心路径从哪来
[RelayCommand]
public async Task LoadImageAsync(string path)
{
    await LoadBitmapAsync(path);
}

// View:弹对话框 + 调 VM
private void OpenImageButton_Click(object sender, RoutedEventArgs e)
{
    var dlg = new OpenFileDialog { Filter = "图片|*.png;*.jpg;*.bmp" };
    if (dlg.ShowDialog() == true)
        _vm.LoadImageCommand.Execute(dlg.FileName);  // 把文件路径传给 VM
}

设计原则 :VM 接受 string,View 负责把"用户选文件"这个 UI 动作翻译成 string。VM 完全不知道对话框长什么样,单元测试直接传个假路径进去就能跑。


案例 E:文本输入弹窗------跟打开文件一个套路

画 Text 标注时需要弹窗让用户输入文字,跟打开文件对话框异曲同工:

csharp 复制代码
// VM:不知道"弹窗"这回事,只管拿到 text 就干活
[RelayCommand]
public async Task AddTextAnnotationAsync(string text, Point position)
{
    if (string.IsNullOrEmpty(text)) return;
    var ann = new AnnotationEntity { Kind = AnnotationKind.Text, Text = text, PositionX = position.X, PositionY = position.Y };
    await AddAnnotationAsync(ann);
}

// View:弹窗 + 取 text + 调 VM
private void AddTextAt(Point position)
{
    var dlg = new TextInputWindow { Owner = Window.GetWindow(this) };
    if (dlg.ShowDialog() == true)
        _vm.AddTextAnnotationCommand.Execute((dlg.InputText, position));  // 元组传参
}

TextInputWindow 是纯 WPF Window,UI 依赖,绝不放 VM 。VM 拿到 string 和坐标之后该存存该算算,干净利落。


边界争议:Command 不工作的时候,能不能"破戒"?

实战里我真遇到过这个场景:[RelayCommand] 配 XAML CommandParameter="Rectangle" 死活不触发 。调试半天才发现------XAML 传的是字符串 "Rectangle",但 VM 方法签名是 SelectTool(AnnotationKind kind),类型对不上,Command 系统直接静默失败,连个报错都没有。

解决方案有三条路:

解法 1:VM 参数改 string,内部解析(最省事)

csharp 复制代码
[RelayCommand]
private void SelectTool(string kindName)
{
    if (Enum.TryParse(kindName, out AnnotationKind kind))
        CurrentTool = kind;
}

XAML 不用改,代码最简洁。但前提是你得知道这个坑才会这么写。

解法 2:XAML 用 x:Static 传真正的 enum(最正统)

xaml 复制代码
<Button Content="矩形" Command="{Binding SelectToolCommand}"
        CommandParameter="{x:Static m:AnnotationKind.Rectangle}" />
csharp 复制代码
[RelayCommand]
private void SelectTool(AnnotationKind kind)
{
    CurrentTool = kind;
}

XAML 稍微啰嗦一点,但 type-safe,编译时就能检查。

解法 3:绕开 Command,用 Click 事件 + Tag(最"不体面"但最管用)

xaml 复制代码
<Button Content="矩形" Click="OnToolSelected" Tag="Rectangle" />
csharp 复制代码
private void OnToolSelected(object sender, RoutedEventArgs e)
{
    if (sender is not Button btn || btn.Tag is not string s) return;
    if (Enum.TryParse(s, out AnnotationKind kind))
        _vm.CurrentTool = kind;  // 业务逻辑仍在 VM,只是"触发方式"从 Command 变成了 Event
}

MVVM 纪律小让步 ------但业务逻辑 _vm.CurrentTool = kind 仍然在 VM 里,View 只负责"把用户点击翻译成 enum 赋值"。这算不算破戒?算轻微破戒,但功能优先,别为了教条让项目卡壳

什么时候用解法 3 :反复折腾 Command 就是不触发、CommandParameter 类型对不上、XAML 改 x:Static 太丑或者改动范围太大。先用 Event 让功能跑起来,回头再优化------代码是写给人看的,但首先是写给机器跑的


小结:MVVM 边界判定速查图

text 复制代码
一段代码,你纠结放哪
        │
        ▼
   ┌───问:是 UI 状态(焦点/鼠标/动画)吗?──── Yes ──▶ 放 View(它本来就住那儿)
   │       No
   │
   ├───问:是高频调用(>10次/秒)吗?───────── Yes ──▶ View 事件触发,VM 暴露方法(别走 Command)
   │       No
   │
   ├───问:需要 UI 控件(对话框/Window)吗?─── Yes ──▶ View 弹窗,VM 接收参数(各司其职)
   │       No
   │
   ├───问:有副作用/业务规则/需要测试吗?───── Yes ──▶ 放 ViewModel(必须的)
   │       No
   │
   └───问:只是 View ↔ Model 的翻译吗?─────── Yes ──▶ 放 ViewModel(Binding 帮你自动干)

底线只有一条:业务逻辑永远在 ViewModel。 其他的,可以"灵活处理",别让教条绑架了生产力。


最后说句心里话:MVVM 边界这事,没有绝对正确的答案,只有"当下最合适"的选择。4 问判别法是我用 12 天踩坑换来的,希望能帮你省下同样的时间。我们下篇见,记得把键盘放在顺手的位置,因为下一篇全是"我 X,原来是这样"的瞬间。⌨️💥

相关推荐
影寂ldy1 小时前
C# WinForm TCP聊天室(服务端+客户端)
windows·tcp/ip·c#
七仔啊2 小时前
C#、.NET 、ASP.NET Core、Visual Studio全系列快速入门
c#·asp.net·.net
李高钢14 小时前
REST vs gRPC 选型对比与迁移实践:从原理到落地
c#·集成·winforms
一个帅气昵称啊15 小时前
.Net C# AI智能体开发-快速开始
开发语言·c#·.net
半亩码田18 小时前
C#转Python第3.6篇:Python 的 @property 比 C# 的 get/set 更灵活
java·python·c#
楠楠子呀1 天前
独立站聊天转化全链路:从二维码引流到数据复盘
java·javascript·数据仓库·python·c#·自动化·etl
csdn_aspnet1 天前
C# 第k个最小元素(K’th Smallest Element)
算法·c#·排序算法
影寂ldy1 天前
Modbus-ASCII 协议 + LRC校验
笔记·网络协议·c#
自己的九又四分之三站台1 天前
C# 接入 RasterLite2:从原生 DLL 加载到 Coverage、Section、Tile 验证
开发语言·c#·地理信息