自适应 WPF ICommand 实现






4.87/5 (15投票s)
创建一个可以处理警告并适应用户 UI 使用方式的命令。
引言
本文讨论了一种使 WPF UI 更用户友好的方法,通过根据用户的使用方式调整警告对话框。这种根据用户偏好改变 UI 行为的方式并非新颖。本文建议的方法侧重于如何通过实现 ICommand
来实现这一点。
背景
当我在 Visual Studio 中按 F5 键,但代码中仍有编译器错误时,会出现这样的对话框:

突出显示的选项允许我告知 IDE,我永远不想运行上次成功的生成。我觉得这个功能非常有用,因为禁用警告的选项是本地于该操作的。这意味着我不必在某个偏好设置页面中查找它的设置。它还告诉我,禁用警告的选项甚至存在,而无需我去找这样的设置。
本文旨在展示如何轻松地将这种行为添加到几乎任何 WPF 命令中。它还将展示如何将跨应用程序实例甚至跨应用程序记住警告的责任从最初产生警告的逻辑中分离出来。
这对我来说很重要,因为我见过很多试图实现相同功能的示例,但都只会搞乱拥有该命令的 ViewModel 的内部聚合性。
Using the Code
下载解决方案,解压缩并打开。该解决方案包含一个类库和一个 WPF 测试应用程序,展示了一个非常简单的示例实现。所有这些都是使用 VS2010 Express Edition 编写的。
问题
假设存在一个命令,它会在用户按下某个 UI 按钮时将某些数据写入文件。如果目标文件不存在,命令会创建它;如果文件已存在,命令会覆盖它。
在这种情况下,可以合理地警告用户文件将被创建(尽管这不那么重要,因为用户应该预料到这一点),以及(更重要的是)文件将被覆盖。其他警告也可能适用,例如使用了错误或奇怪的文件名。
在 ViewModel 中实现此类命令可能如下所示:
public class ViewModel
{
public ICommand SaveFileCommand { get; private set; }
public ViewModel()
{
SaveFileCommand = new SomeCommand(x => true, SaveFile);
}
private void SaveFile(object parameter)
{
if (File.Exists(Filename))
{
MessageBoxResult result = MessageBox.Show(
"This will overwrite the file, are you sure you want to do this?", #
"Warning",
MessageBoxButton.YesNo);
if (result == MessageBoxResult.No)
return;
}
else
{
MessageBoxResult result = MessageBox.Show(
"This will create a new file, are you sure you want to do this?",
"Warning",
MessageBoxButton.YesNo);
if (result == MessageBoxResult.No)
return;
}
File.WriteAllText(Filename, "Go do that voodoo that you do so well.");
}
}
假设 SomeCommand
是一个以合理方式实现 ICommand
的类。
在我看来,这种方法的缺点在于它向 ViewModel 添加了非常 UI 特定的元素。当然,对话框的创建可以,也应该,通过某个接口进行抽象,以允许 ViewModel 进行单元测试,但这并没有改变创建和检查对话框结果是 ViewModel 责任的事实。我认为这破坏了良好设计的基石:高内聚。
此外,请注意我甚至还没有考虑到用户应该能够永久忽略警告,并且这种决定需要在应用程序关闭和重新启动后仍被记住。这意味着该偏好需要在某处存储,而这又意味着它必须在启动时从某处加载。如果所有这些代码都必须放在 ViewModel 中,那么良好设计的另一个基石就被破坏了:低耦合。
我的方法
我采用的方法是将加载和持久化以及忽略新警告的责任转移到 ICommand
实现中。这意味着 ViewModel 只负责通知 ICommand
实现警告已被触发,而这属于业务逻辑的核心,应该放在 ViewModel 中。我将实现 ICommand
的类称为 TolerantCommand
。
在我的方法中,ViewModel 通过异常通知 TolerantCommand
。
这意味着显示对话框的责任也落在 TolerantCommand
上,但由于这可以根据应用程序的不同方式实现,因此我决定使用一个名为 IDialogDisplayer
的接口对其进行抽象。
使用 IWarningRepository
接口以同样的方式抽象了持久化和加载偏好的责任。

