最近在需要使用MVVM框架的时候才发现MvvmLight作者宣布停止更新了,有点可惜。
原作者推荐使用CommunityToolkit.Mvvm包,所以这里做一个CommunityToolkit.Mvvm包使用的全面的总结。
开发环境:
CommunityToolkit.Mvvm
项目地址:https://github.com/CommunityToolkit/dotnet/tree/main/CommunityToolkit.Mvvm
CommunityToolkit.Mvvm 是一个现代、快速和模块化的 MVVM 库。 它是 CommunityToolkit的一部分。由 Microsoft 维护和发布,也是 .NET Foundation 的一部分。
特点如下:
CommunityToolkit.Mvvm包中的类型定义
这里的类型不算太多,目前我只介绍一些我在项目中使用到的类型,应该能满足大部使用场景了。
ViewModelBase
在MvvmLight中,ViewModel一般都会继承自ViewModelBase类,在CommunityToolkit.Mvvm中,具有相同功能的类是ObservableObject。
ObservableObject实现了INotifyPropertyChanged和INotifyPropertyChanging接口,可以作为属性更改引发通知事件的基类。
ObservableObject提供了以下功能(说明:每个功能下都贴出了部分实现代码,大概知道是怎么实现的。如果想要深入了解的话,可以去读一下源码。)
1.NotifyPropertyChanged 和 INotifyPropertyChanging接口的实现,公开了PropertyChanged and PropertyChanging事件。
2.公开派生类型中可以重写的 OnPropertyChanged 和 OnPropertyChanging 方法,以便自定义如何引发通知事件。
public abstract class ObservableObject : INotifyPropertyChanged, INotifyPropertyChanging {     public event PropertyChangedEventHandler? PropertyChanged;     public event PropertyChangingEventHandler? PropertyChanging;      protected virtual void OnPropertyChanged(PropertyChangedEventArgs e)     {         ...         PropertyChanged?.Invoke(this, e);     }      protected virtual void OnPropertyChanging(PropertyChangingEventArgs e)     {         ...         PropertyChanging?.Invoke(this, e);     } } 3.SetProperty函数,在MvvmLight中,也有一个类似的的函数Set(...),可以让属性值更改时引发通知事件变得更加简单。
     protected bool SetProperty([NotNullIfNotNull(nameof(newValue))] ref T field, T newValue, [CallerMemberName] string? propertyName = null)      {          OnPropertyChanging(propertyName);         ...          OnPropertyChanged(propertyName);          ...      }  4.SetPropertyAndNotifyOnCompletion函数,它和SetProperty函数的功能类似,将负责更新目标字段、监视新任务(如果存在)以及在该任务完成时引发通知事件.
     protected bool SetPropertyAndNotifyOnCompletion([NotNull] ref TaskNotifier? taskNotifier, Task? newValue, [CallerMemberName] string? propertyName = null)      {          return SetPropertyAndNotifyOnCompletion(taskNotifier ??= new TaskNotifier(), newValue, null, propertyName);      }    private bool SetPropertyAndNotifyOnCompletion(ITaskNotifier taskNotifier, TTask? newValue, Action? callback, [CallerMemberName] string? propertyName = null)          where TTask : Task      {         ...          bool isAlreadyCompletedOrNull = newValue?.IsCompleted ?? true;           OnPropertyChanging(propertyName);          taskNotifier.Task = newValue;          OnPropertyChanged(propertyName);          if (isAlreadyCompletedOrNull)          {              if (callback is not null)              {                  callback(newValue);              }                return true;          }          ...      }    如何使用ObservableObject类
