Bootstrap

C# 浅谈基于Wpf下的MVVM模式的设计思想

目录

 一、Model实体层

二、ViewModel视图模型层

1、定义属性通知基类

1.1 数据验证接口的实现

1.2 验证标识类定义

2、ViewModel前端交互实现

2.1 ICommand命令基类

2.2 窗口管理器实现

三、View前端实现

1、交互行为

2、Adorner装饰器

3、XMAL设计

3.1 引用程序集

3.2  引用装饰器行为

3.3 属性绑定

3.4 附加事件绑定


先套用下老话,什么是MVVM?

        MVVM是Model-View-ViewModel的简写。它本质上就是MVC (Model-View- Controller)的改进版。即模型-视图-视图模型。分别定义如下:

  • 【模型】指的是后端传递的数据。
  • 【视图】指的是所看到的页面。
  • 【视图模型】mvvm模式的核心,它是连接view和model的桥梁。它有两个方向:
    • 一是将【模型】转化成【视图】,即将后端传递的数据转化成所看到的页面。实现的方式是:数据绑定。
    • 二是将【视图】转化成【模型】,即将所看到的页面转化成后端的数据。实现的方式是:DOM 事件监听。这两个方向都实现的,我们称之为数据的双向绑定。

MVVM示意图如下所示:

 一、Model实体层

       在笔者理解看来,它也算是某种意义上的实体,因为完全可以简化真正意义上实体部分(仅需涵盖需与前端交互的字段信息)及可补充需交互时的辅助属性字段,也可以完全以实际实体结构为主;

Example:

public class LoginModel
{
  /// <summary>
  /// 登录账号
  /// </summary>
  public string Logno { get; set; }

  /// <summary>
  /// 用户名称
  /// </summary>
  public string Userna { get; set; }

  /// <summary>
  /// 账号密码
  /// </summary>
  public string Pwd { get; set; }

  /// <summary>
  /// 数据填写正确(可视属性字段)
  /// </summary>
  public bool IsValid { get; set; }
}

Model实际发挥的作用,当然是贯穿DAL层的读写以及与ViewModel层的交互作用;具体对DAL层的交互读写在此就不具体展开了。

二、ViewModel视图模型层

       在这里,可能也有人和笔者同样有着一个好奇的问题,为什么它只称为MVVM,而为什么不称为MVMV?本文开头就已经明确MVMM本质就是由MVC演变而来,由Controller演变成ViewModel视图模型。如果还有人争议为什么MVC不称为MCV,这个问题我想只能去问问MVC的创造者(Trygve Reenskaugh和Adele Goldberg两位大神)了,当然仍感兴趣的朋友可参考:从MVC到现代Web框架 | 码农网 

言归正传,ViewModel是MVVM核心思想,主要承担的就是两件事:

  • Model -> View (通知)
  • View -> Model (通知)

直白的理解,就是消息收发室的概念。通过INotifyPropertyChanged接口实现与客户端属性变更通知。

1、定义属性通知基类

public class NotificationProperty : INotifyPropertyChanged, IDataErrorInfo
{
		#region 属性发生改变通知
		public event PropertyChangedEventHandler PropertyChanged;

		/// <summary>
		/// 发起通知
		/// </summary>
		/// <param name="propertyName">属性名</param>
		public void RaisePropertyChanged(string propertyName)
		{
				PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
		}
		#endregion
}

1.1 数据验证接口的实现

public class NotificationProperty : INotifyPropertyChanged, IDataErrorInfo
{
	#region 数据验证
	protected virtual string GetValidationErrors()
	{
			//验证上下文类实例化
			var vc = new ValidationContext(this, null, null);
			//验证请求的结果容器
			var vResults = new List<ValidationResult>();

			return !Validator.TryValidateObject(this, vc, vResults, true)
					? vResults.Aggregate("", (current, ve) => current + ve.ErrorMessage + Environment.NewLine)
					: "";
	}

	protected virtual string GetValidationErrors(string columnName)
	{
			//验证上下文类实例化
			var vc = new ValidationContext(this, null, null);
			//验证请求的结果容器
			var vResults = new List<ValidationResult>();

			//检查确定指定的对象是否有效
			if (!Validator.TryValidateObject(this, vc, vResults, true))
			{
					string error = "";
					foreach (var ve in vResults)
					{
							if (ve.MemberNames.Contains(columnName, StringComparer.CurrentCultureIgnoreCase))
									error += ve.ErrorMessage + Environment.NewLine;
					}
					return error;
			}
			return "";
	}