如果您觉得这个流程图看起来很奇怪,那是因为 Google 尚未在 文档中支持带角度的连接器。
从这个流程图可以明显看出,任何因警告或错误而提前中止的命令都不会改变任何状态,因为对于 ICommand.Execute
的一次调用,该命令可能会被执行任意次数。本质上,这意味着命令实现结构必须是这样的:
if HasWarning_A && IsNotIgnored(A)
throw Warning("A");
if HasWarning_B && IsNotIgnored(B)
throw Warning("B");
// The above is repeated for any number of applicable warnings
// Execute command logic here, nothing may mutate any state
// before this point
SomeLogic();
实现
IWarningRepository
IWarningRepository
的定义相当简单,因为它只需要能够完成三件事:
- 提供当前忽略的警告列表
- 忽略某个警告
- 确认某个警告(取消忽略)
确认警告不是通过对话框完成的,它应该通过某个偏好设置页面可用。我在示例中省略了该实现的实现,因为它非常特定于应用程序。
namespace Bornander.UI.Commands
{
public interface IWarningRepository<T>
{
IEnumerable<T> Ignored { get; }
void Ignore(T warning);
void Acknowledge(T warning);
}
}
请注意,这是一个泛型接口,接受一个类型参数 T
。这是因为我认为警告定义并非总是适用于所有应用程序。在某些情况下,int
会有意义,而在另一些情况下,string
或 enum
会有意义。这完全取决于正在编写的应用程序。在示例应用程序中,我选择了一个枚举,但该实现可以处理任何类型。
IDialogDisplayer
IDialogDisplayer
负责显示对话框(显而易见!),并返回一个结果,指示用户按下了哪个按钮。这与标准的 MessageBox
和 MessageBoxResult
非常相似。
通常,该接口的实现会非常简洁,基本上只是创建一个某种对话框窗口,并根据用户选择返回该值给 TolerantCommand
。无论用户选择什么,结果都会被返回,因为重试或请求持久化偏好的责任在于命令。
namespace Bornander.UI.Commands.Tolerant
{
public enum DialogResult
{
Yes,
YesAndRememberMyDecision,
No
}
public interface IDialogDisplayer
{
DialogResult ShowWarning(CommandWarningException warning);
DialogResult ShowError(CommandRetryableErrorException error);
}
}
该接口暴露了三个方法,用于显示对话框所需的两种不同场景:
ShowWarning(CommandWarningException)
:当警告触发时。ShowError(CommandRetryableErrorException)
:当瞬时错误触发时,用户重试可能会奏效(例如文件被其他人锁定)。
在本篇文章附带的示例应用程序中,对话框看起来像这样:

