🎓 严老师 · C# / WPF 教学
← 全部课程 Day 21 Phase 3 第 8 课

📚 **Day 21 · Phase 3 第 8 课 · WPF 命令系统**

5推送消息数
31520字符数
2026-08-21教学日期
<!-- message_id: om_x100b674d1d3110a4b0363f7ae256c08 --> 📚 **Day 21 · Phase 3 第 8 课 · WPF 命令系统** 主人早安。新一天开课,今天是 Phase 3 的最后一讲(**WPF 三大件:DP / 路由事件 / 命令系统**),也是 MVVM 模式的核心拼图。 —— **【今日主题】** WPF 命令系统核心(ICommand / RoutedCommand / RelayCommand / AsyncRelayCommand / CommandBinding / CommandManager.RequerySuggested 内存泄漏根因 + 5 大生产陷阱 + CompositeCommand 组合命令 + ViewModel 完整集成实战) **【深度策略】** 今天从「MVVM 模式下 Button 点击如何传到 ViewModel」切入 → ICommand 三件套(Execute / CanExecute / CanExecuteChanged)→ 5 种命令实现对比(ApplicationCommands / RoutedCommand / RelayCommand / AsyncRelayCommand / CompositeCommand)→ CommandBinding 沿视觉树查找机制 → **CommandManager 内存泄漏根因深度剖析**(静态事件链强引用 ViewModel → View)→ 5 大生产陷阱 → Q3 完整生产代码(含取消 + 异常 + 节流 + 防重入) —— 一、今日知识点 1. 是什么 WPF 命令系统是**连接 UI 行为与业务逻辑的解耦层**,由三大核心组成: **核心接口 ICommand(3 个成员)**: | 成员 | 类型 | 作用 | | ---------------------------- | --- | ------------------------------ | | Execute(object parameter) | 方法 | 执行命令逻辑 | | CanExecute(object parameter) | 方法 | 判断命令是否可执行(影响 Button.IsEnabled) | | CanExecuteChanged | 事件 | CanExecute 状态变化时通知 UI 重新查询 | **5 种命令实现对比**: | 类型 | 命名空间 | 路由? | 异步? | 何时用 | | ------------------- | --------------------- | ----------------- | --------- | --------------------------------- | | ApplicationCommands | System.Windows.Input | ✅ RoutedUICommand | ❌ | 框架级命令(Save/Open/Cut/Copy/Paste) | | RoutedCommand | System.Windows.Input | ✅ 沿视觉树 | ❌ | 自定义跨元素路由命令 | | RelayCommand | CommunityToolkit.Mvvm | ❌ | ❌ | MVVM 同步命令 | | AsyncRelayCommand | CommunityToolkit.Mvvm | ❌ | ✅ 异步 + 取消 | MVVM 异步命令 | | CompositeCommand | 自定义(Prism / 手写) | ❌ | 取决于子命令 | 组合多个命令(SaveAll = Save + Validate) | **CommandBinding**:把 RoutedCommand 绑定到 UI 元素,附加 2 个 handler(Executed / PreviewExecuted)。这是 RoutedCommand 唯一的执行入口。 **CommandManager**:System.Windows.Input 命名空间下的**进程级单例**,管理全局命令状态。最重要的职责是 RequerySuggested 事件 + InvalidateRequerySuggested() 方法。 --- <!-- message_id: om_x100b674d15cc10a4b27c9c5c09d70ba --> 📚 Day 21 · Phase 3 第 8 课 WPF 命令系统核心(Command System) ─── 1️⃣ 今日知识点 是什么 WPF 命令系统是一套**"将用户意图从 UI 分离到逻辑"**的输入处理架构。它由 4 个核心抽象构成: | 抽象 | 角色 | 关键成员 | | ------------------------------- | ------ | ------------------------------------------------------------------------------------------------- | | ICommand | 命令契约 | bool CanExecute(object) / void Execute(object) / event EventHandler CanExecuteChanged | | ICommandSource | 命令触发源 | Command / CommandParameter / CommandTarget 三个 DP(Button / MenuItem / InputBinding 都实现) | | CommandBinding | 命令处理器 | 把 ICommand 关联到具体 UIElement 上,提供 Executed / CanExecute / PreviewExecuted / PreviewCanExecute 4 个回调 | | RoutedCommand / RoutedUICommand | 命令路由引擎 | 继承 ICommand,支持沿视觉树/逻辑树向上冒泡查找 CommandBinding | **关键洞察**:ICommand 是契约(接口),RoutedCommand 是 WPF 内置实现(带路由能力)。MVVM 里你写的 RelayCommand 是自定义 ICommand 实现,**不走路由**(因为你的命令不需要在 UI 树冒泡找处理器——ViewModel 自己就是处理器)。 ─── 为什么需要命令 如果只有事件,代码会长这样: // ❌ 没有命令的写法(WinForms 风格) buttonSave.Click += (s, e) => { if (ValidateForm()) viewModel.Save(); // UI 直接调 ViewModel }; menuSave.Click += (s, e) => { /* 又写一遍 */ }; ctrlSSave.Click += (s, e) => { /* 又写一遍 */ }; 问题:同一个"保存"动作被 3 个地方重复绑定;快捷键 Ctrl+S 无法自动和菜单联动;UI 无法根据状态自动启用/禁用;MVVM 模式下 ViewModel 拿不到 UI 事件。 **命令系统的 4 大价值**: 1. **单一入口**:一个 SaveCommand 同时绑定 Button / MenuItem / KeyBinding 2. **状态统一**:CanExecute 返回 false 自动禁用所有命令源 3. **输入手势统一**:Ctrl+S 自动关联到 ApplicationCommands.Save 4. **MVVM 解耦**:ViewModel 暴露 ICommand 属性,View 通过 DataContext 自动绑定,UI 和逻辑彻底分离 ─── 怎么用(核心代码) **场景 1:传统 WPF 方式(RoutedCommand + CommandBinding)** // 1. 定义命令(一般用 WPF 内置的 ApplicationCommands) public partial class MainWindow : Window { public MainWindow() { InitializeComponent(); // 2. 把命令绑定到 UI 元素上 this.CommandBindings.Add(new CommandBinding(ApplicationCommands.Save, Executed_Save, // 当命令触发时 CanExecute_Save)); // 当需要询问是否可执行时 // 3. 多个 UI 元素共享同一个命令(XAML) // <Button Command="ApplicationCommands.Save" Content="保存"/> // <MenuItem Command="ApplicationCommands.Save" Header="保存(_S)"/> // <Window.InputBindings> // <KeyBinding Key="S" Modifiers="Ctrl" Command="ApplicationCommands.Save"/> // </Window.InputBindings> } private void Executed_Save(object sender, ExecutedRoutedEventArgs e) => MessageBox.Show("保存了!"); private void CanExecute_Save(object sender, CanExecuteRoutedEventArgs e) => e.CanExecute = HasUnsavedChanges(); // 根据状态自动启用/禁用 } **场景 2:MVVM 方式(RelayCommand)**——生产环境 99% 用这个: // RelayCommand 完整生产级实现 public class RelayCommand : ICommand { private readonly Action<object?> _execute; private readonly Predicate<object?>? _canExecute; public RelayCommand(Action<object?> execute, Predicate<object?>? canExecute = null) { _execute = execute ?? throw new ArgumentNullException(nameof(execute)); _canExecute = canExecute; } // RelayCommand 也支持无参数重载(更常用) public RelayCommand(Action execute, Func<bool>? canExecute = null) : this(_ => execute(), canExecute is null ? null : _ => canExecute()) { } public bool CanExecute(object? parameter) => _canExecute?.Invoke(parameter) ?? true; public void Execute(object? parameter) => _execute(parameter); // ★★★ 命令系统的关键洞见 ★★★ // CanExecuteChanged 是 UI 询问 "CanExecute 变了吗?" 的钩子 // 我们让 UI 自动监听 CommandManager.RequerySuggested(焦点变化/键盘活动/...) // 让 WPF 帮我们重新询问所有命令源 public event EventHandler? CanExecuteChanged { add { CommandManager.RequerySuggested += value; } remove { CommandManager.RequerySuggested -= value; } } // 手动通知 ViewModel 状态变了,需要重新询问 CanExecute public void RaiseCanExecuteChanged() => CommandManager.InvalidateRequerySuggested(); } // ViewModel 中使用 public class MainViewModel : ObservableObject { private string _name = ""; public string Name { get => _name; set => SetProperty(ref _name, value); } // CanExecute 自动响应 Name 变化(因为 CommandManager 会重新询问) public ICommand SaveCommand => new RelayCommand( execute: () => Save(), canExecute: () => !string.IsNullOrWhiteSpace(Name)); } ─── 常见误区(必踩的 4 个坑) | 误区 | 后果 | 正确做法 | | ------------------------------------------------- | -------------------------------------------------------------- | ---------------------------------------------------- | | ❌ 把 CommandBinding 加到 Grid / StackPanel 上 | 命令路由不到(Grid 不是 ICommandSource) | 加到 Window / UserControl 等会接受焦点的元素 | | ❌ 自定义 ICommand 不挂 CommandManager.RequerySuggested | UI 永远不会重新询问 CanExecute,按钮永远不变灰 | 必须把 add/remove 转发到 CommandManager.RequerySuggested | | ❌ 用 class 命令而忘实现 CanExecuteChanged | 绑定到 Button 时编译通过,但运行时 NullReferenceException | 必须实现 CanExecuteChanged(哪怕只转发到 CommandManager) | | ❌ RelayCommand 内直接捕获 ViewModel 实例做 _execute | 整个 ViewModel 被静态 CommandManager 强引用链挂住 → 内存泄漏(Day 3 委托事件泄漏的翻版) | 用 WeakReference 包一层 / 手动 RaiseCanExecuteChanged 不挂全局 | ─── 关联知识网络 | 已学 | 与命令的连接 | | ------------- | ----------------------------------------------------------------------------------------------------------------- | | Day 14 WPF 架构 | CommandManager 位于 PresentationFramework 层,是 UI 线程亲和的全局管理器 | | Day 15 DP | ICommandSource 的 Command / CommandParameter / CommandTarget 都是 DependencyProperty,享受值继承 + 强制值 + 样式重写 | | Day 16 路由事件 | ExecutedRoutedEventArgs 是路由事件家族成员,沿视觉树/逻辑树冒泡;命令系统本质是路由事件的"特化版" | | Day 17 两棵树 | RoutedCommand 沿视觉树冒泡找 CommandBinding(不是逻辑树!这是 Day 17 强调的关键差异) | | Day 20 数据绑定 | View 通过 {Binding SaveCommand} 把 ViewModel 的 ICommand 属性绑到 Button.Command,背后是 Day 15 学的 DP + Day 17 学的 DataContext | ─── 下一条继续 Q1 + Q2 复盘。 --- <!-- message_id: om_x100b674d157748b0b1a35c5dfe0816d --> ─── 2️⃣ 递进式面试题 Q1(基础概念) **请简述 WPF 命令系统的核心组成,并解释 ICommand、ICommandSource、CommandBinding 三者各自承担什么角色?命令系统与路由事件有什么本质区别?** <details> <summary><b>👉 点击查看答案要点</b></summary> **得分点**: 1. **4 大核心抽象**(必须说全): • ICommand(契约:CanExecute / Execute / CanExecuteChanged) • ICommandSource(触发源:Button / MenuItem / InputBinding 等) • CommandBinding(处理器:把命令关联到 UIElement) • RoutedCommand / RoutedUICommand(路由引擎:支持沿视觉树冒泡) 2. **三者角色对应**: • ICommand = "做什么"的抽象(接口) • ICommandSource = "从哪触发"的 UI 元素(Button/MenuItem/KeyBinding) • CommandBinding = "谁来处理"的中间桥梁(关联命令到 UIElement) 3. **与路由事件的本质区别**: • **路由事件**:通知(fire-and-forget),UI 状态变化 → 通知监听者 • **命令系统**:请求-响应(request-response),UI 询问"能做吗?" → CanExecute 返回 → UI 启用/禁用 → 用户触发 → Execute 执行 • 关键差异:命令有**双向状态同步**(CanExecute 反复被问),事件没有 **深度解析**: WPF 命令系统本质上是路由事件的"特化 + 增强": • **特化**:把"点击"这种通用事件,提升为"业务意图"(保存/复制/导航) • **增强**:加入 CanExecute 状态查询机制,让 UI 自动响应业务状态变化 WPF 内置 5 大命令集合(ApplicationCommands / NavigationCommands / MediaCommands / EditingCommands / ComponentCommands),一共定义了 100+ 个标准命令。 **易错点提醒**: • ❌ 认为"命令就是事件的高级版"——这是错的,命令本质是**业务意图抽象**,事件是**状态变化通知** • ❌ 忘记 ICommandSource 三个属性(Command/CommandParameter/CommandTarget)都是 DP • ❌ 认为 RelayCommand 走命令路由——错的,MVVM 命令不路由(因为 ViewModel 自己就是处理器) **扩展思考**: • 如果让你设计一个命令系统,你会让命令沿逻辑树还是视觉树冒泡?为什么 WPF 选择了视觉树?(提示:DataTemplate 展开后的子元素需要能响应命令) </details> ─── Q2(原理与辨析) **深入剖析 CommandManager.RequerySuggested 的工作机制。为什么说它是 WPF 著名的"内存泄漏根因"?请给出至少两种解决方案并分析其优缺点。** <details> <summary><b>👉 点击查看答案要点</b></summary> **得分点**: 1. **RequerySuggested 机制**: • CommandManager.RequerySuggested 是**进程级静态事件**(在 System.Windows.Input.CommandManager 类) • WPF 在以下时机自动 Raise 这个事件: • 焦点变化(GotFocus / LostFocus) • 键盘活动(KeyDown / KeyUp) • 鼠标活动(MouseDown / MouseUp / MouseMove) • 任何 UI 输入事件触发后 • 每次触发时,所有订阅了此事件的 ICommand 实现都会被询问 CanExecute 2. **为什么是内存泄漏根因**(和 Day 3 委托事件泄漏一脉相承): • CommandManager 是**进程级单例**,生命周期 = 进程生命周期 • RelayCommand.CanExecuteChanged 的 add 把当前 RelayCommand 实例的 CanExecuteChanged 委托挂到 CommandManager.RequerySuggested • **强引用链**:CommandManager(静态根) → RequerySuggested 委托链 → CanExecuteChanged 委托 → RelayCommand 实例 → 构造时传入的 Action → **ViewModel 实例** • ViewModel 通常是 Window/UserControl 的 DataContext,正常关闭 Window 时本应被 GC • 但静态 CommandManager 持有它的强引用 → **Window 关闭了,ViewModel 还活着** → 整个 View 子树也跟着泄漏 3. **内存泄漏特征**: • 关闭子 Window 后,dotMemory/dump 还能看到 ViewModel • 反复打开/关闭 N 次 → 内存中累积 N 个 ViewModel • 性能问题(频繁的 GC 扫描)+ 业务问题(脏数据/状态不一致) **深度解析**: 泄漏链路图: [GC Root: static CommandManager] ↓ 强引用 [RequerySuggested: MulticastDelegate] ↓ _invocationList 持有 [CanExecuteChanged 委托实例 #1] ↓ Target 指向 [RelayCommand 实例 #1] ↓ _execute 字段 [Action lambda] ↓ 闭包 DisplayClass 持有 [ViewModel #1 实例] ← 本应被 GC,但因为 CommandManager 静态根,永远活着 **两种解决方案对比**: | 方案 | 优点 | 缺点 | 适用场景 | | ----------------------------------------------------- | ----------------------------- | ----------------------------------------------- | -------- | | A. CanExecuteChanged 不挂 RequerySuggested,改成手动通知(推荐) | 切断静态引用链,彻底无泄漏 | 需要 ViewModel 在状态变化时手动调 RaiseCanExecuteChanged() | 95% 业务场景 | | B. WeakEventManager 子类 | 自动管理订阅者生命周期 | 自己实现,复杂;订阅者可能在 GC 后才被清,状态更新不及时 | 第三方控件事件 | | C. WeakReference 包 CommandManager 引用 | 静态事件变弱引用 | 实际不可行——CommandManager 是 sealed,无法子类化 | ❌ 不推荐 | | D. CommunityToolkit.Mvvm 的 [RelayCommand](.NET 6+ 推荐) | 源生成器自动生成 WeakRelayCommand,无泄漏 | 引入 CommunityToolkit 包 | 新项目首选 | **方案 A 的生产级实现**: public class RelayCommand : ICommand { private readonly Action<object?> _execute; private readonly Predicate<object?>? _canExecute; public RelayCommand(Action<object?> execute, Predicate<object?>? canExecute = null) { _execute = execute; _canExecute = canExecute; } public bool CanExecute(object? parameter) => _canExecute?.Invoke(parameter) ?? true; public void Execute(object? parameter) => _execute(parameter); // ★ 不挂 RequerySuggested,自己维护订阅者列表 public event EventHandler? CanExecuteChanged; public void RaiseCanExecuteChanged() => CanExecuteChanged?.Invoke(this, EventArgs.Empty); } // ViewModel 中 public class MainViewModel : ObservableObject { public MainViewModel() { // 当属性变化时,手动通知命令重新询问 PropertyChanged += (s, e) => { if (e.PropertyName is nameof(Name) or nameof(Age)) SaveCommand.RaiseCanExecuteChanged(); }; } } **易错点提醒**: • ❌ 以为 RequerySuggested 只是性能优化("命令更新慢一点没事")——错,它是**泄漏根因** • ❌ 手动通知忘了写在 PropertyChanged 里 → UI 状态永远不刷新 • ❌ 用 WeakReference 包 ViewModel 但挂在 RequerySuggested 上——双向强引用,泄漏依旧 **扩展思考**: • 为什么 WPF 设计时把 RequerySuggested 做成静态事件?(提示:避免每个 ViewModel 都注册一个监听器,全局统一触发更高效,但代价就是泄漏风险) • 如果让你重做 WPF 命令系统,你会怎么避免这个泄漏?(提示:把订阅者用 WeakReference 存储) </details> 下一条继续 Q3 实战。 --- <!-- message_id: om_x100b674d127594a4b4c5a8e11a21e23 --> ─── Q3(实战与深度) **在一个复杂的 WPF MVVM 业务系统中,需要实现以下需求:** 1. SaveCommand 是普通同步命令,根据 Name 非空启用 2. LoadCommand 是异步命令,需要:① 防止重复点击(Busy 时禁用);② 自动取消(用户关闭窗口时);③ 异常捕获并提示用户 3. **不能使用 CommunityToolkit.Mvvm**,纯手写生产级实现 4. **重点**:必须避免 CommandManager.RequerySuggested 内存泄漏 **请给出完整可运行的代码(含 ViewModel 基类、RelayCommand、AsyncRelayCommand、ViewModel 调用示例、XAML 绑定示例),并解释关键设计决策。** <details> <summary><b>👉 点击查看完整生产级方案</b></summary> ─── 设计决策清单 | 决策点 | 选择 | 理由 | | ------------------------- | ------------------------------------- | ------------------- | | CanExecuteChanged 策略 | 不挂 RequerySuggested | 切断静态引用链,避免泄漏 | | RaiseCanExecuteChanged 触发 | 基类 OnPropertyChanged 自动通知 | ViewModel 不用手动写,零侵入 | | 异步命令取消 | CancellationTokenSource + IDisposable | 用户关闭窗口时取消正在执行的异步任务 | | 异步命令去重 | IsExecuting 标志位 + CanExecute 返回 false | 天然防重入 | | 异步异常处理 | try/catch + OnException 钩子 | 业务可自定义错误处理(弹窗/日志) | ─── 完整生产级代码 using System; using System.Threading; using System.Threading.Tasks; using System.Windows.Input; using System.ComponentModel; using System.Runtime.CompilerServices; namespace WpfCommands { // ============================================================ // ViewModel 基类(解决"任何属性变化都触发命令重新询问") // ============================================================ public abstract class ObservableObject : INotifyPropertyChanged, IDisposable { public event PropertyChangedEventHandler? PropertyChanged; protected bool SetProperty<T>( ref T field, T value, [CallerMemberName] string? propertyName = null, params string[] alsoNotifyCommands) // ★ 关键:可以指定属性变化时连带通知哪些命令 { if (EqualityComparer<T>.Default.Equals(field, value)) return false; field = value; OnPropertyChanged(propertyName); foreach (var cmd in alsoNotifyCommands) OnPropertyChanged(cmd); // 让命令的 CanExecute 重新询问 return true; } protected void OnPropertyChanged([CallerMemberName] string? propertyName = null) => PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)); // 派生命令系统需要的所有 ICommand 属性,让属性变化时连带命令刷新 // 简化方案:直接调用 OnPropertyChanged(nameof(LoadCommand)) public virtual void Dispose() { } } // ============================================================ // RelayCommand(同步命令生产级实现) // ============================================================ public sealed class RelayCommand : ObservableObject, ICommand { private readonly Action<object?> _execute; private readonly Predicate<object?>? _canExecute; public RelayCommand(Action<object?> execute, Predicate<object?>? canExecute = null) { _execute = execute ?? throw new ArgumentNullException(nameof(execute)); _canExecute = canExecute; } public RelayCommand(Action execute, Func<bool>? canExecute = null) : this(_ => execute(), canExecute is null ? null : new Predicate<object?>(_ => canExecute())) { } public bool CanExecute(object? parameter) => _canExecute?.Invoke(parameter) ?? true; public void Execute(object? parameter) => _execute(parameter); // ★ 不挂 CommandManager.RequerySuggested ★ // 切断静态引用链,避免内存泄漏 public event EventHandler? CanExecuteChanged; public void RaiseCanExecuteChanged() => CanExecuteChanged?.Invoke(this, EventArgs.Empty); } // ============================================================ // AsyncRelayCommand(异步命令生产级实现,含取消/去重/异常) // ============================================================ public sealed class AsyncRelayCommand : ObservableObject, ICommand, IDisposable { private readonly Func<object?, CancellationToken, Task> _executeAsync; private readonly Predicate<object?>? _canExecute; private CancellationTokenSource? _cts; private bool _isExecuting; public AsyncRelayCommand( Func<object?, CancellationToken, Task> executeAsync, Predicate<object?>? canExecute = null) { _executeAsync = executeAsync ?? throw new ArgumentNullException(nameof(executeAsync)); _canExecute = canExecute; } // 简化版无参重载 public AsyncRelayCommand( Func<CancellationToken, Task> executeAsync, Func<bool>? canExecute = null) : this((_, ct) => executeAsync(ct), canExecute is null ? null : new Predicate<object?>(_ => canExecute())) { } // ★ 防重入关键:CanExecute 根据 IsExecuting 自动返回 false public bool CanExecute(object? parameter) { if (_isExecuting) return false; // 正在执行,禁用 return _canExecute?.Invoke(parameter) ?? true; } public async void Execute(object? parameter) { // 双重检查锁,防止竞态 if (_isExecuting) return; _isExecuting = true; _cts = new CancellationTokenSource(); RaiseCanExecuteChanged(); // 通知 UI 立即禁用按钮 Exception? caught = null; try { await _executeAsync(parameter, _cts.Token); } catch (OperationCanceledException) { /* 用户主动取消,安静处理 */ } catch (Exception ex) { caught = ex; } finally { _isExecuting = false; _cts?.Dispose(); _cts = null; RaiseCanExecuteChanged(); // 通知 UI 重新启用按钮 } if (caught != null) await OnException(caught); // 调用方兜底处理 } // 异常钩子:默认重新抛出;ViewModel 可重写自定义弹窗/日志 protected virtual Task OnException(Exception ex) => Task.FromException(ex); // ★ 用户关闭窗口时取消正在执行的异步任务 public void Cancel() => _cts?.Cancel(); public event EventHandler? CanExecuteChanged; public void RaiseCanExecuteChanged() => CanExecuteChanged?.Invoke(this, EventArgs.Empty); public void Dispose() { _cts?.Cancel(); _cts?.Dispose(); } } // ============================================================ // 业务 ViewModel 示例 // ============================================================ public class UserEditViewModel : ObservableObject, IDisposable { private string _name = ""; private int _age; private string _status = "就绪"; private bool _isBusy; public string Name { get => _name; set => SetProperty(ref _name, value, alsoNotifyCommands: nameof(SaveCommand)); } public int Age { get => _age; set => SetProperty(ref _age, value, alsoNotifyCommands: nameof(SaveCommand)); } public string Status { get => _status; set => SetProperty(ref _status, value); } public bool IsBusy { get => _isBusy; set => SetProperty(ref _isBusy, value, alsoNotifyCommands: nameof(LoadCommand)); } // ★ 同步命令:Name 非空即可保存 public RelayCommand SaveCommand { get; } // ★ 异步命令:自动取消 + 防重入 + 异常处理 public AsyncRelayCommand LoadCommand { get; } private readonly IUserService _userService; private readonly IMessageBoxService _msg; public UserEditViewModel(IUserService userService, IMessageBoxService msg) { _userService = userService; _msg = msg; SaveCommand = new RelayCommand( execute: _ => Save(), canExecute: _ => !string.IsNullOrWhiteSpace(Name) && Age > 0); LoadCommand = new AsyncRelayCommand( executeAsync: async ct => { IsBusy = true; Status = "正在加载..."; var user = await _userService.LoadAsync(ct); // 自动响应取消 Name = user.Name; Age = user.Age; Status = $"已加载 {user.Name}"; IsBusy = false; }, canExecute: () => !IsBusy); // IsBusy 时禁用,防止重复点击 } private void Save() { try { _userService.Save(Name, Age); Status = "保存成功"; } catch (Exception ex) { _msg.ShowError($"保存失败:{ex.Message}"); } } // ★ 窗口关闭时调用:取消正在执行的异步任务 + 释放资源 public override void Dispose() { LoadCommand.Dispose(); // 取消 + 释放 CTS base.Dispose(); } } } ─── XAML 绑定示例 <Window x:Class="WpfCommands.MainWindow" xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" DataContext="{Binding UserEditViewModel}"> <StackPanel Margin="20"> <TextBlock Text="姓名:" /> <TextBox Text="{Binding Name, UpdateSourceTrigger=PropertyChanged}" /> <TextBlock Text="年龄:" Margin="0,10,0,0" /> <TextBox Text="{Binding Age, UpdateSourceTrigger=PropertyChanged}" /> <Button Content="保存" Command="{Binding SaveCommand}" Margin="0,10,0,0" HorizontalAlignment="Left" /> <Button Content="加载" Command="{Binding LoadCommand}" Margin="0,10,0,0" HorizontalAlignment="Left" /> <TextBlock Text="{Binding Status}" Margin="0,20,0,0" Foreground="Gray" /> </StackPanel> </Window> **关键洞察**: • UpdateSourceTrigger=PropertyChanged 让 TextBox 每次输入都触发 Name 属性变化(Day 20 学的) • Name 变化 → SaveCommand.CanExecute 重新询问 → Button 自动启用/禁用 • LoadCommand 执行中 → IsBusy=true → CanExecute 返回 false → Button 自动禁用 → **零额外代码防重入** ─── 5 大生产陷阱 | # | 陷阱 | 错误示例 | 修复 | | --- | --------------------------------------------------- | -------------------------------------------------------------------------------------------------- | -------------------------------------------------------------- | | T1 | RaiseCanExecuteChanged 写在异步 await 后 | await Task.Delay(1000); RaiseCanExecuteChanged(); —— 异步执行期间 UI 不会重新询问,必须在 _isExecuting = true 立即调用 | 见代码 _isExecuting = true; RaiseCanExecuteChanged(); | | T2 | 异步命令忘了 catch OperationCanceledException | 取消时抛 OperationCanceledException,未捕获会触发 AppDomain.UnhandledException → 进程崩溃 | catch (OperationCanceledException) { } 安静处理 | | T3 | 窗口关闭时未取消异步命令 | 用户点 X 关闭窗口,但 LoadCommand 还在 await → 回调到已 Dispose 的 ViewModel → ObjectDisposedException | 重写 OnClosed → 调 ViewModel.Dispose() → 内部调 LoadCommand.Cancel() | | T4 | CanExecute 内抛异常 | canExecute: () => int.Parse(Name) > 0 —— 输入 "abc" 时抛异常 → WPF 静默吞掉 + UI 卡死 | 用 int.TryParse 或事先校验 | | T5 | RelayCommand 内捕获 ViewModel 强引用 + 挂 RequerySuggested | 经典内存泄漏(Q2 详解) | 本代码用 CanExecuteChanged 不挂全局 | ─── 3 个动手扩展思考 1. **实现 WeakRelayCommand**:用 WeakReference 包 ViewModel 引用,订阅 RequerySuggested 但不让它形成强引用链(思路:RequerySuggested 触发时检查 WeakReference.IsAlive,订阅者死了就跳过)。 2. **实现 ICommand.Undo(命令模式)**:保存每个命令的执行历史,用户点 Ctrl+Z 撤销。这要求 ICommand 增加 CanUndo / Undo() 接口,WPF 没有内置,但 Prism 的 DelegateCommand 支持。 3. **对比 CommunityToolkit.Mvvm 的 [RelayCommand] 源生成**:他们用 Roslyn 生成 RelayCommand 代码,包含 [NotifyCanExecuteChangedFor(nameof(SaveCommand))] 特性自动挂钩。你能否用 Roslyn Analyzers 实现类似功能? </details> ─── 下一条是易错点提醒 + 关联知识网络。 --- <!-- message_id: om_x100b674d13ad1cacb1d8a4cc1d3d692 --> ─── 3️⃣ 全面复盘讲解 核心洞察回顾 1. **命令本质是"业务意图 + 状态查询 + 路由复用"三合一**,不是事件的高级版 2. **MVVM 95% 场景用 RelayCommand**,RoutedCommand 主要用于框架内置命令(剪贴板/导航/编辑) 3. **CommandManager.RequerySuggested 是 WPF 著名的内存泄漏根因**,所有手写命令都必须切断这个静态引用链 4. **异步命令的 3 大铁律**:防重入(IsExecuting 标志)/ 异常隔离(try/catch)/ 资源释放(Dispose 取消) ─── 7 大易错点提醒 | # | 易错点 | 后果 | 正确做法 | | --- | ------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------- | | 1 | CommandManager.RequerySuggested += handler 然后忘了 -=/Dispose | 静态引用链泄漏(和 Day 3 CommandManager.RequerySuggested 委托链泄漏一脉相承) | CanExecuteChanged 转发到 RequerySuggested 但命令本身是 ViewModel 的短命实例,关闭 View 时 ViewModel 死了但静态根还引用 → 泄漏。或者直接不挂全局,用手动通知 | | 2 | RelayCommand 的 _execute 用 lambda 捕获 this(ViewModel),但 ViewModel 实现了 IDisposable 而忘了 Dispose | 整个 View 子树跟着泄漏 | 在 Window.Closed / UserControl.Unloaded 调 ViewModel.Dispose() | | 3 | 异步命令 async void Execute 内未捕获异常 | 异常逃逸到 AppDomain.UnhandledException → 进程崩溃(WPF 进程崩溃默认会让用户看到 Windows 错误对话框) | try/catch (OperationCanceledException) { } + catch (Exception) 上报 | | 4 | CanExecute 内做耗时操作(数据库查询 / 文件 IO) | UI 频繁调用(鼠标一动就问一次)→ 性能雪崩 | CanExecute 只查内存状态;耗时校验放在 Execute 内 + 弹窗提示 | | 5 | CommandParameter 用 {Binding ElementName=...} 但元素未实例化(虚拟化或 ItemsControl 离屏项) | 命令触发时 parameter 是 null 或错的元素 → 业务异常 | RelativeSource / DataContext 优先;或加 null 检查 | | 6 | 同步 RelayCommand 内调 Task.Run(...).Wait() | UI 线程死锁(Day 11 重点) | 用 async void Execute + 完整异步链路,或独立线程 | | 7 | 用 ApplicationCommands.Save 但未注册 CommandBinding | 点击 Button → Execute 时 WPF 找不到处理器 → 静默失败(连异常都不抛) | 必须有匹配的 CommandBinding 或 ICommand 处理器 | ─── 关联知识网络 Day 14 WPF 架构 └─ CommandManager 在 PresentationFramework.dll └─ Day 15 DP └─ ICommandSource.Command / CommandParameter / CommandTarget 都是 DP └─ Day 16 路由事件 └─ ExecutedRoutedEventArgs 是路由事件成员 └─ Day 17 两棵树 └─ RoutedCommand 沿视觉树冒泡找 CommandBinding └─ Day 20 数据绑定 └─ {Binding SaveCommand} 把 ViewModel.ICommand 绑到 Button.Command └─ Day 21 命令系统(今天) └─ RelayCommand / AsyncRelayCommand / RequerySuggested 泄漏 **纵向连接**:命令系统是 Day 16 路由事件的"业务语义升级版"——事件告诉你"发生了什么",命令告诉你"用户想要做什么 + 现在能做吗 + 谁能做"。 **横向连接**:和 Day 20 数据绑定形成 MVVM 闭环—— • **View → ViewModel**:{Binding SaveCommand} 让 Button 自动监听命令 • **ViewModel → View**:PropertyChanged 触发 CanExecute 重新询问 → UI 自动启用/禁用 ─── 8 道自检题(动手验证用,答案不需要发我) 拿纸笔或记事本写下答案,能写出来才算真懂: 1. ICommand 接口的 3 个成员是什么?哪个是事件? 2. Button 为什么能作为 ICommandSource?它暴露了哪 3 个 DP? 3. RoutedCommand.Execute 被调用时,WPF 沿**哪棵树**冒泡找 CommandBinding? 4. CommandManager.RequerySuggested 在哪些时机被触发?至少举 3 个 5. RelayCommand 的 CanExecuteChanged 转发到 RequerySuggested 会形成怎样的强引用链? 6. AsyncRelayCommand 用什么机制防止重复点击?IsBusy 还是 _isExecuting? 7. async void Execute 内未捕获的异常会逃逸到哪里? 8. Dispose 一个 ViewModel 时,需要做什么来取消正在执行的异步命令? ─── 🛣️ 下一步路径预告 **Phase 3(WPF 基础)剩余主题**(按规划顺序): 1. ✅ Day 14-20:WPF 架构 / DP / 路由事件 / 两棵树 / 布局 / 资源 / 数据绑定 2. ✅ Day 21 命令系统(今天) 3. ⏭️ **Day 22**:样式与模板(Style / ControlTemplate / DataTemplate) 4. ⏭️ **Day 23**:动画系统(Storyboard / 缓动函数 / 触发器动画) 5. ⏭️ **Day 24**:自定义控件开发(Generic.xaml / 主题资源 / PART 模式) Phase 3 收官后进入 **Phase 5:MVVM 架构**(社区工具包 / 导航 / DI / 模块化)。 ─── 💬 今日核心 Takeaway **"命令不是事件的高级版,而是把'做什么 + 能不能做 + 谁来做'封装成一个可发现的业务语义单元。MVVM 命令 95% 不走路由,但必须切断 RequerySuggested 静态引用链。"** ─── 请主人回复一下掌握情况,或者把你对 Q1/Q2/Q3 的答案发给我,我来评估你的理解深度、给出针对性的追问或扩展讲解。也可以直接问 Q3 某段代码的设计思路——我准备好了。 如果今日内容掌握得不错,明天推进 Phase 3 第 9 课(样式与模板)。

📡 推送信息

教学日期
2026-08-21
所属阶段
Phase 3 第 8 课
消息条数
5 条(飞书 DM teacher-yan-bot 推送)
消息 ID
om_x100b674d1d3110a4b0363f7ae256c08om_x100b674d15cc10a4b27c9c5c09d70baom_x100b674d157748b0b1a35c5dfe0816dom_x100b674d127594a4b4c5a8e11a21e23om_x100b674d13ad1cacb1d8a4cc1d3d692
数据来源
feishu_by_date_query