	string IDataErrorInfo.Error => GetValidationErrors();

	string IDataErrorInfo.this[string columnName] => GetValidationErrors(columnName);

	/// <summary>
	/// 页面中是否所有控制数据验证正确
	/// </summary>
	public virtual bool IsValid { get; set; }
	#endregion
}

1.2 验证标识类定义

继承所有验证特性ValidationAttribute基类。

/// <summary>
/// 检查字段是否为空
/// </summary>
public class IsNullCheck : ValidationAttribute
{
		public override bool IsValid(object value)
		{
				var name = value as string;
				return !string.IsNullOrEmpty(name);
		}

		public override string FormatErrorMessage(string name)
		{
				return "不能为空";
		}
}

/// <summary>
/// 检查字段是否为数值
/// </summary>
public class LognoExists : ValidationAttribute
{
		public override bool IsValid(object value)
		{
				if (value == null) return false;

				var name = value as string;
				Regex regex = new Regex("^[0-9]*$");
				return regex.IsMatch(name);
		}

		public override string FormatErrorMessage(string name)
		{
				return "账号名必须为纯数字字符!";
		}
}

2、ViewModel前端交互实现

这里既采用预定义的验证标识符,同时将Model中属性逐个均做了客户端通知绑定。重点要说明的是额外增加了ToClose属性(非Model字段),同时此处定义了一个ICommand命令事件。

public class LoginViewModel : NotificationProperty
{
		private LoginModel loginModel = new LoginModel();

		public LoginViewModel()
		{

		}

		[IsNullCheck]
		[LognoExists]
		public string Logno
		{
				get => loginModel.Logno;
				set
				{
						loginModel.Logno = value;
						RaisePropertyChanged("Logno");
				}
		}

		[IsNullCheck]
		public string Password
		{
				get => loginModel.Pwd;
				set
				{
						loginModel.Pwd = value;
						RaisePropertyChanged("Password");
				}
		}

		[IsNullCheck]
		public string Userna
		{
				get => loginModel.Userna;
				set
				{
						loginModel.Userna = value;
						RaisePropertyChanged("Userna");
				}
		}

		private bool toClose = false;
		/// <summary>
		/// 是否要关闭窗口
		/// </summary>
		public bool ToClose
		{
				get => toClose;
				set
				{
						toClose = value;
						if (toClose) RaisePropertyChanged("ToClose");
				}
		}

		/// <summary>
		/// 数据填写正确
		/// </summary>
		public override bool IsValid
		{
				get => loginModel.IsValid;
				set
				{
						loginModel.IsValid = value;
						RaisePropertyChanged("IsValid");
				}
		}

		private BaseCommand loginClick;
		/// <summary>
		/// 登录事件
		/// </summary>
		public BaseCommand LoginClick
		{
				get
				{
						if (loginClick == null)
						{
								loginClick = new BaseCommand(new Action<object>(o =>
								{
										//执行登录逻辑
										WindowManager.Show("MainWindow", null);
										ToClose = true;
								}));
						}
						return loginClick;
				}
		}
}

题外话,NuGet中MvvmLight包,实际上就是省去INotifyPropertyChanged属性通知基类和ICommand基类的一个框架包。

感兴趣的朋友可参考:走进WPF之MVVM完整案例 - 小六公子 - 博客园

2.1 ICommand命令基类

/// <summary>
/// 命令基类
/// </summary>
public class BaseCommand : ICommand
{
		private Func<object, bool> _canExecute;
		private Action<object> _execute;
		public BaseCommand(Action<object> execute, Func<object, bool> canExecute)
		{
				_execute = execute;
				_canExecute = canExecute;
		}

		public BaseCommand(Action<object> execute) : this(execute, null) { }

		public event EventHandler CanExecuteChanged
		{
				add
				{
						if (_canExecute != null) CommandManager.RequerySuggested += value;
				}
				remove
				{
						if (_canExecute != null) CommandManager.RequerySuggested -= value;
				}
		}

		public bool CanExecute(object parameter)
		{
				return _canExecute == null ? true : _canExecute(parameter);
		}

		public void Execute(object parameter)
		{
				if (_execute != null && CanExecute(parameter)) _execute(parameter);
		}
}

注:在XAML UI前端中,Click事件实现的RoutedEventArgs事件,实际上也是经ICommand接口从而实现的。

感兴趣的朋友可参考:WPF Command - Clingingboy - 博客园

 WPF 命令(RoutedCommand自定义命令,实现 ICommand 接口自定义命令)。推荐使用实现 ICommand 接口自定义命令_tiz198183的博客-CSDN博客_routedcommand