下面会用几个小例子来演示一下如何使用ObservableObject类。
简单属性
在MvvmLight中,包装属性通知使用的是Set函数
Set(string propertyName, ref T field, T newValue = default, bool broadcast = false);  在CommunityToolkit.Mvvm中,使用的是SetProperty函数。由于propertyName参数增加了CallerMemberName特性,所以并不需要我们手动再去指定,可以直接为空。
  protected bool SetProperty([global::System.Diagnostics.CodeAnalysis.NotNullIfNotNull("newValue")] ref T field, T newValue, [global::System.Runtime.CompilerServices.CallerMemberName] string? propertyName = null)      {          if (global::System.Collections.Generic.EqualityComparer.Default.Equals(field, newValue))          {              return false;          }            field = newValue;            OnPropertyChanged(propertyName);            return true;      }   下面用一个小例子演示一下。
在界面上放置一个TextBox,Content绑定到CurrentTime属性
                                                                         ViewModel如下:
  public class ObservableObjectPageViewModel : CommunityToolkit.Mvvm.ComponentModel.ObservableObject      {          private string currentTime;            public string CurrentTime { get => currentTime; set => SetProperty(ref currentTime, value); }      } 然后我们在ViewModel中启动一个定时器,用于更新时间
                    ......           public ObservableObjectPageViewModel()           {               StartUpdateTimer();           }              private void StartUpdateTimer()           {              System.Windows.Threading.DispatcherTimer dispatcherTimer = new System.Windows.Threading.DispatcherTimer();              dispatcherTimer.Interval = TimeSpan.FromSeconds(1);              dispatcherTimer.Tick += (a, b) => UpdateTime();              dispatcherTimer.Start();          }              ....... 运行后,可以看到时间在更新

包装非Observable的模型
在日常开发中,可能有些数据模型是来自数据库或其它地方,而这些模型不允许我们去重新定义,但是我们又想在属性更改时触发通知事件,这个时候就可以重新包装这些非Observable的数据模型。
有如下的来自数据库的数据模型:
1  public class Student 2     { 3         public string ID { get; set; } 4         public string Name { get; set; } 5     } 可以把它包装成ObservableStudent
这里的SetProperty使用的是如下重载:
     protected bool SetProperty(T oldValue, T newValue, TModel model, global::System.Action callback, [global::System.Runtime.CompilerServices.CallerMemberName] string? propertyName = null)           where TModel : class       {           if (global::System.Collections.Generic.EqualityComparer.Default.Equals(oldValue, newValue))           {               return false;           }              callback(model, newValue);            OnPropertyChanged(propertyName);            return true;      }    T OldValue : 属性的当前值。
