ViewModel - ViewModel 通信的另一种方式 MVVM - 第 1 部分(共 2 部分)






4.33/5 (2投票s)
基于 MultiBinding 的 ViewModel 实现技术 - MVVM 模式下的 ViewModel 通信。
目录
引言
本文试图填补 Model-View-ViewModel 模式中一个众所周知的空白 - ViewModel-ViewModel 通信。也就是说,更精确地说,是不同 ViewModel 之间的数据交换。该问题的解决方案通过 Binding
(更准确地说,是 MultiBinding
)来呈现和实现。这是 MVVM 最自然和标准的方式,因为 Binding
是 MVVM 中大部分数据交换的通用语言。解决方案以透明的方式实现 - 只使用了 WPF 的通用和标准特性。
背景
随着 .net 技术的发展,用于 GUI 设计的模式实现变得尤其成功。在 WPF 出现之前,可以提到 Model-View-Controller 和 Model-View-Presenter 模式;在我们 WPF 的时代,Model-View-ViewModel 模式[1]理应占据榜首。
所有这些 WPF 之前的和 WPF 的设计模式都有一个独立的表示层(GUI)和一个管理 GUI 的独立控件层。MVC 模式中的 Controller、MVP 模式中的 Presenter、MVVM 模式中的 ViewModel 都可以算作这样的控件层。
在 WPF 出现之前,模式中的大多数 GUI 管理操作(又名控件模块)都是基于相对底层的事件(或弱事件)架构执行的。以同样底层的方式组织不同控件模块之间的通信很容易。通过事件,不同的控件模块可以交换数据,并且可以适当地响应任何其他事件的触发。通过这种控件-控件通信,可以组织非常强大的分层和网络图结构。
另一方面,MVVM 模式基于数据绑定 - 高级 GUI 管理操作;一个弱事件引擎隐藏在数据绑定的深处。然而,数据绑定不能用于 ViewModel 之间的通信,因为数据绑定的至少一个参与者应该继承 DependencyObject
类 - 而根据定义,没有 ViewModel 继承自 DependencyObject
。已经提出了几种想法来填补 MVVM 设计中的这个空白。让我们考虑其中一些。
- 简单的基于事件的通信(例如,[2])。一个 ViewModel 声明一个静态事件,并在某些条件下触发它,例如当某个属性更改时。另一个 ViewModel 订阅此事件。这种方法有明显的缺点:任何通信都应该基于它自己的事件,并且 ViewModel 将充满事件处理程序;一个 ViewModel“应该知道”另一个 ViewModel 的名称,并且应该能够访问它的属性(即使它是静态的)- 设计变得紧密耦合。
- 中介者基于的通信。这种方法最初由 Marlon Grech [3] 为 WPF 实现 MVC 模式的适配而实现。他的出色工作后来被 Sasha Barber [4] 扩展到 MVVM 模式。之后,该方法得到了广泛认可、开发和实施。该方法仍然基于事件引擎(有时是弱事件引擎)。它通过使用中介者功能完全避免了紧密耦合 - 没有 ViewModel 知道其他 ViewModel 的任何信息。即使如此,任何试图广播给其他 ViewModel 的 ViewModel 都应该以某种方式向中介者注册 - 这会给 ViewModel 增加额外的代码,并略微模糊了 ViewModel 的真正含义 - 视图管理。此外,这种方法与 MVVM 的绑定标准相比,仍然使用底层通信。
- 基于绑定的通信。这种方法在逻辑上是合理的,因为它为 MVVM 模式的所有部分提供了功能同质性:View-ViewModel 和 ViewModel-ViewModel 通信都基于相同通用级别的手段。在 Tore Senneseth [5] 的文章中可以找到一种有趣的文章实现。在文章中,绑定在 View-ViewModel 通信级别上进行维护,在 ViewModel 的属性更改后,一个中间的
DependencyObject
,带有 Dependency Properties,发挥作用。其中一个 Dependency Property 通过属性更改回调将这些更改传递给另一个 Dependency Property,后者又绑定到目标 ViewModel,依此类推。中间 DependencyObject 的想法很棒,并且将在本文中(但以不同的方式)使用。
总体要求
- ViewModel 之间的通信应该与其他 MVVM 方式保持一致,即它们应该基于 Binding。
- 通信定义应该以 XAML 的方式声明为资源。这些定义应该提供足够的数据,以便在自定义类或现有类的基础上创建通信。这个要求非常重要,因为它提供了在项目准备阶段进行通信“计算”的能力。
- 此类通信应在程序准备阶段进行计算、准备和创建为类的实例,并在程序与 GUI 交互期间根据需要激活和执行。
- 此类通信的激活可能在某个属性的值更改回调期间,或在某个 GUI 交互期间进行。
- 通信激活方法应在抽象 ViewModel 的基类中定义,应适用于任何通信,并应在需要时在 ViewModel 属性的 getter 或 setter 中或在 ViewModel 命令中调用。ViewModel 中不应包含任何其他关于通信的代码干预。任何 ViewModel 都不应继承除抽象 ViewModel 基类以外的任何其他类。
- 作为指定类的实例的通信应存储在某个存储库中以供重复使用,并防止垃圾回收器过早地将其回收。除了通信实例之外,一些额外的对象也应该(或可以)存储在此存储库中。
根据要求实现
我们将以稍有不同的顺序考虑这些要求的实现。
基于 XAML 的通信定义
作为数据交换的通信基于 ViewModel 及其属性。应该区分发送数据的源 ViewModel 和源属性,以及接收数据的目标 ViewModel 和目标属性。一般而言,可能存在多个目标,但我们将在文章末尾仅对此可能性进行少量触及。
实现非常直接
public class CommunicationDef
{
public string SourceViewModel { get; set; }
public string SourceProperty { get; set; }
public string TargetViewModel { get; set; }
public string TargetProperty { get; set; }
}
using System.Windows.Markup;
namespace VMCommunications.Definitions
{
[ContentProperty("CommunicationDefs")]
public class CommunicationHolder
{
private List<CommunicationDef> _communicationsDef = new List<CommunicationDef>( );
public List<CommunicationDef> CommunicationDefs
{
get { return _communicationDefs; }
set { _communicationDefs.AddRange( value );
}
}
}
<def:CommunicationHolder x:key="vmCommunications">
<def:CommunicationDef SourceViewModel="ViewModel1"
SourceProperty="Exchange11"
TargetViewModel="ViewModel2"
TargetProperty="Exchange21">
...
</def:CommunicationHolder>
要求 #2 已满足。
基于绑定的通信引擎
解释
ViewModel 之间的数据交换是通信的目的。在编程上,是 BindingExpression
维护 WPF 中的数据交换。
尽管如此,BindingExpression
有一个严格的限制:其 Target 对象应继承自某个 DependencyObject
。根据要求 #5,没有 ViewModel 应继承除抽象 ViewModelBase 以外的任何其他类,该类本身只能继承自 IViewModel
(或直接继承自 INotifyPropertyChanged
)。似乎没有 DepenedencyObject
的位置!
取而代之的是 MultiBindingExpression
。任何 MultiBindingExpression
都有一个单一的目标对象,该对象应继承自某个 DependencyObject。此外,任何 MultiBindingExpression
都基于 BindingExpressions 的集合,集合中的每个 BindingExpression 都基于其自己的 Source 对象。
我们的两个 ViewModel - Source 和 Target - 都适合作为这些 BindingExpressions 的 Source。一个 ViewModel 应将数据发送到 MultiBindingExpression
的 Target 对象,其 BindingMode 应为 OneWay
,而另一个应以 OneWayToSource
模式从 MultiBindingExpression
的 Target 对象接收数据。MultiBindingExpression
的 Target 对象可以定义为人工的 DependencyObject
,并扮演两个 ViewModel 之间的连接点(Splice)的角色。
为了不混淆,让我们区分术语。在通信级别上,我们将源 ViewModel 称为逻辑源 ViewModel,目标 ViewModel 称为逻辑目标 ViewModel,我们将 MultiBindingExpression
的目标在逻辑上称为逻辑连接点(LogicalSplice)。当被视为 MultiBindingExpression
的(深度)成员时,Source 和 Target ViewModel 将被称为物理源,而 MultiBindingExpression
的目标将被称为物理目标。
不幸的是,MultiBindingExpression
是一个 sealed 类,我们无法从中派生出所需的 Communication 类 :( 。所以 MultiBindingExpression
将扮演
Communication 本身的角色。通信部分的简化图示如图 1 所示。
严格定义
我喜欢扩展风格的编码,所以大部分代码将以这种方式表示。
private static Binding CreateBinding( string path, BindingMode mode, string className )
{
// BindingSetMode and BindingSetSourcce should be found in ExtensionsHelper
return ( new Binding( path ) ).BindingSetMode( mode ).BindingSetSource( className );
}
private static Binding SourceViewModelBinding( CommunicationDef communication )
{
// source of first binding, that is source of ViewModel communication
return CreateBinding( communication.SourceProperty, BindingMode.OneWay, communication.SourceViewModel );
}
private static Binding TargetViewModelBinding( CommunicationDef communication )
{
// source of second binding, that is target of ViewModel communication
return CreateBinding( communication.TargetProperty, BindingMode.OneWayToSource,
communication.TargetViewModel );
}
public static MultiBindingExpression CreateBindingExpression(CommunicationDef communication)
{
// all extension methods should be found in ExtensionHelper
return
BindingOperations.SetBinding(
CommunicationsSpliceRepository.Resolve( Tuple.Create( communication.SourceViewModel,
communication.SourceProperty ) ),
CommunicationsSplice.SpliceProperty,
( new MultiBinding( ) ).
AddBinding( SourceViewModelBinding( communication ) ).
AddBinding( TargetViewModelBinding( communication ) ).
SetConverter( new CommunicationsConverter( ) ).
SetUpdateSourceTrigger( UpdateSourceTrigger.Explicit ).
SetBindingMode( BindingMode.TwoWay )
) as MultiBindingExpression;
}
BindingOperations
.SetBinding
接受三个参数来创建 MultiBindingExpression
。第一个参数是 PhysicalTarget;这是我们将稍后讨论的CommunicationsSplice对象;在快照 4 中,它从其存储库中解析。
第二个参数是 PhysicalTarget 的 Target DependencyProperty
,即CommunicationSplice.SpliceProperty。
第三个参数是新的 MultiBinding
对象。让我们考虑它的创建。
在第一阶段(AddBindind( . . . )),MultiBinding
被配备了 Bindings。通过SourceViewModelBinding 和TargetViewModelBinding 方法,这些 Bindings 基于 CommunicationDef 中的 SourceProperty 和 TargetProperty 名称构建。之后,Bindings 被提供绑定模式:对于 LogicalSource 是 OneWay
,对于 LogicalTarget 是 OneWayToSource
- 并且 Binding.Source
属性使用相应的 ViewModel
对象进行求值。这些 ViewModel
对象根据 CommunicationDef 中的名称从 Repository 中解析(Repository 策略将在后面讨论)。
在第二阶段,一个派生自 IMultiValueConverter
的重要CommunicationConverter 被设置给 MultiBinding
(SetConverter( . . . ))。让我们详细考虑CommunicationConverter 。
public object Convert( object[ ] value, Type targetType,
object parameter, CultureInfo culture )
{
return value[ 0 ];
}
public object[ ] ConvertBack( object value, Type[ ] targetType,
object parameter, CultureInfo culture )
{
return new object[ ] { Binding.DoNothing, value };
}
Convert 方法返回 MultiBinding
的第一个源 - LogicalSource - 的值,以进一步发送到 MultiBindingExpression
的 Target - PhysicalTarget。ConvertBack 方法从 PhysicalTarget 获取值并将其“广播”到 MultiBinding
Sources 中。在ConvertBack 期间,第一个 PhysicalSource(它是 LogicalSource)应该什么都不接收 - 它接收 Binding.DoNothing
。同时,第二个 PhysicalSource(即我们的 LogicalTarget)应该接收发送的值 - 来自 PhysicalTarget - 并且它接收所需的值。Convert 将通过调用 MultiBindingExpression.UpdateTarget() 激活,而ConvertBack 将通过调用 MultiBindingExpression.UpdateSource() 激活。
在 MultiBinding
构造的第三阶段,将 UpdateSourceTrigger
属性设置为 Explicit 值。这将提供从 ViewModels 对 MultiBindingExpression
的显式激活。
最后,将 MultiBinding 的 BindingMode 设置为 TwoWay
模式以进行所有方向的更新。
要求 #1 和 #3 已满足。
存储库策略
组织一种存储库来存储预先创建和可重用的对象是我们方法的一个重要特性。
MultiBindingExpressions(通信)在程序开始时创建,并多次用于数据交换。它们存储在 BindingExpressionsRepository 中,并使用 Tuple<CommunicationDef.SourceViewModel, CommunicationDef.SourceProperty> 作为键。
在此应用程序中,Views 和 ViewModels 也被预先创建;它们存储在 ViewRepository 中,因为它们在程序执行期间的通信过程中会多次参与。Views 和 ViewModels 的延迟创建也是可能的,将在文章的第 2 部分进行讨论。ViewRepository 使用 CommunicationDef.SourceViewModel 作为键。
CommunicationSplices 的存储库不仅因为后者在通信中使用,而且因为垃圾回收器在几次通信激活后认为它们将来不再被访问,并“吃掉”它们,所以它非常重要。存储在存储库中的 CommunicationSplice 的附加引用可以保存它。CommunicationSpliceRepository 使用
Tuple<CommunicationDef.SourceViewModel, CommunicationDef.SourceProperty> 作为键
存储库组织在 ConcurrentDictionaries 中(不仅仅是为了并发,还因为一些有用的方法)。
存储库部分遵循 Locator 服务范式,并公开 Register 和 Resolve 方法。特别注意了存储库的清除。
这些方法没有什么特别困难的,我将引用源代码。
要求 #6 已满足。
通信激活方法
对于 MultiBinding
,通过将其参数 UpdateSourceTrigger
设置为 Explicit
,我们被迫显式调用 TargetUpdate( ) 和 SourceUpdate( ) 方法来激活 MultiBindingExpression
。MultiBindingExpression
存储在存储库中,ActivateVMCommunication 方法成功执行此操作。
在此方法内部,调用 MultiBindingExpression 的 UpdateTarget() 可将数据从 LogicalSource 传输到 LogicalSplice。根据 CommunicationsConverter 提供的操作,对 MultiBindingExpression 的 UpdateSource() 的后续调用将数据从 LogicalSplice 传输到 LogicalTarget。
protected void ActivateVMCommunication<T>( string sourceName, Expression<Func<T>> propertyExpression )
{
Tuple<string, string> key = Tuple.Create( sourceName,
NameTypeHelper.NameInfer( propertyExpression ) );
MultiBindingExpression mbe = BindingExpressionRepository.Resolve( key );
if ( mbe != default( BindingExpressionBase ) )
{
mbe.UpdateTarget( );
mbe.UpdateSource( );
}
}
对 ActivateVMCommunication 方法的调用可以从属性的 setter(在 PropertyChange 事件激活之后)进行。
public string Exchange11
{
get { return _exchange1; }
set
{
if ( value != _exchange1 )
{
_exchange1 = value;
// communicate with communication target
ActivateVMCommunication( GetType( ).Name, ( ) => Exchange11 );
}
}
}
或从命令执行方法进行。
CommunicateCommand = new RelayCommand( CommunicateAction, CanCommunicateAction );
...
private void CommunicateAction( object parameter )
{
// according to communications definitions sends text back to ViewModel1
ActivateVMCommunication( GetType( ).Name, ( ) => Exchange21 );
}
要求 #4 和 #5 已满足。
示例:VMCommunications 项目
演示用的 VMCommunications 项目很简单。它包含两个 Views(以及与这些 Views 对应的两个 ViewModels)。
第一个任务 - 在 View1 中键入数据,同时将数据从 ViewModel1 发送到 ViewModel2
在其他控件中,View1 包含一个 TextBox。
<TextBox TextAlignment="Left" TextWrapping="Wrap" Background="Ivory" ...>
...
<TextBox.Text>
<Binding Path="View1Text1" UpdateSourceTrigger="PropertyChanged"/>
</TextBox.Text>
</TextBox>
UpdateSourceTrigger 参数设置为 PropertyChanged 值,以便立即将键入的文本映射到 ViewModel1 的属性 View1Text1。在属性 View1Text1 中进行数据分配给属性 Exchange11。
public string View1Text1
{
. . . .
set
{
if ( value != _view1Text1 )
{
_view1Text1 = value;
// send value to communication property
Exchange11 = _view1Text1;
}
}
}
属性 Exchange11 与 ViewModel2.Exchange21 通信(如快照 #3 中所述)。Exchange11 的 Setter(见快照 7)在每次值更改时都会引发通信,并且此新值会立即出现在 Exchange21 中。
属性 Exchange21 反过来执行其工作 - 将数据重新发送到属性 ViewModel2.View2Text,该属性绑定到 View2 的 TextBlock;View2Text 触发 PropertyChanged 事件,数据出现在 TextBlock 中 - 与 View1.TextBox 中的键入同步。
有必要 强调一件 非常重要的事情。 请注意,在 ViewModel - ViewModel 通信和与 Views 的绑定中涉及不同的属性。 ViewModel1.View1Text1 和 ViewModel2.View2Text 忙于与 Views 绑定,而 Exchange11 和 Exchange21 忙于通信。有人可能会认为这是多余的 - 例如,属性 View1Text1 和 View2Text 可以同时扮演这两个角色 - 相互通信并绑定到 Text 属性以显示数据。但事实并非如此!
原因是绑定到 View 的属性遵循 PropertyChanged
范式 - 它们在其 setter 中触发 PropertyChanged
事件 - 当属性值更改时。PropertyChanged
引擎为基于某个属性的所有 Bindings 提供事件订阅。如果一个属性参与了 ViewModel
- ViewModel
通信,那么它将参与通信的 PhysicalSource ParentBindings 之一 - 与到 View 的 DependencyProperty
的 Binding 并列。每当属性更改时,通信的 Binding 会与到 View 的 Binding 一起被激活。这是不希望的行为。
因此,大多数 ViewModel - ViewModel 通信应基于不参与 View - ViewModel 连接的属性。可能需要额外的属性被认为是提议方法的一个缺点。即便如此,可以用非正式规则来弥补:“一个属性 - 一个或少于一个绑定”。
第二个任务 - 将一段文本从 ViewModel2 发回 ViewModel1
让我们展示从一个 ViewModel 发送一段文本到另一个 ViewModel。为了好玩,让我们获取 ViewModel2 中形成的文本,并将其发送回 ViewModel1,然后再发送到 View1 的 TextBlock,以便每个单词都被反转,但单词的整体顺序保持不变。
应该对发送的文本应用数据转换。
数据转换(连同数据提供)是 Model 的任务。将 Model 作为成员(或局部变量)包含在 ViewModel 中不是一个正确的方法。但是,我将把与 Model 通信的方式推迟到以后讨论。
PseudoModel 是一个类,它执行所需的转换。来自文件 PseudoModel.cs 的静态方法 PseudoModel.ReverseWords 实现了转换。在 ViewModel2.Exchange21 属性的 getter 中调用 ReverseWords 。通信将反转的文本发送到 ViewModel1。
public string Exchange21
{
get
{
return PseudoModel.ReverseWords( View2Text );
}
. . . .
}
此通信的激活是通过命令 CommunicateCommand 进行的,该命令又通过按下“返回反转的单词文本...”按钮来激活。
图 3 显示了通信操作的结果。
最后的话和未来考虑
我曾承诺简要谈谈通信中的广播 - 即一个值从源 ViewModel 发送到多个目标 ViewModel 的情况。这需要在通信定义中进行更改 - 在 CommunicationConverter 中使用 TargetViewModels 和 TargetProperties 的集合 - 以不同的方式进行 Convert 和 ConvertBack 操作,以及一些额外的检查。所有这些都可以在未来考虑。
具体来说,在文章的第二部分,我们将考虑在 ViewModel - ViewModel 通信中延迟创建 Views 和 ViewModels。还将进行一些性能评估。
参考文献
1. Vice R., Siddiqi M.S. MVVM Survival Guide for Enterprise Architectures in Silverlight and WPF. – PACT publishing, 2012.
2. Gilbert D. Simple Inter-ViewModel Communication in WPF.
3. Grech M. More than Just MVC for WPF.
4. Barber S. MVVM Mediator Pattern.
5. Senneseth T. Object-To-Object Binding / ViewModel-To-ViewModel Binding.