长话短说:
解决方案是显示OpenFileDialog
一个类,它是视图组件的一部分。
这意味着,这样的类必须是视图模型未知的类,因此不能被视图模型调用。
该解决方案当然可以涉及代码隐藏实现,因为在评估解决方案是否符合MVVM时,代码隐藏并不相关。
除了回答最初的问题,这个答案还试图提供一个关于一般问题的替代视图,为什么从视图模型控制 UI 组件(如对话框)违反了MVVM设计模式,以及为什么像对话框服务这样的变通方法不能解决问题。
1 MVVM 和对话框
1.1 对常见建议的批评
几乎所有的答案都遵循这样的误解,即MVVM是一种模式,它针对类级别的依赖关系并且还需要空的代码隐藏文件。但它是一种架构模式,它试图解决一个不同的问题——在应用程序/组件级别:保持业务域与 UI 分离。
大多数人(这里是 SO)同意视图模型不应该处理对话框,但随后建议将 UI 相关逻辑移动到一个帮助器类(不管它是否称为帮助器或服务),它仍然由视图控制模型。
这(尤其是服务版本)也称为依赖隐藏. 许多模式都是这样做的。这种模式被认为是反模式。服务定位器是最著名的依赖隐藏反模式。
这就是为什么我将任何涉及将 UI 逻辑从视图模型类提取到单独的类的模式也称为反模式的原因。它没有解决最初的问题:如何更改应用程序结构或类设计,以便从视图模型(或模型)类中删除 UI 相关职责并将其移回视图相关类。
换句话说:关键逻辑仍然是视图模型组件的一部分。
出于这个原因,我不建议实施像公认的那样涉及对话服务的解决方案(无论它是否隐藏在界面后面)。如果您担心编写符合MVVM设计模式的代码,那么就不要在视图模型中处理对话框视图。
引入一个接口来解耦类级别的依赖,例如一个IFileDialogService
接口,称为依赖倒置原则(SOLID 中的 D),与MVVM无关。当它与MVVM无关时,它无法解决与MVVM相关的问题。如果室温与建筑物是四层楼还是摩天大楼没有任何关系,那么改变室温永远不会将任何建筑物变成摩天大楼。MVVM不是Dependency Inversion的同义词。
MVVM是一种架构模式,而依赖倒置是一种 OO 语言原则,与构建应用程序(也称为软件架构)无关。构造应用程序的不是接口(或抽象类型),而是抽象对象或实体,如组件或模块,例如Model - View - View Model。接口只能帮助“物理地”解耦组件或模块。它不会删除组件关联。
1.2 为什么对话或处理Window
一般感觉很奇怪?
我们必须记住,对话框控件Microsoft.Win32.OpenFileDialog
是“低级”本机Windows 控件。他们没有必要的 API 将它们顺利集成到MVVM环境中。由于它们的真实性质,它们在集成到 WPF 等高级框架的方式上存在一些限制。通常,对话框或本机窗口主机是 WPF 等高级框架的已知“弱点”。
对话框通常基于抽象类Window
或抽象CommonDialog
类。该类Window
是一个ContentControl
,因此允许样式和模板以内容为目标。
一个很大的限制是,aWindow
必须始终是根元素。您不能将其作为子项添加到可视化树中,例如使用触发器显示/启动它或将其托管在DataTemplate
.
如果是CommonDialog
,则无法将其添加到可视化树中,因为它不扩展UIElement
.
因此,必须始终从代码隐藏中显示Window
或CommonDialog
基于类型,我想这是正确处理此类控件的巨大混乱的原因。
此外,许多开发人员,尤其是刚接触 MVVM的初学者,都认为代码隐藏违反了MVVM。
由于一些不合理的原因,他们发现处理视图模型组件中的对话框视图不太违反。
由于它的 API,aWindow
看起来像一个简单的控件(实际上,它扩展了ContentControl
)。但在下面,它与操作系统的底层挂钩。要实现这一点,需要大量非托管代码。来自 MFC 等低级 C++ 框架的开发人员确切地知道幕后发生了什么。
Window
和类都是真正的CommonDialog
混合体:它们是 WPF 框架的一部分,但为了表现得像或实际上是本机 OS 窗口,它们也必须是低级 OS 基础结构的一部分。
WPFWindow
以及CommonDialog
类基本上是复杂的低级 OS API 的包装器。这就是为什么这个控件与普通和纯框架控件相比有时会有一种奇怪的感觉(从开发人员的角度来看)。
说Window
是卖的简单ContentControl
,是很有欺骗性的。但由于 WPF 是一个高级框架,所有低级细节都被设计为对 API 隐藏。
我们必须接受我们必须根据Window
和处理控制CommonDialog
仅使用 C# - 并且该代码隐藏根本不违反任何设计模式。
如果您愿意放弃原生外观和通用操作系统集成来获得主题和任务栏等原生功能,您可以通过创建自定义对话框来改进处理,例如,通过扩展Control
or Popup
,将相关属性公开为DependencyProperty
. 然后,您可以像通常那样设置数据绑定和 XAML 触发器来控制可见性。
1.3 为什么选择MVVM?
如果没有复杂的设计模式或应用程序结构,开发人员会例如直接将数据库数据加载到表格控件并将 UI 逻辑与业务逻辑混合。在这种情况下,更改为不同的数据库会破坏 UI。但更糟糕的是,更改 UI 需要更改处理数据库的逻辑。并且在更改逻辑时,您还需要更改相关的单元测试。
真正的应用程序是业务逻辑,而不是花哨的 GUI。
您想为业务逻辑编写单元测试——而不是被迫包含任何 UI。
您想修改 UI 而不修改业务逻辑和单元测试。
MVVM是一种解决问题并允许将 UI 与业务逻辑(即视图中的数据)解耦的模式。它比相关的设计模式MVC和MVP更有效地做到这一点。
我们不希望 UI 渗透到应用程序的较低级别。我们希望将数据与数据表示分离,尤其是它们的呈现(数据视图)。例如,我们希望处理数据库访问,而不必关心使用哪些库或控件来查看数据。这就是我们选择MVVM的原因。为此,我们不能允许在视图以外的组件中实现 UI 逻辑。
1.4 为什么将 UI 逻辑从一个名为的类移动ViewModel
到一个单独的类仍然违反MVVM
通过应用MVVM,您可以有效地将应用程序构建为三个组件:模型、视图和视图模型。理解这种划分或结构与类无关是非常重要的。它是关于应用程序组件的。
您可能会遵循广泛传播的模式来命名或后缀一个类ViewModel
,但您必须知道,视图模型组件通常包含许多类,其中一些没有命名或后缀ViewModel
- 视图模型是一个抽象组件。
示例:
当您从名为的大类中提取功能(例如创建数据源集合)MainViewModel
并将此功能移动到名为 的新类ItemCreator
时,该类ItemCreator
在逻辑上仍然是视图模型组件的一部分。
在类级别上,功能现在在MainViewModel
类之外(而MainViewModel
现在对新类有一个强引用,以便调用代码)。在应用程序级别(架构级别),功能仍然在同一个组件中。
您可以将此示例投影到经常提出的对话服务上:从视图模型中提取对话逻辑到一个名为的专用类DialogService
不会将逻辑移动到视图模型组件之外:视图模型仍然依赖于这个提取的功能。
视图模型仍然参与UI 逻辑,例如通过显式调用“服务”来控制何时显示对话框并控制对话框类型本身(例如,文件打开、文件夹选择、颜色选择器等)。
这一切都需要了解 UI 的业务细节。知识,每个定义不属于视图模型组件。当然,这样的知识引入了从视图模型组件到视图组件的耦合/依赖关系。
职责根本不会改变,因为您命名了一个类DialogService
而不是 eg DialogViewModel
。
因此,这DialogService
是一种反模式,它隐藏了真正的问题:实现了依赖于 UI 并执行 UI 逻辑的视图模型类。
1.5 编写代码隐藏是否违反了MVVM设计模式?
MVVM是一种设计模式,设计模式独立于每个定义库、独立于框架和独立于语言或编译器。因此,在谈论MVVM时,代码隐藏不是主题。
代码隐藏文件绝对是编写 UI 代码的有效上下文。它只是另一个包含 C# 代码的文件。代码隐藏意味着“具有.xaml.cs扩展名的文件”。它也是事件处理程序的唯一位置。而且你不想远离事件。
为什么存在“代码隐藏中没有代码”的口头禅?
对于 WPF、UWP 或 Xamarin 的新手,例如来自 WinForms 等框架的熟练和经验丰富的开发人员,我们必须强调,使用 XAML 应该是编写 UI 代码的首选方式。实现Style
或DataTemplate
使用 C#(例如在代码隐藏文件中)过于复杂,并且生成的代码非常难以阅读 => 难以理解 => 难以维护。
XAML 非常适合此类任务。视觉上冗长的标记风格完美地表达了 UI 的结构。它在这方面做得比 C# 做的要好得多。尽管像 XAML 这样的标记语言可能感觉不如一些或不值得学习,但它绝对是实现 GUI 时的首选。我们应该努力使用 XAML 编写尽可能多的 GUI 代码。
但是这些考虑与MVVM设计模式完全无关。
代码隐藏只是一个编译器概念,由partial
指令(在 C# 中)实现。这就是为什么代码隐藏与任何设计模式无关。这就是为什么 XAML 和 C# 都不能与任何设计模式有任何关系的原因。
2 解决方案
就像 OP 正确得出的结论一样:
“我真的不想在我的 ViewModel 中执行此操作 [打开文件选择器对话框](其中‘浏览’是通过 DelegateCommand 引用的)。因为我认为这违反了MVVM方法。
2.1 一些基本考虑
- 对话框是一个 UI 控件:一个视图。
- 对话框控件或一般控件(例如显示/隐藏)的处理是 UI 逻辑。
- MVVM要求:视图模型不知道 UI 或用户的存在。正因为如此,一个需要视图模型主动等待或调用用户输入的控制流,确实需要重新设计:这是一个严重的违规行为,并打破了MVVM规定的架构边界。
- 显示对话框需要了解何时显示和何时关闭它。
- 显示对话框需要了解 UI 和用户,因为显示对话框的唯一原因是与用户交互。
- 显示对话框需要有关当前 UI 上下文的知识(以便选择适当的对话框类型)。
- 破坏MVVM
OpenFileDialog
模式的不是对程序集或类的依赖,而是视图模型组件或模型组件中 UI 逻辑的实现或引用(尽管这样的依赖可能是一个有价值的提示)。UIElement
- 出于同样的原因,从模型组件中显示对话框也是错误的。
- 唯一负责 UI 逻辑的组件是视图组件。
- 从MVVM的角度来看,没有什么比得上 C#、XAML、C++ 或 VB.NET。这意味着,没有类似
partial
或相关的臭名昭著的代码隐藏文件 (*.xaml.cs)。存在代码隐藏的概念以允许编译器将类的 XAML 部分与其 C# 部分合并。合并之后,两个文件都被视为一个类:这是一个纯编译器概念。partial
是能够使用 XAML 编写类代码的魔法(真正的编译器语言仍然是 C# 或任何其他符合 IL 的语言)。
ICommand
是 .NET 库的接口,因此在谈论MVVM时不是主题。认为每个ICommand
动作都必须由视图模型中的实现触发是错误的。只要保持组件之间的单向依赖关系,
事件仍然是一个非常有用的概念,符合MVVM 。总是强制使用ICommand
而不是使用事件会导致不自然和有异味的代码,就像 OP 提供的代码一样。
- 没有这样的“规则”
ICommand
只能由视图模型类实现。它也可以由视图类实现。
事实上,视图通常实现RoutedCommand
(或RoutedUICommand
),它们都是 的实现ICommand
,也可用于触发来自例如 aWindow
或任何其他控件的对话框的显示。
我们有数据绑定以允许 UI 与视图模型交换数据(匿名,从数据源的角度来看)。但是由于数据绑定不能调用操作(至少在 WPF 中——例如,UWP 允许这样做),我们必须ICommand
实现ICommandSource
这一点。
- 一般来说,接口不是MVVM的相关概念。因此,引入接口(例如
IFileDialogService
)永远无法解决与MVVM相关的问题。
- 服务或助手类不是MVVM的概念。因此,引入服务或帮助类永远无法解决与MVVM相关的问题。
- 类及其名称或类型名称通常与MVVM无关。将视图模型代码移动到单独的类中,即使该类没有命名或后缀为
ViewModel
,也无法解决与MVVM相关的问题。
2.2 结论
解决方案是显示OpenFileDialog
一个类,它是视图组件的一部分。
这意味着,这样的类必须是视图模型未知的类,因此不能被视图模型调用。
这个逻辑可以直接在代码隐藏文件或任何其他类(文件)中实现。实现可以是一个简单的辅助类或更复杂的(附加的)行为。
重点是:对话框,即 UI 组件必须由视图组件单独处理,因为这是唯一包含 UI 相关逻辑的组件。由于视图模型对视图没有任何了解,因此它无法主动与视图通信。只允许被动通信(数据绑定、事件)。
我们总是可以使用视图模型引发的事件来实现特定的流程,这些事件可以被视图观察到,以便采取诸如使用对话框与用户交互之类的操作。
存在使用视图模型优先方法的解决方案,这首先不违反MVVM。但是,设计不当的职责也可以将这个解决方案变成一种反模式。
3 如何解决对某些对话请求的需求
大多数时候,我们可以通过修复应用程序的设计来消除在应用程序中显示对话框的需要。
由于对话框是实现与用户交互的 UI 概念,因此我们必须使用 UI 设计规则评估对话框。
也许最著名的 UI 设计规则是 Nielsen 和 Molich 在 90 年代提出的 10 条规则。
一个重要的规则是关于错误预防:它指出
a) 我们必须防止任何类型的错误,尤其是与输入相关的错误,因为
b) 用户不喜欢他的生产力被错误消息和对话框打断。
a) 表示:输入数据验证。不允许无效数据进入业务逻辑。
b) 意味着:尽可能避免向用户显示对话框。永远不要在应用程序中显示对话框,并让用户显式触发对话框,例如在鼠标单击时(无意外中断)。
遵循这个简单的规则当然总是消除显示由视图模型触发的对话框的需要。
从用户的角度来看,应用程序是一个黑盒子:它输出数据、接受数据并处理输入数据。如果我们控制数据输入以防止无效数据,我们将消除未定义或非法状态并确保数据完整性。这意味着无需从应用程序内部向用户显示对话框。只有那些由用户明确触发的。
例如,一个常见的场景是我们的模型需要将数据持久化在一个文件中。如果目标文件已经存在,我们要让用户确认覆盖这个文件。
遵循防错规则,我们始终让用户首先选择文件:无论是源文件还是目标文件,始终是用户通过文件对话框显式选择文件来指定该文件。这意味着,用户还必须明确触发文件操作,例如通过单击“另存为”按钮。
这样,我们可以使用文件选择器或文件保存对话框来确保只选择现有文件。作为奖励,我们还消除了警告用户覆盖现有文件的需要。
按照这种方法,我们满足了 a)“[...]防止任何类型的错误,尤其是与输入相关的错误”和b)“[...]用户不喜欢被错误消息和对话框打断”。
更新
由于人们质疑您不需要视图模型来处理对话视图这一事实,因此通过提出诸如数据验证之类的额外“复杂”要求来证明他们的观点,我不得不提供更复杂的示例来解决这些问题复杂的场景(OP最初没有要求)。
4 个例子
4.1 概述
该场景是一个简单的输入表单,用于收集用户输入(如专辑名称),然后使用OpenFileDialog
选择保存专辑名称的目标文件。
三个简单的解决方案:
解决方案1:非常简单和基本的场景,符合问题的确切要求。
解决方案 2:能够在视图模型中使用数据验证的解决方案。为简洁起见,省略了 的实现。INotifyDataErrorInfo
解决方案 3:另一个更优雅的解决方案,它使用ICommand
和ICommandSource.CommandParameter
将对话结果发送到视图模型并执行操作。
解决方案 1
以下示例提供了一个简单直观的解决方案,以符合 MVVM 的OpenFileDialog
方式显示。
该解决方案允许视图模型不知道任何 UI 组件或逻辑。
您甚至可以考虑将 a 传递FileStream
给视图模型而不是文件路径。这样,您可以在创建流时直接在 UI 中处理任何错误,例如,在需要时显示对话框。
看法
主窗口.xaml
<Window>
<Window.DataContext>
<MainViewModel />
</Window.DataContext>
<StackPanel>
<!-- The data to persist -->
<TextBox Text="{Binding AlbumName}" />
<!-- Show the file dialog.
Let user explicitly trigger the file save operation.
This button will be disabled until the required input is valid -->
<Button Content="Save as"
Click="AppendAlbumNameToFile_OnClick" />
</StackPanel>
</Window>
主窗口.xaml.cs
partial class MainWindow : Window
{
public MainWindow()
{
InitializeComponent();
}
private void AppendAlbumNameToFile_OnClick(object sender, EventArgs e)
{
var dialog = new OpenFileDialog();
if (dialog.ShowDialog() == true)
{
// Consider to create the FileStream here to handle errors
// related to the user's picked file in the view.
// If opening the FileStream succeeds, we can pass it over to the viewmodel.
string destinationFilePath = dialog.FileName;
(this.DataContext as MainViewModel)?.SaveAlbumName(destinationFilePath);
}
}
}
查看模型
主视图模型.cs
class MainViewModel : INotifyPropertyChanged
{
private string albumName;
public string AlbumName
{
get => this.albumName;
set
{
this.albumName = value;
OnPropertyChanged();
}
}
// A model class that is responsible to persist and load data
private DataRepository DataRepository { get; }
// Default constructor
public MainViewModel() => this.DataRepository = new DataRepository();
// Since 'destinationFilePath' was picked using a file dialog,
// this method can't fail. If it fails, the error is not related to the user input
public void SaveAlbumName(string destinationFilePath)
{
// Use a aggregated/composed model class to persist the data
this.DataRepository.SaveData(this.AlbumName, destinationFilePath);
}
}
解决方案 2
更现实的解决方案是添加一个专用TextBox
的作为输入字段来收集目标文件路径。
一个按钮可以打开可选的文件选择器视图,以允许用户交替浏览文件系统以查找目标路径。
浏览器结果分配给TextBox
绑定到视图模型类的 。通过这种方式,可以验证文件路径,例如,通过实现INotifyDataErrorInfo
:
看法
主窗口.xaml
<Window>
<Window.DataContext>
<MainViewModel />
</Window.DataContext>
<StackPanel>
<!-- The data to persist -->
<TextBox Text="{Binding AlbumName}" />
<!-- Alternative file path input, validated using INotifyDataErrorInfo validation
e.g. using File.Exists to validate the file path -->
<TextBox x:Name="FilePathTextBox"
Text="{Binding DestinationPath, ValidatesOnNotifyDataErrors=True}" />
<!-- Option to search a file using the file picker dialog -->
<Button Content="Browse" Click="PickFile_OnClick" />
<!-- Let user explicitly trigger the file save operation.
This button will be disabled until the required input is valid -->
<Button Content="Save as"
Command="{Binding SaveAlbumNameCommand}" />
</StackPanel>
</Window>
主窗口.xaml.cs
partial class MainWindow : Window
{
public MainWindow()
{
InitializeComponent();
}
private void PickFile_OnClick(object sender, EventArgs e)
{
var dialog = new OpenFileDialog();
if (dialog.ShowDialog() == true)
{
this.FilePathTextBox.Text = dialog.FileName;
// Since setting the property explicitly bypasses the data binding,
// we must explicitly update it by calling BindingExpression.UpdateSource()
this.FilePathTextBox
.GetBindingExpression(TextBox.TextProperty)
.UpdateSource();
}
}
}
查看模型
主视图模型.cs
class MainViewModel : INotifyPropertyChanged, INotifyDataErrorInfo
{
private string albumName;
public string AlbumName
{
get => this.albumName;
set
{
this.albumName = value;
OnPropertyChanged();
}
}
private string destinationPath;
public string DestinationPath
{
get => this.destinationPath;
set
{
this.destinationPath = value;
OnPropertyChanged();
ValidateDestinationFilePath();
}
}
public ICommand SaveAlbumNameCommand => new RelayCommand(
commandParameter =>
{
ExecuteSaveAlbumName(this.TextValue);
},
commandParameter => true);
// A model class that is responsible to persist and load data
private DataRepository DataRepository { get; }
// Default constructor
public MainViewModel() => this.DataRepository = new DataRepository();
private void ExecuteSaveAlbumName(string destinationFilePath)
{
// Use a aggregated/composed model class to persist the data
this.DataRepository.SaveData(this.AlbumName, destinationFilePath);
}
}
解决方案 3
以下解决方案是第二种方案的更优雅版本。它使用该ICommandSource.CommandParameter
属性将对话结果发送到视图模型(而不是前面示例中使用的数据绑定):
看法
主窗口.xaml
<Window x:Name="Window">
<Window.DataContext>
<MainViewModel />
</Window.DataContext>
<StackPanel>
<!-- The data to persist -->
<TextBox Text="{Binding AlbumName}" />
<!-- Alternative file path input, validated using binding validation
e.g. using File.Exists to validate the file path -->
<TextBox x:Name="FilePathTextBox"
Text="{Binding ElementName=Window, Path=DestinationPath, ValidationRules={Binding ElementName=Window, Path=FilePathValidationRules}" />
<!-- Option to search a file using the file picker dialog -->
<Button Content="Browse" Click="PickFile_OnClick" />
<!-- Let user explicitly trigger the file save operation.
This button will be disabled until the required input is valid -->
<Button Content="Save as"
CommandParameter="{Binding ElementName=Window, Path=DestinationPath}"
Command="{Binding SaveAlbumNameCommand}" />
</StackPanel>
</Window>
主窗口.xaml.cs
partial class MainWindow : Window
{
public static readonly DependencyProperty DestinationPathProperty = DependencyProperty.Register(
"DestinationPath",
typeof(string),
typeof(MainWindow),
new PropertyMetadata(default(string)));
public string DestinationPath
{
get => (string)GetValue(MainWindow.DestinationPathProperty);
set => SetValue(MainWindow.DestinationPathProperty, value);
}
public MainWindow()
{
InitializeComponent();
}
private void PickFile_OnClick(object sender, EventArgs e)
{
var dialog = new OpenFileDialog();
if (dialog.ShowDialog() == true)
{
this.DestinationPath = dialog.FileName;
}
}
}
查看模型
主视图模型.cs
class MainViewModel : INotifyPropertyChanged, INotifyDataErrorInfo
{
private string albumName;
public string AlbumName
{
get => this.albumName;
set
{
this.albumName = value;
OnPropertyChanged();
}
}
public ICommand SaveAlbumNameCommand => new RelayCommand(
commandParameter =>
{
ExecuteSaveAlbumName(commandParameter as string);
},
commandParameter => true);
// A model class that is responsible to persist and load data
private DataRepository DataRepository { get; }
// Default constructor
public MainViewModel() => this.DataRepository = new DataRepository();
private void ExecuteSaveAlbumName(string destinationFilePath)
{
// Use a aggregated/composed model class to persist the data
this.DataRepository.SaveData(this.AlbumName, destinationFilePath);
}
}