T newValue: 属性的新值
Tmodel:正在包装的目标模型
Action
包装后如下:
    public class ObservableStudent : ObservableObject       {           private readonly Student student;              public ObservableStudent(Student student) => this.student = student;              public string Name           {               get => student.Name;              set => SetProperty(student.Name, value, student, (u, n) => u.Name = n);          }            public string ID          {              get => student.ID;              set => SetProperty(student.ID, value, student, (u, n) => u.ID = n);          }      } 在界面上放置一个ListBox,绑定到StudentList
                                                                                                                                                                                                          ViewModel
  public class ObservableObjectPageViewModel : CommunityToolkit.Mvvm.ComponentModel.ObservableObject       {           private ObservableCollection studentList;           public ObservableCollection StudentList { get => studentList; set => SetProperty(ref studentList, value); }              private ObservableStudent selectedStudent;           public ObservableStudent SelectedStudent { get => selectedStudent; set => SetProperty(ref selectedStudent, value); }               public ObservableObjectPageViewModel()          {              InitStudentList();          }            private void InitStudentList()          {              //假设这些数据来自数据库              var dbStudentList = GetDemoData();               StudentList = new ObservableCollection(dbStudentList.Select(x => new ObservableStudent(x)));          }            private List GetDemoData()          {              var list = new List();              Student student1 = new Student() { ID = "1", Name = "相清" };              Student student2 = new Student() { ID = "2", Name = "濮悦" };             list.Add(student1);              list.Add(student2);              return list;          }        }      运行结果如下:

如果没有再次包装成ObservableStudent,直接使用的Student。显示到界面是没有问题的,但是在更改某一项的某个属性时,就会发现界面不会实时刷新。
包装成ObservableStudent后,更改属性值时,界面也会同步更新
Task
日常开发中,我还没有使用过将Task类型包装成属性,一般是直接将需要显示的值定义成属性,等待一个Task的结果,然后绑定显示即可。
在CommunityToolkit.Mvvm包中,可以将Task直接包装成属性,并且能在任务完成后触发通知事件
因为这里官方的文档说得比较简单,示例代码只是演示了如何显示Task的状态,而并没有获取Task的结果,也是困扰了我几天。
后面查了一些资料,受到一些启发。前面在介绍ObservableObject的功能时,说到公开了PropertyChanged事件,这里这里正好可以利用这一点。
这里主要用到SetPropertyAndNotifyOnCompletion函数,跟SetProperty功能类似,但是会在Task完成时引发通用事件。
  private bool SetPropertyAndNotifyOnCompletion(ITaskNotifier taskNotifier, TTask? newValue, Action? callback, [CallerMemberName] string? propertyName = null)          where TTask : Task      {          if (ReferenceEquals(taskNotifier.Task, newValue))          {              return false;          }          bool isAlreadyCompletedOrNull = newValue?.IsCompleted ?? true;          OnPropertyChanging(propertyName);          taskNotifier.Task = newValue;          OnPropertyChanged(propertyName);          async void MonitorTask()          {              await newValue!.GetAwaitableWithoutEndValidation();              if (ReferenceEquals(taskNotifier.Task, newValue))              {                  OnPropertyChanged(propertyName);              }                ...            }            MonitorTask();          return true;      }    这里还有一个新的类型需要了解
TaskNotifier类型,
  protected sealed class TaskNotifier : ITaskNotifier>          {              public static implicit operator Task?(TaskNotifier? notifier);          }     它重新包装了System.Threading.Tasks.Task类型,在封装Task类型的属性时,需要用到它。
TaskNotifier支持直接使用Task
下面先演示一下如何在界面上显示一个Task的状态
在界面上放置一个Label,绑定到MyTask.Status(Converter代码在后面)
  定义一个Task
  private TaskNotifier? myTask;            public Task? MyTask          {              get => myTask;              private set => SetPropertyAndNotifyOnCompletion(ref myTask, value);          }   然后模拟一个Task,等待5秒返回一个字符串结果。
         public ObservableObjectPageViewModel()           {               MyTask = GetTextAsync();           }              private async Task GetTextAsync()           {               await Task.Delay(5000);               return "任务执行后的结果";           }  Converter代码
  public class TaskStatusConverter : IValueConverter       {           public object Convert(object value, Type targetType, object parameter, CultureInfo culture)           {               var status = (TaskStatus)value;                 switch(status)               {                   case TaskStatus.RanToCompletion:                       return "任务完成";                   default:                       return "加载中";               }           }              public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)           {               return DependencyProperty.UnsetValue;           }       }                 运行后可以看到界面会在5秒后更新显示任务状态

如果还想在Task完成后,获取Task的结果,可以增加一个NotifyPropertyChanged事件处理程序方法。
这里需要注意的是,要在MyTask赋值完成后,再增加NotifyPropertyChanged事件处理程序方法,否则会触发两次,在Task未完成时,调用Task.Resut会引起阻塞。
          public ObservableObjectPageViewModel()           {               MyTask = GetTextAsync();                  this.PropertyChanged += ObservableObjectPageViewModel_PropertyChanged;           }              private void ObservableObjectPageViewModel_PropertyChanged(object sender, PropertyChangedEventArgs e)           {               if (e.PropertyName == nameof(MyTask))               {                   //在这里处理Task的结果                   var result = MyTask.Result;               }           } RelayCommand
ICommand接口是用于在 .NET 中为 Windows 运行时 应用编写的命令的代码协定。 这些命令为 UI 元素提供命令行为,如Button的Command。
RelayCommand实现了ICommand接口,可以将一个方法或委托绑定到视图(View)上。
MvvmLight中的命令类也叫RelayCommand,使用方法大同小异,但是在引发CanExeCutechanged事件时,有点区分,这点会在后面说明。
CommunityToolkit.Mvvm库中RelayCommand具备的功能如下(第1点和第2点跟MvvmLight中都是一样的,第3点有区别):
Action 和Func,这也就意味着可以直接使用封装好的方法或lambda表达式。
下面看一个RelayCommand的简单使用
首先创建一个窗口,然后添加一个TextBox和一个Button,TextBox用于显示当前时间,绑定到CurrentTime属性,Button用于更新时间,命令绑定为UpdateCommand:
                                              创建一个ViewModel类,继承自ObservableObject。增加属性CurrentTime和命令UpdateCommand
   public class ObservableObjectPageViewModel : CommunityToolkit.Mvvm.ComponentModel.ObservableObject       {           private string currentTime;              public string CurrentTime { get => currentTime; set => SetProperty(ref currentTime, value); }              public ICommand UpdateCommand { get; set; }                     public ObservableObjectPageViewModel()           {               UpdateCommand = new RelayCommand(UpdateTime);           }              private void UpdateTime()           {               CurrentTime = DateTime.Now.ToString("F");           }   } 设置窗口的DataContext
this.DataContext = new ViewModels.ObservableObjectPageViewModel(); 运行后,单击按钮,可以在文本框显示时间

命令的CanExecute
在MvvmLight中,设置命令的CanExecute后,命令会自动去调用CanExecute去判断命令是否处于可用状态。
调用的时机可以参考
https://blog.walterlv.com/post/when-wpf-commands-update-their-states.html
在CommunityToolkit.Mvvm中,这里有点不一样。需要使用实现了IRelayCommand接口的类RelayCommand,然后再手动调用NotifyCanExecuteChanged()函数来进行通知
下面看一个小例子:
创建一个窗口,界面布局如下:
         ViewModel如下:
 public class ObservableObjectPageViewModel : CommunityToolkit.Mvvm.ComponentModel.ObservableObject     {         private string inputText;          public string InputText { get => inputText; set => SetProperty(ref inputText, value); }          public ICommand MsgShowCommand { get; set; }          public ObservableObjectPageViewModel()         {             MsgShowCommand = new RelayCommand(ShowMsg, CanShowMsgExecute);          }          private void ShowMsg() => MessageBox.Show(InputText);          private bool CanShowMsgExecute() => !string.IsNullOrEmpty(InputText);     } 此时我们运行程序后,输入文本,发现按钮并没有变成可用状态

将ICommand改成IRelayCommand,然后在InputText修改时,调用CanExecute通知
          private string inputText;              public string InputText            {                get => inputText;                set               {                   SetProperty(ref inputText, value);                   MsgShowCommand.NotifyCanExecuteChanged();              }          }            public IRelayCommand MsgShowCommand { get; set; } 再次运行,就可以达到预期效果

AsyncRelayCommand
AsyncRelayCommand提供了和RelayCommand一样的基础命令功能,但是在此基础上,增加了异步。
AsyncRelayCommand具备功能如下:
属性,可以用于判断操作是否完成AsyncRelayCommand中定义的属性如下(部分翻译存在疑问,所以贴出了MSDN中的原文。):
| CanBeCanceled | 获取当前命令能否被取消  | 
| ExecutionTask | 获取任务调度中的最后一个任务。 任务完成后,会引发属性更改通知事件(Gets the last scheduled Task, if available. This property notifies a change when the Task completes.)  | 
| IsCancellationRequested | 获取是否已经请求取消当前操作  | 
| IsRunning | 获取一个值,指示该命令当前是否是执行状态(Gets a value indicating whether the command currently has a pending operation being executed.)  | 
 在官方的示例代码中,我看到了返回Task
界面布局
         界面上有两个Label,一个显示任务状态,一个显示任务结果
ViewModel
AsyncRelayCommand的构造函数需要传入一个返回Task类型的函数或委托。我这里定义了一个GetText函数,在函数里模拟等待了5秒(正常使用时,这个等待可以是任意一个耗时操作。)
  public class AsyncRelayCommandPageViewModel : ObservableObject       {           private string textResult;           public string TextResult { get => textResult; set => SetProperty(ref textResult, value); }              public IAsyncRelayCommand GetTextCommand { get; set; }                      public AsyncRelayCommandPageViewModel()          {              GetTextCommand = new AsyncRelayCommand(GetText);          }            public async Task GetText()          {              await Task.Delay(3000); //模拟耗时操作              TextResult =  "Hello world!";          }      } 运行结果:

这种情况是直接在Task内部处理结果的,也可以直接绑定到AsyncRelayCommand的ExecutionTask,然后用一个Converter来转换值。
下面看另外一个示例
界面布局:
依旧在界面上放置两个Label,一个显示状态,一个显示结果,一个开始任务的按钮。但是这里的结果绑定的是ExecutionTask属性值
         ViewModel:
通过ExecutionTask属性,可以获取到GetTextCommand2最后执行的Task。
然后再通过一个CommunityToolkit.Common包中的Task.GetResultOrDefault()扩展函数,可以获取ExecutionTask的任务返回结果。
  public class AsyncRelayCommandPageViewModel : ObservableObject       {           public IAsyncRelayCommand GetTextCommand2 { get; set; }                 public AsyncRelayCommandPageViewModel()           {                             GetTextCommand2 = new AsyncRelayCommand(GetText2);                        }               public async Task GetText2()           {               await Task.Delay(3000); //模拟耗时操作               return "Hello world!";           }      }  Converter:
  using CommunityToolkit.Common;   using System;   using System.Globalization;   using System.Threading.Tasks;   using System.Windows.Data;      namespace CommunityToolkit.Mvvm.WpfDemo.Converters   {       public class TaskResultConverter : IValueConverter       {              public object Convert(object value, Type targetType, object parameter, CultureInfo culture)           {               if (value is Task task)               {                   return task.GetResultOrDefault();               }                  return null;           }              public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)           {               throw new NotImplementedException();           }       }   } 运行结果:

如何取消AsyncRelayCommand
前面在介绍AsyncRelayCommand的功能时,提到了Cancel函数。可以使用AsyncRelayCommand.Cancel()函数来取消Task的执行。
使用带CancellationToken的重载版本,可以让AsyncRelayCommand具备取消功能。AsyncRelayCommand内部会维护一个CancellationTokenSource实例,然后将CancellationTokenSource.CancellationToken暴露出来。
如果对Task Cancellation不是很理解的话,可以阅读下面的内容
Task Cancellation - .NET | Microsoft Learn
注意:
1.如果AsyncRelayCommand未执行(Task未执行),或者它不支持取消,调用Cancel函数会不起作用。
2.即使成功调用函数,当前的操作也可能 不会立即被取消,这个要根据实际情况。例如:我在过程A和过程B开始前都增加了任务取消操作,但是如果过程A已经执行了,此时去调用取消任务,是不会立即生效的,必须要等到过程A执行完。
1 public AsyncRelayCommand(FunccancelableExecute); 
下面用一个示例来演示一下如何取消AsyncRelayCommand
界面上右边区域用于显示Task的状态,左边是获取并显示一个网站的源码。
获取按钮绑定到StartGetHtmlTaskCommand命令,取消按钮绑定到CancelGetHtmlTaskCommand命令。
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                              ViewModel:
StartGetHtmlTaskCommand使用了带CancellationToken的重载版本。
防止加载太快,看不到效果,我这里增加了5秒的等待。
后面获取网页源码的过程,因为HttpWebRequest中异步的函数都不支持传入CancellationToken,需要重新封装。我这里仅做演示,所以直接把CancellationToken放在了这等待的5秒里。
public class AsyncRelayCommandPageViewModel : ObservableObject     {         private string urlSource;          public string UrlSource { get => urlSource; set => SetProperty(ref urlSource, value); }          private string url;         public string Url         {             get => url;             set             {                 SetProperty(ref url, value);                 StartGetHtmlTaskCommand.NotifyCanExecuteChanged();             }         }          public IAsyncRelayCommand StartGetHtmlTaskCommand { get; set; }          public ICommand CancelGetHtmlTaskCommand { get; set; }          public AsyncRelayCommandPageViewModel()         {             StartGetHtmlTaskCommand = new AsyncRelayCommand(StartTask, () => !string.IsNullOrEmpty(Url));             CancelGetHtmlTaskCommand = new RelayCommand(CancelTask);         }          private async Task StartTask(System.Threading.CancellationToken cancellationToken)         {             UrlSource = await GetHtmlSource(Url, cancellationToken);         }          private async Task GetHtmlSource(string url,System.Threading.CancellationToken cancellationToken)         {             var result = await Task.Run(async () =>             {                  try                 {                     //模拟等待5秒,防止加载太快看不到效果                     await Task.Delay(5000,cancellationToken);                     HttpWebRequest request = (HttpWebRequest)HttpWebRequest.Create(url);                     using (var response = request.GetResponse())                     {                         using (var stream = response.GetResponseStream())                         {                             using (var reader = new System.IO.StreamReader(stream, Encoding.UTF8))                             {                                 return reader.ReadToEnd();                             }                         }                     }                 }                 catch (OperationCanceledException ex)                 {                     return ex.Message;                 }              }, cancellationToken);              return result;         }          private void CancelTask()         {             StartGetHtmlTaskCommand.Cancel();         }      }  运行结果:

代码生成器
CommunityToolkit.Mvvm提供了一个便捷的方式,可以使用自带的源码生成器来快速生成属性、命令。
详细了解可以阅读这篇文章
https://devblogs.microsoft.com/ifdef-windows/announcing-net-community-toolkit-v8-0-0-preview-1/
就像下面这样
 private IRelayCommand greetUserCommand;    public IRelayCommand GreetUserCommand => greetUserCommand ??= new RelayCommand(GreetUser);    private void GreetUser(User user)  {      Console.WriteLine($"Hello {user.Name}!");  }    简化以后:
 [ICommand]  private void GreetUser(User user)  {      Console.WriteLine($"Hello {user.Name}!");  }  private string? firstName;  public string? FirstName {     get => firstName;     set     {         if (SetProperty(ref firstName, value))         {             OnPropertyChanged(nameof(FullName));             GreetUserCommand.NotifyCanExecuteChanged();         }     } }  private string? lastName;  public string? LastName {     get => lastName;     set     {         if (SetProperty(ref lastName, value))         {             OnPropertyChanged(nameof(FullName));             GreetUserCommand.NotifyCanExecuteChanged();         }     } }  public string? FullName => $"{FirstName} {LastName}"; 简化以后
  [ObservableProperty]   [AlsoNotifyChangeFor(nameof(FullName))]   [AlsoNotifyCanExecuteFor(nameof(GreetUserCommand))]   private string? firstName;      [ObservableProperty]   [AlsoNotifyChangeFor(nameof(FullName))]   [AlsoNotifyCanExecuteFor(nameof(GreetUserCommand))]   private string? lastName;     public string? FullName => $"{FirstName} {LastName}"; 示例代码:
GitHub - zhaotianff/CommunityToolkit.Mvvm.WpfDemo: Simple CommunityToolkit.Mvvm.WpfDemo