写 WPF 这么多年,凡是正经用 MVVM 架构做业务的,早晚都得跟“按钮事件”怼上一架。刚接触 MVVM 的时候我也犯过嘀咕:界面上一个按钮,用户点一下要触发一段逻辑,这不是明摆着写 Click 事件就完事了吗?绕一圈搞什么 Command?等真的在一个十来个窗口的中型项目里用事件驱动写了一遍,又用命令绑定重构了一遍,我才彻底明白,Command 绑定这个东西压根不是语法糖,它是 MVVM 能不能落地的生死线。
这篇文章就把我实战中总结的 Button 按钮事件绑定经验全部倒出来:ICommand 底层原理、手写 RelayCommand 的细节、5 种高频场景、还有那些你在文档里查不到的报错和排查技巧。不管你是刚入门 WPF 的新手,还是已经写了两三年 MVVM 想补补基础的老手,这文都能让你少走弯路。
1. 为什么 MVVM 中的按钮不能直接写 Click 事件
很多初学者最容易卡死的点就在这里:我明明能用btn.Click += Btn_Click解决事情,非要用 Command,不是脱裤子放屁吗?表面看确实是多绕了一步,但这一步绕的是架构上的命根子。
1.1 事件处理器的致命缺陷:ViewModel 与 View 的深度耦合
直接写事件处理器,最典型的写法是这样的:在 code-behind 里拿控件、取输入、查状态,再调用 ViewModel 的某个方法或属性来完成业务逻辑。比如一个登录按钮:
private void LoginBtn_Click(object sender, RoutedEventArgs e) { var username = UsernameTextBox.Text; var password = PasswordBox.Password; var vm = (LoginViewModel)DataContext; vm.Username = username; vm.Password = password; await vm.LoginAsync(); }这事乍一看没啥问题,但再往下发展就变味了:如果某个“登录”动作不止按钮触发,还有回车键触发、还有登录页之外的快捷入口触发,你怎么办?三个地方各写一遍类似逻辑?还是全部耦合到 code-behind?
更深层的问题是这套代码没法做单元测试。你不可能在测试环境里 new 一个窗体去点按钮。逻辑跟控件绑在一块,ViewModel 想单独验证,就得搭界面。而且你换个 UI 框架、换套界面风格,code-behind 里的代码大概率得跟着全盘重写。这就是典型的业务逻辑和视图逻辑搅成一锅粥。
用一个贴切的比喻:按钮是前台的电话分机,事件处理器是“接线员”,ViewModel 是后台的客服团队。直接写 Click 意味着每一次电话进来都要靠人工喊一嗓子“小王,这单是你的”,接线员一旦休假,全公司都得瘫痪。而命令绑定等于给每个业务动作配了一个专属分机号码,谁打过来系统自动路由,接线员换不换根本不重要。
1.2 命令绑定的核心思路:按钮只表达意图,不关心实现
命令绑定的本质,是让按钮这种 View 控件只负责一件事:把“用户点击了”这个 UI 事件,转译成“我要执行某个业务动作”的意图。按钮不关心这个动作具体怎么实现、数据从哪来,它只负责触发。
ViewModel 这边用一个实现了ICommand接口的对象来接收这个意图。接口定义清楚“能执行吗”和“执行什么”,至于界面上是 Button、MenuItem、Hyperlink 还是某个自定义控件,ViewModel 一概不知。这样带来的直接好处有几点:
- View 可以随便换皮肤、换控件类型,ViewModel 代码一行不用改
- 业务逻辑可以被单元测试覆盖,因为命令对象就是一个普通的可实例化类
- 多个入口可以复用同一条命令,比如列表右键删除、工具栏删除按钮、快捷键删除,全部指向同一个我命令实例
1.3 对比案例:同一功能,两种写法的差异
我拿“删除选中行”这个典型功能做个对比。代码里有个ObservableCollection<Item>,界面是一个ListBox加一个删除按钮。
事件处理器写法(注意大量代码在 View 层处理业务):
private void DeleteBtn_Click(object sender, RoutedEventArgs e) { if (ItemListBox.SelectedItem is not Item selected) return; _vm.Items.Remove(selected); _vm.StatusMessage = $"已删除 {selected.Name}"; }命令绑定写法:
<ListBox ItemsSource="{Binding Items}" SelectedItem="{Binding SelectedItem}" /> <Button Content="删除" Command="{Binding DeleteCommand}" />ViewModel 里定义一个ICommand DeleteCommand,执行逻辑就是“删除选中项并更新状态”。代码干净了多少是一目了然的,关键是这个 DeleteCommand 可以被单独测试:构造一个装了两条数据的集合,选中其中一条,执行命令,断言集合里只剩一条。整个过程没有任何 UI 参与。
2. ICommand 接口没那么神秘:命令的核心原理与实现
要用好命令绑定,光会写 XAML 是不行的,你得把ICommand这个接口从里到外看明白。别看它只有三个成员,每一个背后都藏着一个坑。
2.1 Execute、CanExecute、CanExecuteChanged 分别代表什么
public interface ICommand { bool CanExecute(object? parameter); void Execute(object? parameter); event EventHandler? CanExecuteChanged; }这三个成员各有各的职责,缺一个都不行:
- Execute:命令真正执行的方法体,相当于“命令被触发后干什么”。
- CanExecute:返回
bool,决定命令当前是否可以执行。WPF 里按钮的 IsEnabled 属性会自动跟这个返回值联动,返回 false 时按钮就变灰,点不下去。这是命令绑定最爽的地方——你不用手动管按钮的可用状态。 - CanExecuteChanged:这个事件是告诉按钮“命令的可执行状态变了,你赶紧重新问一下 CanExecute”。当你不想让按钮可点了,就触发这个事件,UI 自动去查一次 CanExecute,查到 false 按钮就禁用了。
理解这三个成员,可以把 ICommand 看成一个“自带开关的遥控器”。Execute是遥控器上的确认键,CanExecute是遥控器的电源开关,CanExecuteChanged是开关拨动后发给电视机的信号。没有信号的遥控器,电视永远不会知道你已经拨动了开关。
2.2 手写一个 RelayCommand:几个必须注意的细节
网上能搜到一堆 RelayCommand 的实现,但很多都是教学版——能用,离好使还有距离。先来看最基础的版本:
public class RelayCommand : ICommand { private readonly Action _execute; private readonly Func<bool>? _canExecute; public RelayCommand(Action execute, Func<bool>? canExecute = null) { _execute = execute ?? throw new ArgumentNullException(nameof(execute)); _canExecute = canExecute; } public bool CanExecute(object? parameter) => _canExecute?.Invoke() ?? true; public void Execute(object? parameter) => _execute(); public event EventHandler? CanExecuteChanged; }这个版本应付 Demo 没问题,但实际项目里很快会碰到几个问题。
第一个坑是CanExecuteChanged怎么引发。很多人图省事,直接在事件声明里写CommandManager.RequerySuggested,也就是让 WPF 在每次界面交互后自动重新评估所有命令的 CanExecute:
public event EventHandler? CanExecuteChanged { add => CommandManager.RequerySuggested += value; remove => CommandManager.RequerySuggested -= value; }CommandManager.RequerySuggested的触发频率很高,每次鼠标键盘交互都会引发一波评估,命令少时无所谓,命令多了性能就会开始发虚。而且它有个特性:在 ListBox、DataGrid 这类虚拟化容器里,有些命令的 CanExecute 变化它不一定能及时感知。我踩过这个坑,列表滚动半天按钮状态不变,最后定位出来就是 RequerySuggested 没触发到。所以实际项目里我更推荐手动触发方式——在需要的时候显式调用RaiseCanExecuteChanged()。
第二个坑是CanExecute委托异常。执行逻辑如果抛异常,WPF 默认行为有时候不够明确。更稳的做法是把异常在命令执行层处理掉,或者用一个自定义异常处理回调。实战里我习惯给 RelayCommand 加一个catch兜底,把异常直接转交给全局的日志模块。
第三个坑是async void。命令的 Execute 返回 void 是接口规定的,如果你把一个async方法直接塞给 RelayCommand,等于制造了一个async void方法。这在 WPF 里意味着:异常如果没被捕获,会直接崩进程。所以异步命令必须单独实现,而不是把asynclambda 直接塞进Action。这个后面异步场景会细说。
我日常用的是带参数和显式刷新功能的改良版:
public class RelayCommand : ICommand { private readonly Action<object?> _execute; private readonly Func<object?, bool>? _canExecute; private readonly Action<Exception>? _exceptionHandler; public RelayCommand(Action<object?> execute, Func<object?, bool>? canExecute = null, Action<Exception>? exceptionHandler = null) { _execute = execute ?? throw new ArgumentNullException(nameof(execute)); _canExecute = canExecute; _exceptionHandler = exceptionHandler ?? (ex => Debug.WriteLine($"Command error: {ex}")); } public bool CanExecute(object? parameter) => _canExecute?.Invoke(parameter) ?? true; public void Execute(object? parameter) { try { _execute(parameter); } catch (Exception ex) { _exceptionHandler?.Invoke(ex); } } public void RaiseCanExecuteChanged() => CanExecuteChanged?.Invoke(this, EventArgs.Empty); public event EventHandler? CanExecuteChanged; }这样设计的好处是:命令的 CanExecute 支持参数化判断,异常有了统一的兜底出口,而且RaiseCanExecuteChanged可以由外部主动调用,不依赖 CommandManager 的自动刷新机制。
2.3 自己造轮子还是用现成框架:我的推荐
手写 RelayCommand 有一个明确的好处:你能把原理吃透,出问题不容易慌。但实际项目里,我强烈建议你优先用现成的 MVVM 工具包,除非你的项目特殊到连 NuGet 都不想加。
社区里常用的两个命令实现如下表:
| 框架 | 命令类型 | 优点 | 适合场景 |
|---|---|---|---|
| CommunityToolkit.Mvvm | RelayCommand/AsyncRelayCommand | 轻量、无需额外依赖、C# 源生成器生成命令属性、对 AOT 友好 | 大多数新项目,尤其 .NET 6+ |
| Prism | DelegateCommand | 自带复合命令、与 Prism 导航/DI 深度集成 | 大型模块化应用、已经使用 Prism 的团队 |
个人经验:如果项目是简单的几个窗口的 CRUD 工具,手写一个 50 行的 RelayCommand 完全绰绰有余。但如果是业务系统,建议直接用 CommunityToolkit.Mvvm,它的[RelayCommand]源生成器能帮你自动生成命令属性,省掉大量样板代码,这个我在后面异步命令的场景里再细写。
3. 按钮命令绑定的完整实操:5 个高频场景一次讲透
原理讲完了,接下来是实操。我把开发中用到最多的按钮命令绑定场景分成五类,从最简单到最复杂,一一拆解。
3.1 场景一:无参数命令——最简单的绑定
最基础的情况:按钮点击后执行一段逻辑,这段逻辑不需要外部参数。比如“刷新列表”“清空日志”。
ViewModel 里的代码:
public ICommand RefreshCommand { get; } public ObservableCollection<LogItem> Logs { get; } = new(); public MainViewModel() { RefreshCommand = new RelayCommand(RefreshLogs); } private void RefreshLogs() { Logs.Clear(); foreach (var item in _dataService.LoadLogs()) { Logs.Add(item); } }XAML 里绑定:
<Button Content="刷新日志" Command="{Binding RefreshCommand}" />这就完成了。按钮点击后,自动执行 ViewModel 里的RefreshLogs()。注意事项:Command绑定的是 ViewModel 上公开的ICommand属性,不是方法本身,这一点刚开始很容易搞混。如果你在 XAML 里写Command="{Binding RefreshLogs()}",那是绑不上的。
3.2 场景二:带 CommandParameter 的命令——把选中项传给 ViewModel
很多操作不能没有参数。比如删除列表里的一项、显示某条数据的详情、在多个 Tab 间切换。这时就要用CommandParameter把对象传给命令。
经典案例:ListBox里选中一个人,点了“查看详情”按钮,弹窗显示这个人的信息。
XAML:
<ListBox ItemsSource="{Binding Persons}" SelectedItem="{Binding SelectedPerson}"> <ListBox.ItemTemplate> <DataTemplate> <TextBlock Text="{Binding Name}" /> </DataTemplate> </ListBox.ItemTemplate> </ListBox> <Button Content="查看详情" Command="{Binding ShowDetailCommand}" CommandParameter="{Binding SelectedItem, ElementName=PersonList}" />这个写法里CommandParameter用了ElementName=PersonList去引用ListBox的选中项。我见过不少人在 DataTemplate 里直接写CommandParameter="{Binding}",这时候绑定的是 Person 对象,不是 ListBox 里整个选中的对象,两种场景要分清:
- 按钮在
DataTemplate内部,每行一个按钮时:CommandParameter="{Binding}"绑的是当前行的数据对象。 - 按钮在
DataTemplate外部,针对整个列表操作时:用ElementName或x:Reference引用列表的SelectedItem。
ViewModel 侧的命令支持参数判断:
public ICommand ShowDetailCommand { get; } public MainViewModel() { ShowDetailCommand = new RelayCommand(ShowDetail, p => p is Person); } private void ShowDetail(object? parameter) { if (parameter is not Person person) return; _dialogService.ShowPersonDetail(person); }注意CanExecute里的p is Person:如果参数类型不对,比如绑定了一个 null,按钮自动变灰,这比在 Execute 里硬判安全得多。用参数化的 CanExecute 来判断参数合法性,是我很推荐的一个小习惯。
3.3 场景三:CanExecute 动态控制按钮可用性
这是按钮命令绑定的高光时刻:你不用手动管理按钮的IsEnabled,“可点”和“不可点”变成纯业务规则。
最常见的例子是登录按钮:用户名密码都为非空时才能点,否则按钮置灰。
ViewModel 代码:
private string _username = string.Empty; public string Username { get => _username; set { if (_username != value) { _username = value; OnPropertyChanged(); LoginCommand.RaiseCanExecuteChanged(); } } } private string _password = string.Empty; public string Password { get => _password; set { if (_password != value) { _password = value; OnPropertyChanged(); LoginCommand.RaiseCanExecuteChanged(); } } } public RelayCommand LoginCommand { get; } public MainViewModel() { LoginCommand = new RelayCommand(Login, _ => !string.IsNullOrWhiteSpace(Username) && !string.IsNullOrWhiteSpace(Password)); }这里的关键点在于:每次Username或Password属性变化时,都要调用LoginCommand.RaiseCanExecuteChanged(),否则按钮的可用状态不会自动更新。如果依赖CommandManager.RequerySuggested,在某些场景下(比如窗口模态、数据模板里)按钮状态可能更新不及时。手动调用RaiseCanExecuteChanged是最可控的。
登录方法里还可以做个状态保护:
private async void Login(object? parameter) { IsLoggingIn = true; LoginCommand.RaiseCanExecuteChanged(); try { await _authService.LoginAsync(Username, Password); } finally { IsLoggingIn = false; LoginCommand.RaiseCanExecuteChanged(); } }登录期间按钮自动禁用,防止用户重复点击。这就是命令绑定和手动设置 IsEnabled 相比的直觉优势。
3.4 场景四:用 EventTrigger 把普通事件转成命令
不是所有交互都是 Click。双击列表项打开详情、鼠标悬浮显示工具提示、TextChanged 触发搜索——这些普通事件在纯 MVVM 下没法直接绑命令。这时候需要借助行为(Behavior),把事件转成命令。
WPF 开发里常用的是Microsoft.Xaml.Behaviors库(老项目里叫System.Windows.Interactivity)。用法如下:
xmlns:i="http://schemas.microsoft.com/xaml/behaviors" <ListBox ItemsSource="{Binding Persons}"> <i:Interaction.Triggers> <i:EventTrigger EventName="MouseDoubleClick"> <i:InvokeCommandAction Command="{Binding OpenDetailCommand}" CommandParameter="{Binding SelectedItem}" /> </i:EventTrigger> </i:Interaction.Triggers> </ListBox>这里EventName="MouseDoubleClick"表示监听ListBox的双击事件,双击后执行OpenDetailCommand。CommandParameter直接绑定SelectedItem,因为这里的 DataContext 是 ListBox 所在的 ViewModel。
场景再举一个:鼠标进入某个面板时触发一个命令。总之,任何控件的事件都可以用这招转成命令,只要不给业务代码造成泄露 View 情况的压力就行。不过要注意,EventTrigger绑定的是控件的某个事件,如果事件触发的参数你需要,可以在InvokeCommandAction里设置CommandParameter为{Binding}或者通过PassEventArgsToCommand把事件参数带过去。具体用哪种,看你 ViewModel 是否需要知道事件细节。我一般倾向把 CommandParameter 绑定到某个已经存在于 VM 的属性上,而不是直接把RoutedEventArgs传给 VM——毕竟传事件参数进来了,ViewModel 又跟 UI 细节沾边了,这是违背 MVVM 精神的。
3.5 场景五:异步命令与按钮状态管理
登录、保存、导入导出这些操作都是耗时操作。如果命令是同步执行的,UI 会卡死——这是 WPF 新手最容易犯的错误之一。前文已经警告过,async voidlambda 直接传给 RelayCommand 是有崩溃风险的。正确的做法是用异步命令。
我用 CommunityToolkit.Mvvm 里的AsyncRelayCommand演示,因为它的代码量最少、可读性最强。直接用源生成器:
ViewModel:
using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; public partial class LoginViewModel : ObservableObject { [ObservableProperty] private string _username = string.Empty; [ObservableProperty] private string _password = string.Empty; [ObservableProperty] private bool _isBusy; [RelayCommand(CanExecute = nameof(CanLogin))] private async Task LoginAsync() { IsBusy = true; LoginCommand.NotifyCanExecuteChanged(); try { await Task.Delay(2000); // 模拟网络登录 } finally { IsBusy = false; LoginCommand.NotifyCanExecuteChanged(); } } private bool CanLogin() { return !IsBusy && !string.IsNullOrWhiteSpace(Username) && !string.IsNullOrWhiteSpace(Password); } }[RelayCommand]源生成器会自动生成一个LoginCommand属性,类型是AsyncRelayCommand。CanExecute = nameof(CanLogin)指定了命令的可执行判断条件。IsBusy设置为 true 后,再调用LoginCommand.NotifyCanExecuteChanged()让按钮禁用,等任务彻底结束再恢复。
这套写法把“按钮忙碌态”和“按钮可用态”完全纳入了 VM 的自控范围。你甚至可以在按钮上绑定 IsBusy 做前端的“登录中…”文字切换:
<Button Content="{Binding IsBusy, Converter={StaticResource BoolToStringConverter}, ConverterParameter=登录中...|登 录}" Command="{Binding LoginCommand}" />细心的人会发现,IsBusy这个属性上我标注了[ObservableProperty],也就是说 ViewModel 基类要继承ObservableObject。这个属性变更时,按钮的状态刷新其实依赖的是 CanLogin 里返回的!IsBusy条件被重新评估。这里必须养成一个习惯:所有会影响CanLogin结果的属性,在 setter 里要么自己调用LoginCommand.NotifyCanExecuteChanged(),要么在属性变更后集中刷新一次。
4. 常见问题与排查技巧实录
命令绑定看起来简单,真正写起来踩坑的地方比想象中多。我把自己和同行遇到的高频问题全部整理出来,按优先级排了个排查清单。
4.1 数据模板里按钮命令绑定失效:DataContext 被换了
这是我在实际项目里被问得最多的问题。列表的 DataTemplate 里面放了一个按钮,按钮的 Command 绑定 ViewModel 上的命令,结果点击没反应。XAML 长这样:
<ListBox ItemsSource="{Binding Persons}"> <ListBox.ItemTemplate> <DataTemplate> <Button Content="删除" Command="{Binding DeleteCommand}" /> </DataTemplate> </ListBox.ItemTemplate> </ListBox>这里的 DataContext 已经不是外面的 ViewModel 了,而是列表项Person。Person上没有DeleteCommand,绑定自然失败。解决办法有两种:
一种是绑定到列表项里的某个属性,通过该属性转发命令:
<Button Content="删除" Command="{Binding DataContext.DeleteCommand, RelativeSource={RelativeSource AncestorType=ListBox}}" />另一种是把命令作为列表项对象的属性暴露出去(比如每个 Person 自己持有删除命令),但这样会在 Model 层掺入命令逻辑,我不推荐。
实际项目中我更倾向于用RelativeSource AncestorType=ListBox的方式,非常明确:向上找 ListBox,拿到 ListBox 的 DataContext(就是 ViewModel),再取它的 DeleteCommand。如果层级更深,可以一直往上找 Window:
<Button Command="{Binding DataContext.DeleteCommand, RelativeSource={RelativeSource AncestorType=Window}}" />4.2 点击按钮没反应,命令根本没触发
这种问题要分成两种情况看。先说怎么查:在 Execute 方法里打一个断点,看它有没有被命中。
首先检查按钮的Command="{Binding SomeCommand}"里命令属性名拼写是否正确。这类错误常见于重构后属性改名,但 XAML 是运行时绑定的,编译不会报错,所以特别隐蔽。用 Snoop 这类 WPF 可视化工具检查按钮的Command属性到底有没有值。如果没有值,多半是 DataContext 没设置好或绑定路径写错。
如果Command有值但 Execute 没执行,基本可以确定是CanExecute返回了 false。这时候按钮通常是变灰的状态。如果按钮看起来正常但点击没反应,也可能是命令被某个事件吞掉了——比如外层容器的PreviewMouseLeftButtonDown里做了e.Handled = true,按钮根本收不到鼠标事件。这种问题就得到外层事件处理里去查了。
4.3 按钮永远是灰色:CanExecute 失效
按钮灰了,说明 CanExecute 返回了 false。这时候别急着怀疑 CanExecute 写错,先想一个问题:CanExecuteChanged 有没有被正确触发。这是最典型的调度问题。
我遇到过这么个情况:命令的 CanExecute 依赖一个属性,这个属性在主窗口逻辑里被异步更新了,结果按钮纹丝不动。原因就是属性更新后没有调用RaiseCanExecuteChanged。用CommandManager.RequerySuggested的写法在这个场景下恰好失效,因为那个属性更新的时候焦点不在界面上,RequerySuggested 没有触发。
也不排除你绑定的命令是类里创建的局部变量,每次属性变更重新 new 了一个命令对象——那当然变了也白变,按钮绑定的是旧命令。这种情况要确认一下命令属性是否在构造函数里只初始化一次。
4.4 常见问题排查速查表
| 现象 | 可能原因 | 排查与解决 |
|---|---|---|
| 按钮 Command 绑定不上,值为空 | DataContext 未设置 / 属性路径错误 / 类名拼写错误 | 用 Snoop 或 Debug 输出查看 Binding 错误,检查 VM 是否被正确赋值 |
| 按钮点击无反应且断点未进 Execute | CanExecute 返回 false / 事件被外层拦截 / 命令本身为 null | 查 CanExecute 逻辑;查可视化树外层事件处理 |
| 按钮永远是灰色 | CanExecuteChanged 没有触发 | 确认属性 setter 里调用 NotifyCanExecuteChanged;避免依赖 CommandManager.RequerySuggested |
| 数据模板里的按钮命令绑定失败 | DataTemplate 的 DataContext 是列表项而不是 ViewModel | 用 RelativeSource AncestorType 找到包含 DataContext 的父容器 |
| 命令执行后异常导致程序崩溃 | async void / 同步命令里未捕获异常 | 改用 AsyncRelayCommand;命令内部加异常捕获兜底 |
| 按钮状态在异步任务期间不更新 | IsBusy 属性变更后未刷新 CanExecute | 属性 setter 或任务状态变更处调用 NotifyCanExecuteChanged |
| 同一命令绑到多个按钮,部分按钮不刷新 | 个别按钮在虚拟化容器中未收到通知 | 使用更高层的命令刷新,或在自定义命令里统一触发全局刷新 |
还有一个容易被忽略的小细节:命令的CanExecuteChanged是在 UI 线程里注册的事件。如果你在后台线程里调用RaiseCanExecuteChanged(),WPF 的绑定系统会面临跨线程问题。所以异步命令里凡是需要通知界面刷新的操作,都要通过Dispatcher.Invoke或者在ConfigureAwait(true)的上下文里完成。用 CommunityToolkit.Mvvm 的AsyncRelayCommand时这个细节框架已经帮你处理好了,但如果你手写的异步命令,就一定要主动注意。
个人经验几点补充
最后再分享几个我踩过坑之后固化的习惯。
第一,能不用手写 RelayCommand 就不用,优先上 CommunityToolkit.Mvvm 的源生成器。手写虽然能长知识,但生产项目里代码量一多,手写命令容易出一些低级 bug,比如 CanExecuteChanged 未触发、async void 问题,这些东西框架都替你考虑好了,何必重复造轮子。
第二,命令的CanExecute不要写太重的逻辑。每次界面交互都可能会评估 CanExecute(尤其在 RequerySuggested 模式下),里面查数据库或者做复杂集合运算会直接拖慢 UI。CanExecute 里只做简单的属性判断就够,复杂校验放到 URL 流程里去。
第三,按钮绑参数时有个性能陷阱:CommandParameter="{Binding SelectedItem}"这种双向绑定的写法在列表里如果频繁选中切换,会影响命令触发顺序。我的经验是尽量用SelectedItem的绑定的 setter 去主动触发命令,而不是完全依赖CommandParameter的绑定值,这样可以避免一些偶发的空引用问题。
这些都是实操里一点点攒出来的,谈不上什么高深理论,但能在关键时刻帮你省下半天排查时间。MVVM 的按钮命令绑定说到底不难,吃透原理、踩过坑、再总结出自己的一套写法,后面写 WPF 就会顺畅很多。