2.2 窗口管理器实现

/// <summary>
/// 窗口管理器
/// </summary>
public static class WindowManager
{
	private static Hashtable _RegisterWindow = new Hashtable();

	public static void Register<T>(string key)
	{
			if (!_RegisterWindow.Contains(key))
			{
					_RegisterWindow.Add(key, typeof(T));
			}
	}

	public static void Register(string key, Type t)
	{
			if (!_RegisterWindow.Contains(key))
			{
					_RegisterWindow.Add(key, t);
			}
	}

	public static void Remove(string key)
	{
			if (_RegisterWindow.ContainsKey(key))
			{
					_RegisterWindow.Remove(key);
			}
	}

	public static void Show(string key, object VM)
	{
			if (_RegisterWindow.ContainsKey(key))
			{
					Window win = (Window)Activator.CreateInstance((Type)_RegisterWindow[key]);
					win.DataContext = VM;
					win.Show();
			}
	}
}

该管理器通过哈希表对窗体的注册、移除管理,同时实现Show方法窗体实例化等;使用方式此处就介绍两种最常用的方式:

(1) 直接在窗体后端调用:

public WpfLogin()
{
  InitializeComponent();
  WindowManager.Register<MainWindow>("MainWindow");

  Init();
}

/// <summary>
/// 实例化计时器
/// </summary>
private void Init()
{
		int count = 0, nTimer = 100;
		timer = new DispatcherTimer
		{
				//间隔秒数
				Interval = TimeSpan.FromMilliseconds(nTimer)
		};

		//间隔时触发事件
		timer.Tick += (s, e) =>
		{
				count++;
				timer.Stop();
				//执行登录逻辑
				WindowManager.Show("MainWindow", null);
				Close();
		};

}

(2) 封装ICommand事件进行调用

具体封装方式,上文[VIewModel前端交互实现]处已经详细给出实现部分。XAML调用请见下文[附加事件绑定]处内容。

三、View前端实现

  说到Windows Presentation Foundation (WPF)前端,我们不得不得到它的核心Api【FrameworkElement】:此类表示所提供的 WPF 框架级别实现基于 UIElement 定义的 WPF 核心级别 API。