但这种外观绝不取决于命令实现或支持类型,IDialogDisplayer
可以将实际显示对话框的委托给任何类型的可视化。我认为这是一个重要的方面,因为驱动可重试命令的逻辑应该与任何 UI 特定的代码完全解耦。
TolerantCommand
TolerantCommand
本质上与 本文中的 RelayCommand
相同,但在 ICommand.Execute
实现中增加了逻辑。Execute
方法负责检查执行委托抛出的任何异常,并通过委托给 IDialogDisplayer
来触发对话框,同时还使用 IWarningRepository
来确定要忽略或持久化的警告。
静默执行
由于我有时需要以编程方式执行一个或多个命令(即,不是作为用户操作的结果),TolerantCommand
支持一种*静默*执行模式,在这种模式下,任何未被忽略的警告都会导致命令中止,而不会触发任何对话框。诚然,这种行为在很大程度上是过度的,但我还是决定将其包含在这篇文章中。
在构造 TolerantCommand
时,必须同时传递 IDialogDisplayer
和 IWarningrepository
实例,以及执行委托和可以执行谓词。
public TolerantCommand(IDialogDisplayer dialogDisplayer,
IWarningRepository<T> repository,
Predicate<object> canExecute,
Action<object, IEnumerable<T>> execute)
{
if (execute == null)
throw new ArgumentNullException("execute");
this.dialogDisplayer = dialogDisplayer;
this.repository = repository;
CanExecutePredicate = canExecute;
ExecuteAction = execute;
}
执行委托接受两个参数,而不是一个(如 RelayCommand
实现中),一个是实际的命令参数,另一个是已忽略警告的列表。
Execute
方法本质上是在一个循环中,尝试执行执行委托,直到成功或直到用户因警告而中止。
public void Execute(object parameter)
{
bool isSilent = parameter is ExecuteSilent;
object actualParameter = isSilent ? ((ExecuteSilent)parameter).Parameter : parameter;
IList<T> localIgnorableWarnings = new List<T>(
repository != null ?
repository.Ignored : new T[0]);
while (true)
{
try
{
// Execute the command
ExecuteAction(actualParameter,
isSilent && ((ExecuteSilent)parameter).IgnoreAllWarnings ?
null :
localIgnorableWarnings);
return;
}
catch (CommandWarningException warning)
{
if (isSilent)
return;
// If a warning is thrown, show a dialog.
// If the user accepts the warning the
// command is executed again by letting the loop run
// another iteration, this time with the
// warning ignored
switch (dialogDisplayer.ShowWarning(warning))
{
case DialogResult.No:
return;
case DialogResult.YesAndRememberMyDecision:
// Persist the preference
if (repository != null)
repository.Ignore((T)warning.Warning);
break;
}
localIgnorableWarnings.Add((T)warning.Warning);
}
catch (CommandRetryableErrorException error)
{
if (isSilent)
return;
if (dialogDisplayer.ShowError(error) == DialogResult.No)
return;
// If the result is Yes, then the command runs again
// to cater for transient errors
}
}
}
ExecuteSilent
类是一个包装类,它包装了一个命令参数,允许传递参数,同时仍指示此执行是静默的。由于 ICommand.Execute
方法只接受一个参数,因此需要这样的构造来处理静默执行。
结果
使用 TolerantCommand
实现本文开头示例的最终命令实现如下所示:
private void SaveFile(object parameter, IEnumerable<Warnings> ignorableWarnings)
{
if (File.Exists(Filename) &&
!TolerantCommand<Warnings>.IsWarningIgnored
(Warnings.OverwriteFile, ignorableWarnings))
{
throw new CommandWarningException(
String.Format("This will overwrite the file \"{0}\",
are you sure you want to do that?", Filename),
Warnings.OverwriteFile);
}
if (!Filename.Contains('.') &&
!TolerantCommand<Warnings>.IsWarningIgnored(Warnings.NoFileSuffix, ignorableWarnings))
{
throw new CommandWarningException(
String.Format("The filename
\"{0}\" has no file suffix, are you sure you want keep it like that?", Filename),
Warnings.NoFileSuffix);
}
File.WriteAllText(Filename, "Go do that voodoo that you do so well.");
}
不可否认,代码量看起来并没有少多少,但这主要是因为初始示例没有考虑持久化偏好。此外,目的是创建一个合理的关注点分离,让命令实现负责执行命令逻辑,而不是处理 UI 元素或存储用户偏好,我认为这种方法很好地实现了这一点。
关注点
示例项目包含了 IDialogDisplayer
和 IWarningRepository
接口的实现,但请注意,它们仅仅是示例实现。使用接口抽象这些元素的全部意义在于,没有办法创建足够通用的实现来满足所有应用程序。
例如,IWarningRepository
的示例实现 EnumRepository
处理定义为 enum
的警告,并将它们本地存储给用户和应用程序。但这可能不适合您的应用程序,其中警告可能被定义为 int
或 string
,并且偏好可能需要以不同的方式存储。
谢谢
感谢 George Barbu 对本文的审阅和评论,他对异常处理有一些非常有价值的观点。
历史
- 2011-02-27:初稿