FrameworkElement UIElement扩展并添加以下功能:

  • 布局系统定义: FrameworkElement 为定义为虚拟成员 UIElement的某些方法提供特定的 WPF 框架级实现。最值得注意的是, FrameworkElement 提供一个与派生类应替代的 WPF 框架级等效项。
  • 逻辑树: 一般 WPF 编程模型通常以元素树表示。 支持将元素树表示为逻辑树,并支持在标记中定义树是在级别实现的 FrameworkElement 。
  • 对象生存期事件: 在调用构造函数) 或首次加载到逻辑树中时,知道何时初始化元素 (通常很有用。 FrameworkElement 定义与对象生存期相关的多个事件,这些事件为涉及元素的代码隐藏操作(例如添加更多子元素)提供有用的挂钩。
  • 支持数据绑定和动态资源引用: 对数据绑定和资源的属性级支持由 DependencyProperty 类实现,并体现在属性系统中,但解析存储 Expression 为 (编程构造中存储的成员值的能力由) 实现 FrameworkElement
  • 风格: FrameworkElement 定义 Style 属性。 但是, FrameworkElement 尚未定义对模板或支持修饰器的支持。 这些功能由控件类(如 Control 和 ContentControl)引入。

  • 更多动画支持: 某些动画支持已在 WPF 核心级别定义,但 FrameworkElement 通过实现 BeginStoryboard 和相关成员来扩展此支持。 

详情请参考微软官方API文档:FrameworkElement 类 (System.Windows) | Microsoft Learn

1、交互行为

这里,笔者使用的是微软NuGet包:Microsoft.Xaml.Behaviors.Wpf,有助于精简对行为的定义与交互配置:

/// <summary>
/// 验证异常行为
/// </summary>
public class ValidationExceptionBehavior : Behavior<FrameworkElement>
{
		/// <summary>
		/// 记录异常的数量
		/// </summary>
		/// <remarks>
		/// 在一个页面里面,所有控件的验证错误信息都会传到这个类上,每个控制需不需要显示验证错误,需要分别记录
		/// </remarks>
		private Dictionary<UIElement, int> ExceptionCount;

		/// <summary>
		/// 缓存页面的提示装饰器
		/// </summary>
		private Dictionary<UIElement, NotifyAdorner> AdornerDict;

		/// <summary>
		/// 隐藏错误信息提示
		/// </summary>
		private void HideAdorner(UIElement element)
		{
				if (AdornerDict.ContainsKey(element))
				{
						AdornerLayer adornerLayer = AdornerLayer.GetAdornerLayer(element);
						adornerLayer.Remove(AdornerDict[element]);
						AdornerDict.Remove(element);
				}
		}

		/// <summary>
		/// 显示错误信息提示
		/// </summary>
		private void ShowAdorner(UIElement element, string errorMessage)
		{
				if (AdornerDict.ContainsKey(element))
				{
						AdornerDict[element].ChangeToolTip(errorMessage);
				}
				else
				{
						AdornerLayer adornerLayer = AdornerLayer.GetAdornerLayer(element);
						NotifyAdorner adorner = new NotifyAdorner(element, errorMessage);
						adornerLayer.Add(adorner);
						AdornerDict.Add(element, adorner);
				}
		}

		/// <summary>
		/// 获得行为所在窗口的DataContext
		/// </summary>
		private NotificationProperty GetValidationExceptionHandler()
		{
				if (AssociatedObject.DataContext is NotificationProperty)
				{
						NotificationProperty handler = AssociatedObject.DataContext as NotificationProperty;

						return handler;
				}

				return null;
		}

		/// <summary>
		/// 当验证错误信息改变时,首先调用此函数
		/// </summary>
		private void OnValidationError(object sender, ValidationErrorEventArgs e)
		{
				try
				{
						//错误信息发生改变的控件
						//插入<c:ValidationExceptionBehavior></c:ValidationExceptionBehavior>此语句的窗口的DataContext,也就是ViewModel
						NotificationProperty handler = GetValidationExceptionHandler();

						if (handler == null || !(e.OriginalSource is UIElement element))
						{
								return;
						}

						if (e.Action == ValidationErrorEventAction.Added)
						{
								if (ExceptionCount.ContainsKey(element))
								{
										ExceptionCount[element]++;
								}
								else
								{
										ExceptionCount.Add(element, 1);
								}
						}
						else if (e.Action == ValidationErrorEventAction.Removed)
						{
								if (ExceptionCount.ContainsKey(element))
								{
										ExceptionCount[element]--;
								}
								else
								{
										ExceptionCount.Add(element, -1);
								}
						}

						if (ExceptionCount[element] <= 0)
						{
								HideAdorner(element);
						}
						else
						{
								ShowAdorner(element, e.Error.ErrorContent.ToString());
						}

						int TotalExceptionCount = 0;
						foreach (KeyValuePair<UIElement, int> kvp in ExceptionCount)
						{
								TotalExceptionCount += kvp.Value;
						}

						handler.IsValid = TotalExceptionCount <= 0;//ViewModel里面的IsValid
				}
				catch (Exception ex)
				{
						throw ex;
				}
		}

		protected override void OnAttached()
		{
				ExceptionCount = new Dictionary<UIElement, int>();
				AdornerDict = new Dictionary<UIElement, NotifyAdorner>();

				AssociatedObject.AddHandler(Validation.ErrorEvent, new EventHandler<ValidationErrorEventArgs>(OnValidationError));
		}
}

注意,目前最新版本仅支持到.NET 5.0的框架使用。

2、Adorner装饰器

       装饰器是绑定到一个UIElement自定义FrameworkElement。 装饰器在装饰器层中呈现,它是始终位于装饰元素或装饰元素集合之上的呈现图面:装饰器呈现与装饰器绑定到的呈现 UIElement 无关。 装饰器通常使用位于装饰元素左上部的标准 2D 坐标原点,相对于其绑定到的元素进行定位。

详情请见微软官方Api文档:Adorner 类 (System.Windows.Documents) | Microsoft Learn

 具体实现带惊叹号的提示框:

/// <summary>
/// 带有惊叹号的提示图形
/// </summary>
public class NotifyAdorner : Adorner
{
		//Visual对象有序集合
		private VisualCollection _visuals;
		//绘制区域
		private Canvas _canvas;
		//图像控件
		private Image _image;
		//轻型控件,用于显示少量流内容
		private TextBlock _toolTip;

		public NotifyAdorner(UIElement adornedElement, string errorMessage) : base(adornedElement)
		{
				_visuals = new VisualCollection(this);

				_image = new Image()
				{
						Width = 16,
						Height = 16,
						Source = new BitmapImage(new Uri("/Resources/warning.png", UriKind.RelativeOrAbsolute))
				};

				_toolTip = new TextBlock() { Text = errorMessage };
				_image.ToolTip = _toolTip;

				_canvas = new Canvas();
				_canvas.Children.Add(_image);
				_visuals.Add(_canvas);
		}

		//获取此元素内可视子元素的数目
		protected override int VisualChildrenCount => _visuals.Count;

		//重写从子元素集合中返回指定索引处的子元素
		protected override Visual GetVisualChild(int index)
		{
				return _visuals[index];
		}

		public void ChangeToolTip(string errorMessage)
		{
				_toolTip.Text = errorMessage;
		}

		//实现装饰器的任何自定义度量行为
		protected override Size MeasureOverride(Size constraint)
		{
				return base.MeasureOverride(constraint);
		}

		//为 FrameworkElement 派生类定位子元素并确定大小
		protected override Size ArrangeOverride(Size finalSize)
		{
				_canvas.Arrange(new Rect(finalSize));
				_image.Margin = new Thickness(finalSize.Width + 3, 0, 0, 0);

				return base.ArrangeOverride(finalSize);
		}
}

3、XMAL设计

3.1 引用程序集

<!-- Microsoft.Xaml.Behaviors.Wpf程序集 -->
xmlns:i="http://schemas.microsoft.com/xaml/behaviors"
<!-- Mdel实体、ViewModel交互模型以及数据校验ValidationAttribute标识实现 -->
xmlns:c="clr-namespace:SmartCar.ViewModel.Common"
<!-- UserControl自定义控件 -->
xmlns:CustCtl="clr-namespace:SmartCar.BaseUI.CustCtl;assembly=SmartCar.BaseUI"
<!-- DataContext 上下文绑定 ViewModel模型 -->
xmlns:model="clr-namespace:SmartCar.ViewModel.Model" 
d:DataContext="{d:DesignInstance Type=model:LoginViewModel}"

3.2  引用装饰器行为

<i:Interaction.Behaviors>
  <c:ValidationExceptionBehavior></c:ValidationExceptionBehavior>
  <c:WindowBehavior Close="{Binding ToClose}"/>
</i:Interaction.Behaviors>

3.3 属性绑定

<!-- Binding 属性名 -->
<!-- Mode=TwoWay:双向绑定通知模式 -->
<!-- UpdateSourceTrigger=PropertyChanged: 每当绑定目标属性发生更改时,都会更新绑定源 -->
<!-- ValidatesOnExceptions=True: 是否包含ExceptionValidationRule 用于检查绑定源属性更新过程中抛出的异常的规则 -->
<!-- ValidatesOnDataErrors=True: 检查由源对象的 IDataErrorInfo 实现所引发的错误 -->
<!-- NotifyOnValidationError=True: 是否对绑定对象引发 Error 附加事件 -->
<TextBox x:Name="tBoxLogno" Text="{Binding Logno,Mode=TwoWay,UpdateSourceTrigger=PropertyChanged,ValidatesOnExceptions=True, ValidatesOnDataErrors=True, NotifyOnValidationError=True}" />
<TextBox x:Name="tUserna" Text="{Binding Userna,Mode=TwoWay,UpdateSourceTrigger=PropertyChanged,ValidatesOnExceptions=True, ValidatesOnDataErrors=True, NotifyOnValidationError=True}"/>

<CustCtl:CustPasswordBox x:Name="tBoxPassword" FontSize="14" MaxLength="10"
													Password="{Binding Password,Mode=TwoWay,UpdateSourceTrigger=PropertyChanged,ValidatesOnExceptions=True, ValidatesOnDataErrors=True, NotifyOnValidationError=True}"
													Height="22" Width="150" Margin="0,8,180,8" >
</CustCtl:CustPasswordBox>

3.4 附加事件绑定

<!-- IsValid: ViewModel属性绑定 -->
<Button x:Name="LoginBtn" Content="登录" Width="100" Height="30" IsEnabled="{Binding IsValid}">
    <!-- 行为触发 -->
    <i:Interaction.Triggers>
      <i:EventTrigger EventName="Click">
        <!-- ICommand 命令事件绑定 -->
        <c:EventCommand Command="{Binding LoginClick}" />
      </i:EventTrigger>
    </i:Interaction.Triggers>
</Button>

微软官方Api技术文档请见:附加事件概述 - WPF .NET | Microsoft Learn

附加事件的应用场景非常广泛,其定义形式与绑定方式也有很多种。具体还有哪些,欢迎各位大神留言提出宝贵的意见思路。

最终效果

;